Grabbed dndbeyond's source code

```
~/go/bin/sourcemapper -output ddb -jsurl https://media.dndbeyond.com/character-app/static/js/main.90aa78c5.js
```
This commit is contained in:
2025-05-28 11:50:03 -07:00
commit 8df9031d27
3592 changed files with 319051 additions and 0 deletions
@@ -0,0 +1,270 @@
import clsx from "clsx";
import { HTMLAttributes, ReactNode, useState } from "react";
import {
AbilityManager,
AbilityScoreSupplierData,
DataOrigin,
DataOriginUtils,
HelperUtils,
} from "@dndbeyond/character-rules-engine/es";
import { NumberDisplay } from "~/components/NumberDisplay";
import { useSidebar } from "~/contexts/Sidebar";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { getDataOriginComponentInfo } from "~/subApps/sheet/components/Sidebar/helpers/paneUtils";
import { PaneComponentEnum } from "~/subApps/sheet/components/Sidebar/types";
import DataOriginName from "~/tools/js/smartComponents/DataOriginName";
import { AbilityIcon } from "~/tools/js/smartComponents/Icons";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLDivElement> {
ability: AbilityManager;
showHeader?: boolean;
isReadonly: boolean;
isBuilder?: boolean;
}
export function AbilityScoreManager({
ability,
isReadonly,
showHeader = true,
className = "",
isBuilder = false,
...props
}: Props) {
const { characterTheme: theme, originRef } = useCharacterEngine();
const {
pane: { paneHistoryPush },
} = useSidebar();
const [overrideScore, setOverrideScore] = useState(
ability.getOverrideScore()
);
const [otherBonus, setOtherBonus] = useState(ability.getOtherBonus());
const handleOtherBonusBlur = (
evt: React.FocusEvent<HTMLInputElement>
): void => {
let value: number | null = HelperUtils.parseInputInt(evt.target.value);
setOtherBonus(ability.handleOtherBonusChange(value));
};
const handleOverrideScoreBlur = (
evt: React.FocusEvent<HTMLInputElement>
): void => {
const value: number | null = HelperUtils.parseInputInt(evt.target.value);
setOverrideScore(ability.handleOverrideScoreChange(value));
};
const handleOtherBonusChange = (
evt: React.ChangeEvent<HTMLInputElement>
): void => {
setOtherBonus(HelperUtils.parseInputInt(evt.target.value));
};
const handleOverrideScoreChange = (
evt: React.ChangeEvent<HTMLInputElement>
): void => {
setOverrideScore(HelperUtils.parseInputInt(evt.target.value));
};
const handleDataOriginClick = (dataOrigin: DataOrigin) => {
let component = getDataOriginComponentInfo(dataOrigin);
if (component.type !== PaneComponentEnum.ERROR_404) {
paneHistoryPush(component.type, component.identifiers);
}
};
const label = ability.getLabel();
const modifier = ability.getModifier();
const baseScore = ability.getBaseScore();
const statId = ability.getId();
const totalScore = ability.getTotalScore();
const speciesBonus = ability.getRacialBonus();
const classBonuses = ability.getClassBonuses();
const miscBonus = ability.getMiscBonus();
const stackingBonus = ability.getStackingBonus();
const setScore = ability.getSetScore();
const allStatsBonusSuppliers = ability.getAllStatBonusSuppliers();
const statSetScoreSuppliers = ability.getStatSetScoreSuppliers();
const stackingBonusSuppliers = ability.getStackingBonusSuppliers();
const getSupplierData = (
suppliers: Array<AbilityScoreSupplierData>
): ReactNode => {
return (
<div className={styles.suppliers}>
{suppliers.map((supplier) => {
const origin = supplier.dataOrigin;
let expandedOrigin: DataOrigin | null = null;
if (supplier.expandedOriginRef) {
const primaryOrigin = DataOriginUtils.getRefPrimary(
supplier.expandedOriginRef,
originRef
);
if (primaryOrigin && primaryOrigin["componentId"] !== null) {
expandedOrigin = primaryOrigin["dataOrigin"];
}
}
return (
<div
className={clsx(styles.supplier, isBuilder && styles.builder)}
key={supplier.key}
>
<div className={styles.label}>
<DataOriginName
className={isBuilder ? styles.builder : ""}
dataOrigin={origin}
tryParent
theme={theme}
onClick={!isBuilder ? handleDataOriginClick : undefined}
/>
{expandedOrigin && (
<span>
{" "}
(
<DataOriginName
className={isBuilder ? styles.builder : ""}
dataOrigin={expandedOrigin}
tryParent
theme={theme}
onClick={!isBuilder ? handleDataOriginClick : undefined}
/>
)
</span>
)}
</div>
<div className={styles.value}>
(
{supplier.type === "set" ? (
supplier.value
) : (
<NumberDisplay
className={styles.number}
type="signed"
number={supplier.value}
/>
)}
)
</div>
</div>
);
})}
</div>
);
};
return (
<div {...props}>
{showHeader && (
<div className={styles.header}>
<AbilityIcon
statId={statId}
themeMode="light"
className={styles.icon}
/>
<div className={styles.name}>{label}</div>
</div>
)}
<div className={styles.table}>
<div className={clsx([styles.row, isBuilder && styles.highlight])}>
<div className={styles.label}>Total Score</div>
<div className={styles.value}>
{totalScore === null ? "--" : totalScore}
</div>
</div>
<div className={clsx([styles.row, isBuilder && styles.highlight])}>
<div className={styles.label}>Modifier</div>
<div className={styles.value}>
<NumberDisplay type="signed" number={modifier} />
</div>
</div>
<div className={styles.row}>
<div className={styles.label}>Base Score</div>
<div className={styles.value}>
{baseScore === null ? "--" : baseScore}
</div>
</div>
<div
className={clsx(
styles.row,
allStatsBonusSuppliers.length > 0 && styles.hasSuppliers
)}
>
<div className={styles.label}>
<div>Bonus</div>
</div>
<div className={styles.value}>
<NumberDisplay
type="signed"
number={classBonuses + speciesBonus + miscBonus}
/>
</div>
</div>
{getSupplierData(allStatsBonusSuppliers)}
<div
className={clsx(
styles.row,
statSetScoreSuppliers.length > 0 && styles.hasSuppliers
)}
>
<div className={styles.label}>
<div>Set Score</div>
</div>
<div className={styles.value}>{setScore}</div>
</div>
{getSupplierData(statSetScoreSuppliers)}
<div
className={clsx(
styles.row,
stackingBonusSuppliers.length > 0 && styles.hasSuppliers
)}
>
<div className={styles.label}>
<div>Stacking Bonus</div>
</div>
<div className={styles.value}>
<NumberDisplay type="signed" number={stackingBonus} />
</div>
</div>
{getSupplierData(stackingBonusSuppliers)}
</div>
<div className={styles.overrides}>
<div className={styles.override}>
<div className={styles.label}>Other Modifier</div>
<div className={styles.value}>
<input
type="number"
value={otherBonus === null ? "" : otherBonus}
placeholder="--"
onChange={handleOtherBonusChange}
onBlur={handleOtherBonusBlur}
readOnly={isReadonly}
/>
</div>
</div>
<div className={styles.override}>
<div className={styles.label}>Override Score</div>
<div className={styles.value}>
<input
type="number"
value={overrideScore === null ? "" : overrideScore}
placeholder="--"
onChange={handleOverrideScoreChange}
onBlur={handleOverrideScoreBlur}
readOnly={isReadonly}
/>
</div>
</div>
</div>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import clsx from "clsx";
import {
FC,
HTMLAttributes,
MouseEvent,
ReactNode,
useEffect,
useRef,
useState,
} from "react";
import { v4 as uuidv4 } from "uuid";
import ChevronDown from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-down.svg";
import styles from "./styles.module.css";
export interface AccordionProps extends HTMLAttributes<HTMLElement> {
variant?: "default" | "text" | "paper";
summary: ReactNode;
description?: ReactNode;
size?: "small" | "medium";
forceShow?: boolean;
useTheme?: boolean;
resetOpen?: boolean;
override?: boolean | null;
handleIsOpen?: (id: string, isOpen: boolean) => void;
summaryAction?: ReactNode;
summaryImage?: string;
summaryImageAlt?: string;
summaryImageSize?: number;
summaryMetaItems?: Array<ReactNode>;
showAlert?: boolean;
}
export const Accordion: FC<AccordionProps> = ({
className,
summary,
description,
size = "medium",
variant = "default",
children,
forceShow = false,
// style,
useTheme,
resetOpen,
handleIsOpen,
override = null,
id = "",
summaryAction,
summaryImage,
summaryImageAlt,
summaryImageSize = 32,
summaryMetaItems,
showAlert,
...props
}) => {
const summaryRef = useRef<HTMLElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const loaded = useRef(false);
const [isOpen, setIsOpen] = useState<boolean>(forceShow);
const [height, setHeight] = useState<number | string>();
const handleToggle = (e: MouseEvent) => {
e.preventDefault();
setIsOpen((current) => {
if (handleIsOpen && id) handleIsOpen(id, !current);
return !current;
});
};
useEffect(() => {
const summaryHeight = summaryRef.current?.clientHeight;
const contentHeight = contentRef.current?.getBoundingClientRect().height;
if (isOpen) {
// If the dialog is open and it's not the first load, set the height to the summary + content
if (summaryHeight && contentHeight && loaded.current === true)
setHeight(summaryHeight + contentHeight);
} else {
// If the dialog is closed, set the height to the summary
if (summaryHeight) setHeight(summaryHeight);
}
loaded.current = true;
}, [isOpen, resetOpen]);
useEffect(() => {
if (resetOpen && !isOpen) {
setIsOpen(true);
}
}, [resetOpen]);
useEffect(() => {
if (override !== null) {
setIsOpen(override);
}
}, [override]);
return (
<details
className={clsx([
styles.details,
styles[size],
styles[variant],
useTheme && styles.theme,
showAlert && styles.alert,
className,
])}
open={isOpen}
// style={{ height, ...style }} INVESTIGATE THIS LATER
{...props}
>
<summary
className={styles.summary}
onClick={handleToggle}
ref={summaryRef}
>
{summaryImage && (
<img
className={styles.image}
src={summaryImage}
alt={summaryImageAlt}
width={summaryImageSize}
/>
)}
<div className={styles.heading}>
<div>
{summary}
{summaryMetaItems && (
<div className={styles.metaItems}>
{summaryMetaItems.map((metaItem, idx) => (
<div key={uuidv4()} className={styles.metaItem}>
{metaItem}
</div>
))}
</div>
)}
</div>
<div className={styles.actions}>{summaryAction}</div>
<ChevronDown className={styles.icon} />
</div>
{description && <div className={styles.description}>{description}</div>}
</summary>
<div className={styles.content} ref={contentRef}>
{children}
</div>
</details>
);
};
+59
View File
@@ -0,0 +1,59 @@
import clsx from "clsx";
import { FC } from "react";
import {
Button as TtuiButton,
ButtonProps as TtuiButtonProps,
} from "@dndbeyond/ttui/components/Button";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { ThemeMode } from "~/types";
import styles from "./styles.module.css";
export interface ButtonProps
extends Omit<TtuiButtonProps, "size" | "variant" | "color"> {
size?: "xx-small" | TtuiButtonProps["size"];
themed?: boolean;
forceThemeMode?: ThemeMode;
variant?: "builder" | "builder-text" | TtuiButtonProps["variant"];
color?: "builder-green" | TtuiButtonProps["color"];
}
export const Button: FC<ButtonProps> = ({
className,
size = "medium",
themed,
variant = "solid",
forceThemeMode,
color,
...props
}) => {
// Check if the button has a custom size
const isCustomSize = size === "xx-small";
const isCustomVariant = variant === "builder" || variant === "builder-text";
const isCustomColor = color === "builder-green";
const { isDarkMode } = useCharacterTheme();
return (
<TtuiButton
// If the button has a custom size, use local styles
className={clsx([
styles[size],
themed && styles.themed,
forceThemeMode && styles[forceThemeMode],
styles[variant],
!forceThemeMode && isDarkMode && styles.dark,
isCustomColor && styles[color],
className,
])}
// If the button has a normal size, pass prop normally
size={!isCustomSize ? size : undefined}
// If the button is themed, use theme colors
variant={!isCustomVariant ? variant : undefined}
// Children are included in props
color={!isCustomColor ? color : undefined}
{...props}
/>
);
};
+82
View File
@@ -0,0 +1,82 @@
import clsx from "clsx";
import { ChangeEvent, FC, useEffect, useState } from "react";
import {
Checkbox as TtuiCheckbox,
CheckboxProps as TtuiCheckboxProps,
} from "@dndbeyond/ttui/components/Checkbox";
import styles from "./styles.module.css";
interface CheckboxProps extends TtuiCheckboxProps {
themed?: boolean;
darkMode?: boolean;
variant?: "default" | "builder" | "sidebar";
onClick?: (isEnabled: boolean) => void;
onChangePromise?: (
newIsEnabled: boolean,
accept: () => void,
reject: () => void
) => void;
}
export const Checkbox: FC<CheckboxProps> = ({
className,
themed,
variant = "default",
darkMode,
checked = false,
onChangePromise,
onClick,
disabled,
...props
}) => {
const [isChecked, setIsChecked] = useState(checked);
useEffect(() => {
setIsChecked(checked);
}, [checked]);
const handleChange = (evt: ChangeEvent<HTMLInputElement>): void => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
const newCheckedState = !isChecked;
// promise-based logic
if (onChangePromise) {
onChangePromise(
newCheckedState,
() => {
// Promise accepted, update the state
setIsChecked(newCheckedState);
},
() => {
// Promise rejected, do nothing
}
);
} else {
// Update the state immediately if no promise handling - only IF onClick is provided
if (onClick) {
onClick(newCheckedState);
setIsChecked(newCheckedState);
}
}
};
return (
<TtuiCheckbox
className={clsx([
themed && styles.themed,
darkMode && styles[darkMode ? "darkMode" : "lightMode"],
className,
variant === "default" && styles.default,
variant === "builder" && styles.builder,
])}
onChange={handleChange}
aria-pressed={isChecked}
checked={isChecked}
disabled={disabled}
{...props}
/>
);
};
@@ -0,0 +1,62 @@
import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode, useState } from "react";
import { HtmlContent } from "../HtmlContent";
import styles from "./styles.module.css";
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
children: ReactNode;
className?: string;
forceShow?: boolean;
heading?: ReactNode;
maxLength?: number;
}
/**
* A component that will display a "Show More" button if the content is longer
* than the maxLength prop. It will also accept a heading prop to display text
* above the content.
*/
export const CollapsibleContent: FC<Props> = ({
forceShow,
className,
children,
heading,
maxLength = 600,
...props
}) => {
const [isOpen, setIsOpen] = useState(forceShow);
const handleToggleClick = (): void => {
setIsOpen((prev) => !prev);
};
if (typeof children === "string" && children?.length <= maxLength && !heading)
return (
<HtmlContent html={children as string} className={className} {...props} />
);
return (
<div
className={clsx([
styles.collapsible,
heading && styles.withHeading,
isOpen && styles.open,
className,
])}
{...props}
>
{heading && <div>{heading}</div>}
{typeof children === "string" ? (
<HtmlContent className={styles.content} html={children as string} />
) : (
<div className={styles.content}>{children}</div>
)}
{!forceShow && (
<button className={styles.button} onClick={handleToggleClick}>
{isOpen ? "Show Less" : "Show More"}
</button>
)}
</div>
);
};
@@ -0,0 +1,116 @@
import clsx from "clsx";
import { FC, useEffect } from "react";
import CloseIcon from "@dndbeyond/fontawesome-cache/svgs/solid/x.svg";
import { Dialog, DialogProps } from "@dndbeyond/ttui/components/Dialog";
import { Button, ButtonProps } from "../Button";
import styles from "./styles.module.css";
/**
* @heading Text for the title of the modal - defaults to "Confirm"
* @onClose Function to call when the Close button is clicked
* @onConfirm Function to call when the Confirm button is clicked
* @confirmButtonText Provide text to the Confirm button - defaults to "Confirm"
* @closeButtonText Provide text to the Close button - defaults to "Cancel"
* @variant "default" | "remove" | "confirm-only"
* --default: default modal with both Confirm and Close buttons using the "success" button color
* --remove: modal with both Confirm and Close buttons using the "secondary" color for the header and Confirm button
* --confirm-only: modal with only the Confirm button using the "success" button color
* @size "default" | "fit-content"
* --default: default modal size
* --fit-content: modal size adjusts to the content
* @color Passes a ButtonProps color to the confirm button
* @useMobileFullScreen Boolean to set the modal to full screen on mobile breakpoint
*/
export interface ConfirmModalProps extends DialogProps {
heading?: string;
onClose: () => void;
onConfirm: () => void;
confirmButtonText?: string;
closeButtonText?: string;
variant?: "default" | "remove" | "confirm-only";
size?: "default" | "fit-content";
color?: ButtonProps["color"];
useMobileFullScreen?: boolean;
}
export const ConfirmModal: FC<ConfirmModalProps> = ({
children,
className,
heading = "Confirm",
onClose,
onConfirm,
confirmButtonText = "Confirm",
closeButtonText = "Cancel",
open,
variant = "default",
size = "default",
color = "success",
useMobileFullScreen,
...props
}) => {
const isConfirmOnly = variant === "confirm-only";
useEffect(() => {
const body = document.querySelector("body");
if (!body) return;
// Prevent scrolling when modal is visible
if (open) {
body.style.overflow = "hidden";
} else {
body.style.overflow = "";
}
}, [open]);
return (
<Dialog
className={clsx([
styles.confirmModal,
useMobileFullScreen && styles.fullScreen,
styles[variant],
styles[size],
className,
])}
onClose={onClose}
modal
open={open}
{...props}
>
<header className={styles.header}>
<h2>{heading}</h2>
<Button
className={styles.closeButton}
size="x-small"
variant="text"
onClick={onClose}
aria-label={`Close ${heading} Modal`}
forceThemeMode="light"
>
<CloseIcon className={styles.closeIcon} />
</Button>
</header>
<div className={styles.content}>{children}</div>
<footer className={styles.footer}>
{!isConfirmOnly && (
<Button className={styles.footerButton} onClick={onClose}>
{closeButtonText}
</Button>
)}
<Button
className={clsx([
styles.footerButton,
isConfirmOnly && styles.confirmOnly,
])}
color={variant === "remove" ? "secondary" : color}
onClick={onConfirm}
forceThemeMode="light"
size={isConfirmOnly ? "x-small" : undefined}
>
{confirmButtonText}
</Button>
</footer>
</Dialog>
);
};
@@ -0,0 +1,40 @@
import clsx from "clsx";
import { HTMLAttributes } from "react";
import { useSelector } from "react-redux";
import Pen from "@dndbeyond/fontawesome-cache/svgs/light/pen.svg";
import { appEnvSelectors } from "../../tools/js/Shared/selectors";
import styles from "./styles.module.css";
interface EditableNameProps extends HTMLAttributes<HTMLDivElement> {
onClick: () => void;
}
/**
* Component which displays children with an edit button to the right. It is
* used in panes to give custom names to items, etc.
*/
export const EditableName = ({
children,
className,
onClick,
...props
}: EditableNameProps) => {
const isReadOnly = useSelector(appEnvSelectors.getIsReadonly);
return (
<div className={clsx([styles.editableName, className])} {...props}>
<div>{children}</div>
{!isReadOnly && (
<button
className={styles.button}
onClick={onClick}
aria-label="Edit name"
>
<Pen className={styles.icon} />
</button>
)}
</div>
);
};
@@ -0,0 +1,306 @@
import { FC, HTMLAttributes } from "react";
import { BuilderChoiceTypeEnum } from "~/constants";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { useRuleData } from "~/hooks/useRuleData";
import { useSource } from "~/hooks/useSource";
import {
DetailChoice,
DetailChoiceFeat,
} from "~/tools/js/Shared/containers/DetailChoice";
import { TypeScriptUtils } from "~/tools/js/Shared/utils";
import {
CharClass,
Choice,
ClassDefinitionContract,
ClassFeature,
Feat,
FeatLookup,
FeatureChoiceOption,
HtmlSelectOptionGroup,
SourceData,
} from "~/types";
export interface FeatureChoiceProps extends HTMLAttributes<HTMLDivElement> {
choice: Choice;
charClass?: CharClass;
feature?: ClassFeature;
featsData: Feat[];
subclassData?: ClassDefinitionContract[];
onChoiceChange: (
choiceId: string,
type: number,
value: any,
parentChoiceId: string | null
) => void;
collapseDescription?: boolean;
}
/**
* Component for rendering a features choice(s) - for species traits and class features - using DetailChoiceFeat for a selected feat's choices and DetailChoice for all others.
It is used in both the builder and character sheet sidebar panes.
*/
export const FeatureChoice: FC<FeatureChoiceProps> = ({
charClass,
choice,
feature,
featsData,
subclassData,
onChoiceChange,
className,
collapseDescription,
...props
}) => {
const {
choiceUtils,
featUtils,
helperUtils,
prerequisiteUtils,
classUtils,
featLookup,
preferences,
prerequisiteData,
choiceInfo,
ruleData,
entityRestrictionData,
} = useCharacterEngine();
const { ruleDataUtils } = useRuleData();
const {
getGroupedOptionsBySourceCategory,
getSimpleSourcedDefinitionContracts,
} = useSource();
const optionValue = choiceUtils.getOptionValue(choice);
const options = choiceUtils.getOptions(choice);
const type = choiceUtils.getType(choice);
const tagConstraints = choiceUtils.getTagConstraints(choice);
let availableOptions: Array<FeatureChoiceOption> = [];
let availableGroupedOptions: HtmlSelectOptionGroup[] = [];
let detailChoiceDesc: string | null = null;
let subchoicesNode: React.ReactNode;
const handleChoiceChange = (
id: string,
type: number,
subType: number | null,
value: any,
parentChoiceId: string | null
): void => {
onChoiceChange(id, type, value, parentChoiceId);
};
const getSubclassData = (): ClassDefinitionContract[] => {
if (!subclassData || !charClass) {
return [];
}
let data: ClassDefinitionContract[] = [...subclassData];
let existingSubclass = classUtils.getSubclass(charClass);
if (
existingSubclass !== null &&
!data.some(
(classDefinition) =>
existingSubclass !== null &&
classDefinition.id === existingSubclass.id
)
) {
data.push(existingSubclass);
}
return data;
};
const getAvailableFeatChoices = (
existingFeatId: number | null,
featData: Feat[],
featLookup: FeatLookup
): Feat[] => {
let data: Feat[] = [...featData];
if (existingFeatId !== null) {
let existingFeat = helperUtils.lookupDataOrFallback(
featLookup,
existingFeatId
);
if (
existingFeat !== null &&
!data.some((feat) => featUtils.getId(feat) === existingFeatId)
) {
data.push(existingFeat);
}
}
return data;
};
const getSubclassSources = (
subclass: ClassDefinitionContract
): SourceData[] => {
if (subclass.sources === null) {
return [];
}
return subclass.sources
.map((sourceMapping) =>
helperUtils.lookupDataOrFallback(
ruleDataUtils.getSourceDataLookup(ruleData),
sourceMapping.sourceId
)
)
.filter(TypeScriptUtils.isNotNullOrUndefined);
};
switch (type) {
case BuilderChoiceTypeEnum.FEAT_CHOICE_OPTION:
const availableFeats = getAvailableFeatChoices(
optionValue,
featsData,
featLookup
);
const repeatableFeatTracker = new Set();
// Add selected feat to repeatable tracker if repeatable
const selectedFeat = optionValue ? featLookup[optionValue] : null;
if (selectedFeat && featUtils.isRepeatable(selectedFeat)) {
const parentId = featUtils.getRepeatableGroupId(selectedFeat);
if (parentId) {
repeatableFeatTracker.add(parentId);
}
}
const filteredFeats = availableFeats.filter((feat) => {
const featId = featUtils.getId(feat);
const isRepeatable = featUtils.isRepeatable(feat);
// If the feat is the currently selected, always include it
if (featId === optionValue) {
return true;
}
// Always exclude all previous selected feats
if (featLookup[featId]) {
return false;
}
// If the Feat does not meet the tag constraints, should they exist, exclude it
const tagCategories = featUtils.getCategories(feat);
if (
tagConstraints &&
!featUtils.doesSatisfyTagConstraints(tagCategories, tagConstraints)
) {
return false;
}
// Handle prerequisites when enforcing feat rules
if (
preferences.enforceFeatRules &&
!prerequisiteUtils.validatePrerequisiteGrouping(
featUtils.getPrerequisites(feat),
prerequisiteData
)
) {
return false;
}
// Special handling for repeatable feats, there can be only one
if (isRepeatable) {
const parentId = featUtils.getRepeatableGroupId(feat);
// If a feat from this repeatable group exist exclude all others
if (repeatableFeatTracker.has(parentId)) {
return false;
}
repeatableFeatTracker.add(parentId);
}
// If none of the exclusions above are met, include the feat
return true;
});
//Group available feats by source category
availableGroupedOptions = getGroupedOptionsBySourceCategory(
filteredFeats
.map((feat) => featUtils.getDefinition(feat))
.filter(TypeScriptUtils.isNotNullOrUndefined)
);
if (selectedFeat && optionValue !== null) {
detailChoiceDesc = featUtils.getDescription(selectedFeat);
subchoicesNode = <DetailChoiceFeat featId={optionValue} />;
}
break;
case BuilderChoiceTypeEnum.SUB_CLASS_OPTION:
const subclassData = getSubclassData();
//Group available subclasses by source category
availableGroupedOptions = getGroupedOptionsBySourceCategory(subclassData);
const chosenSubclass = subclassData.find(
(subclass) => subclass.id === optionValue
);
if (chosenSubclass) {
detailChoiceDesc = "";
let sources = getSubclassSources(chosenSubclass);
sources.forEach((source) => {
if (source.sourceCategory && source.sourceCategory.isToggleable) {
detailChoiceDesc += source.sourceCategory.description
? source.sourceCategory.description
: "";
}
});
}
break;
case BuilderChoiceTypeEnum.ENTITY_SPELL_OPTION:
// Map over the options to mock parts of a SpellDefinitionContract.
const spellOptions = getSimpleSourcedDefinitionContracts(options);
availableGroupedOptions = getGroupedOptionsBySourceCategory(
spellOptions,
optionValue,
entityRestrictionData
);
// If there is a chosen spell, set detailChoiceDesc to its description.
const chosenSpell = spellOptions.find(
(spell) => spell.id === optionValue
);
if (chosenSpell) {
detailChoiceDesc = chosenSpell.description ?? "";
}
break;
default:
availableOptions = options.map((option) => ({
...option,
value: option.id,
}));
}
return (
<div className={className} {...props}>
<DetailChoice
{...choice}
choice={choice}
options={
availableGroupedOptions.length > 0
? availableGroupedOptions
: availableOptions
}
onChange={handleChoiceChange}
description={detailChoiceDesc || ""}
choiceInfo={choiceInfo}
classId={charClass && classUtils.getId(charClass)}
showBackgroundProficiencyOptions={true}
collapseDescription={collapseDescription}
/>
{subchoicesNode}
</div>
);
};
@@ -0,0 +1,214 @@
import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode } from "react";
import { useFeatureFlags } from "~/contexts/FeatureFlag";
import { Search } from "~/subApps/builder/components/Search";
import Checkbox from "~/tools/js/smartComponents/Checkbox";
import { Accordion } from "../Accordion";
import { Button } from "../Button";
import styles from "./styles.module.css";
export interface FilterButtonData {
label: ReactNode;
type: number | string;
className?: string;
sortOrder?: number;
}
export interface FilterCheckboxData {
label: string;
type: string;
initiallyEnabled: boolean;
onChange: () => void;
className?: string;
}
export interface FilterGroupProps extends HTMLAttributes<HTMLDivElement> {
filterQuery: string;
onQueryChange: (value: string) => void;
searchPlaceholder?: string;
filterButtonData: Array<FilterButtonData>;
activeFilterButtonTypes: Array<number | string>;
onFilterButtonClick: (filterType: number | string) => void;
filterCheckboxData?: Array<FilterCheckboxData>;
sourceCategoryButtonData: Array<FilterButtonData>;
onSourceCategoryClick: (categoryId: number) => void;
activeFilterSourceCategories: Array<number>;
themed?: boolean;
buttonGroupLabel: string;
buttonSize?: "x-small" | "xx-small";
filterStyle?: "builder";
shouldOpenFilterButtons?: boolean;
shouldOpenSourceCategoryButtons?: boolean;
onSourceCategoriesCollapse?: () => void;
onFilterButtonsCollapse?: () => void;
}
export const FilterGroup: FC<FilterGroupProps> = ({
filterQuery,
searchPlaceholder = "Search",
filterButtonData,
activeFilterButtonTypes,
onQueryChange,
onFilterButtonClick,
filterCheckboxData,
sourceCategoryButtonData,
onSourceCategoryClick,
activeFilterSourceCategories,
themed,
buttonGroupLabel,
buttonSize = "xx-small",
filterStyle,
shouldOpenFilterButtons,
shouldOpenSourceCategoryButtons,
onSourceCategoriesCollapse,
onFilterButtonsCollapse,
className,
...props
}) => {
const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
onQueryChange(event.target.value);
};
const handleSourceCategoryClick = (evt: React.MouseEvent, id: number) => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onSourceCategoryClick(id);
};
const handleFilterButtonClick = (
evt: React.MouseEvent,
type: string | number
) => {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
onFilterButtonClick(type);
};
return (
<div {...props}>
<div className={styles.filterGroup}>
<label htmlFor="filter-input" className={styles.filterLabel}>
Filter
</label>
<Search
id="filter-input"
value={filterQuery}
onChange={handleQueryChange}
placeholder={searchPlaceholder}
/>
</div>
{/* FILTER BUTTONS */}
<div className={styles.filterGroup}>
<Accordion
variant="text"
size="small"
summary={<div className={styles.filterLabel}>{buttonGroupLabel}</div>}
className={styles.accordion}
forceShow={shouldOpenFilterButtons}
onClick={onFilterButtonsCollapse}
>
<div className={styles.buttonGroup}>
{filterButtonData.map((button) => {
return (
<div
className={clsx([button.className, styles.button])}
key={button.type}
>
<Button
color={
filterStyle === "builder" ? "builder-green" : undefined
}
themed={themed}
variant={
activeFilterButtonTypes.includes(button.type)
? "solid"
: "outline"
}
size={buttonSize}
onClick={(evt) => handleFilterButtonClick(evt, button.type)}
>
{button.label}
</Button>
</div>
);
})}
</div>
{filterCheckboxData && filterCheckboxData.length > 0 && (
<div className={styles.checkboxGroup}>
{filterCheckboxData.map((checkbox) => {
return (
<div
className={clsx([
styles.checkbox,
filterStyle === "builder" && styles.builder,
])}
key={checkbox.type}
>
<Checkbox
stopPropagation={true}
initiallyEnabled={checkbox.initiallyEnabled}
onChange={checkbox.onChange}
label={checkbox.label}
/>
</div>
);
})}
</div>
)}
</Accordion>
</div>
{/* SOURCE CATEGORY FILTERS */}
<div className={styles.filterGroup}>
<Accordion
variant="text"
size="small"
summary={
<div className={styles.filterLabel}>Filter By Source Category</div>
}
className={styles.accordion}
forceShow={shouldOpenSourceCategoryButtons}
onClick={onSourceCategoriesCollapse}
>
<div className={styles.buttonGroup}>
{sourceCategoryButtonData.map((button) => {
return (
<div
className={clsx([
button.className,
styles.button,
styles.sourceCategoryButton,
filterStyle === "builder" && styles.builder,
])}
key={button.type}
>
<Button
color={
filterStyle === "builder" ? "builder-green" : undefined
}
themed={themed}
variant={
activeFilterSourceCategories.includes(
button.type as number
)
? "solid"
: "outline"
}
size={buttonSize}
onClick={(evt) =>
handleSourceCategoryClick(evt, button.type as number)
}
>
{button.label}
</Button>
</div>
);
})}
</div>
</Accordion>
</div>
</div>
);
};
@@ -0,0 +1,38 @@
import { FC } from "react";
import { v4 as uuidv4 } from "uuid";
import { BuilderHelperTextInfoContract } from "@dndbeyond/character-rules-engine";
import { DdbBadgeSvg } from "~/tools/js/smartComponents/Svg";
import { Accordion, AccordionProps } from "../Accordion";
import { HtmlContent } from "../HtmlContent";
import styles from "./styles.module.css";
export interface HelperTextAccordionProps
extends Omit<AccordionProps, "summary"> {
builderHelperText: BuilderHelperTextInfoContract[];
}
export const HelperTextAccordion: FC<HelperTextAccordionProps> = ({
builderHelperText,
...props
}) => {
return (
<>
{builderHelperText.map((helperText, idx) => (
<Accordion
{...props}
key={uuidv4()}
summary={
<span className={styles.summary}>
<DdbBadgeSvg /> {helperText.label}
</span>
}
>
<HtmlContent html={helperText.description} />
</Accordion>
))}
</>
);
};
@@ -0,0 +1,25 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { FormatUtils } from "@dndbeyond/character-rules-engine/es";
interface HtmlContentProps extends HTMLAttributes<HTMLDivElement> {
html: string;
className?: string;
withoutTooltips?: boolean;
}
export const HtmlContent: FC<HtmlContentProps> = ({
html,
className = "",
withoutTooltips,
...props
}) => (
<div
className={clsx(["ddbc-html-content", className])}
dangerouslySetInnerHTML={{
__html: withoutTooltips ? FormatUtils.stripTooltipTags(html) : html,
}}
{...props}
/>
);
+35
View File
@@ -0,0 +1,35 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { InfoItem as TtuiInfoItem } from "@dndbeyond/ttui/components/InfoItem";
import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick";
import styles from "./styles.module.css";
interface InfoItemProps extends HTMLAttributes<HTMLElement> {
label: string;
color?: "primary"; // Not needed here, but fixes ts error
inline?: boolean;
}
/**
* This component is an attribute for a given item, spell, or other entity.
* The InfoItemList component is a customized version of the InfoItem component from
* the @dndbeyond/ttui library.
**/
export const InfoItem: FC<InfoItemProps> = ({
className,
onClick,
...props
}) => {
const handleClick = useUnpropagatedClick(onClick);
return (
<TtuiInfoItem
className={clsx([styles.item, className])}
onClick={onClick ? handleClick : undefined}
{...props}
/>
);
};
@@ -0,0 +1,152 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { useFiltersContext } from "~/contexts/Filters";
import { SimpleSourceCategoryContract } from "~/types";
import { FilterGroup } from "../FilterGroup";
import {
FilterButtonData,
FilterCheckboxData,
} from "../FilterGroup/FilterGroup";
import styles from "./styles.module.css";
export interface ItemFilterProps extends HTMLAttributes<HTMLDivElement> {
filterQuery: string;
onQueryChange: (value: string) => void;
filterTypes: Array<string>;
onFilterButtonClick: (type: string) => void;
onCheckboxChange: (type: string) => void;
sourceCategories: Array<SimpleSourceCategoryContract>;
onSourceCategoryClick: (categoryId: number) => void;
filterSourceCategories: Array<number>;
filterProficient: boolean;
filterBasic: boolean;
filterMagic: boolean;
filterContainer: boolean;
themed?: boolean;
buttonSize?: "x-small" | "xx-small";
filterStyle?: "builder";
}
export const ItemFilter: FC<ItemFilterProps> = ({
filterQuery,
onQueryChange,
filterTypes,
onFilterButtonClick,
onCheckboxChange,
sourceCategories,
onSourceCategoryClick,
filterSourceCategories,
filterProficient,
filterBasic,
filterMagic,
filterContainer,
themed,
buttonSize = "xx-small",
filterStyle,
className,
...props
}) => {
const {
showItemTypes,
showItemSourceCategories,
setShowItemSourceCategories,
setShowItemTypes,
} = useFiltersContext();
const itemTypes: Array<string> = [
"Armor",
"Potion",
"Ring",
"Rod",
"Scroll",
"Staff",
"Wand",
"Weapon",
"Wondrous item",
"Other Gear",
];
const toggleTypes: Array<string> = [
"Proficient",
"Common",
"Magical",
"Container",
];
const isChecked = (type: string): boolean => {
switch (type) {
case "Proficient":
return filterProficient;
case "Common":
return filterBasic;
case "Magical":
return filterMagic;
case "Container":
return filterContainer;
default:
return false;
}
};
const filterCheckboxData: Array<FilterCheckboxData> = toggleTypes.map(
(type) => {
return {
label: type,
type: type,
onChange: () => onCheckboxChange(type),
initiallyEnabled: isChecked(type),
};
}
);
const filterButtons: Array<FilterButtonData> = itemTypes.map((itemType) => {
return {
type: itemType,
label: itemType === "Wondrous item" ? "Wondrous" : itemType,
className: clsx([
styles.filterButton,
buttonSize === "xx-small" && styles.filterButtonSmall,
]),
};
});
const sourcesData: Array<FilterButtonData> = sourceCategories.map(
(sourceCategory) => {
return {
label: sourceCategory.name,
type: sourceCategory.id,
className: styles.sourceCategoryButton,
sortOrder: sourceCategory.sortOrder,
};
}
);
return (
<FilterGroup
searchPlaceholder={"Weapon, Longsword, Vorpal Longsword, etc."}
filterQuery={filterQuery}
filterButtonData={filterButtons}
activeFilterButtonTypes={filterTypes}
onQueryChange={onQueryChange}
themed={themed}
onFilterButtonClick={onFilterButtonClick}
onSourceCategoryClick={onSourceCategoryClick}
activeFilterSourceCategories={filterSourceCategories}
sourceCategoryButtonData={sourcesData}
buttonGroupLabel={"Filter By Type"}
buttonSize={buttonSize}
filterStyle={filterStyle}
filterCheckboxData={filterCheckboxData}
shouldOpenSourceCategoryButtons={showItemSourceCategories}
shouldOpenFilterButtons={showItemTypes}
onFilterButtonsCollapse={() => setShowItemTypes(!showItemTypes)}
onSourceCategoriesCollapse={() =>
setShowItemSourceCategories(!showItemSourceCategories)
}
className={className}
{...props}
/>
);
};
+109
View File
@@ -0,0 +1,109 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { v4 as uuidv4 } from "uuid";
import {
Item,
Constants,
ItemUtils,
} from "@dndbeyond/character-rules-engine/es";
import { AttunementIcon } from "~/tools/js/smartComponents/Icons";
import { LegacyBadge } from "../LegacyBadge";
import { Tooltip } from "../Tooltip";
import styles from "./styles.module.css";
/**
* Component to display the name of an item with an attunement icon and a color
* to identify the rarity of the object. It is used in the equipment tab on the
* character sheet as well as the manage inventory pane.
*/
interface ItemNameProps extends HTMLAttributes<HTMLSpanElement> {
item: Item;
className?: string;
onClick?: () => void;
showAttunement?: boolean;
showLegacy?: boolean;
showLegacyBadge?: boolean;
}
export const ItemName: FC<ItemNameProps> = ({
showAttunement = true,
showLegacy = false,
showLegacyBadge,
className,
onClick,
item,
...props
}) => {
const handleClick = (e: React.MouseEvent): void => {
if (onClick) {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
onClick();
}
};
const getDisplayName = () => {
const name = ItemUtils.getName(item);
if (!name) return ItemUtils.getDefinitionName(item);
if (!name) return "Item";
return name;
};
const getItemRarity = () => {
switch (ItemUtils.getRarity(item)) {
case Constants.ItemRarityNameEnum.ARTIFACT:
return "artifact";
case Constants.ItemRarityNameEnum.LEGENDARY:
return "legendary";
case Constants.ItemRarityNameEnum.VERY_RARE:
return "veryrare";
case Constants.ItemRarityNameEnum.RARE:
return "rare";
case Constants.ItemRarityNameEnum.UNCOMMON:
return "uncommon";
case Constants.ItemRarityNameEnum.COMMON:
default:
return "common";
}
};
const tooltipId = `itemName-${uuidv4()}`;
return (
<>
<span
className={clsx([styles.itemName, styles[getItemRarity()], className])}
onClick={handleClick}
{...props}
>
{getDisplayName()}
{item.isCustomized && (
<>
<span
className={styles.asterisk}
data-tooltip-id={tooltipId}
data-tooltip-content="Item is Customized"
>
*
</span>
<Tooltip id={tooltipId} />
</>
)}
{showAttunement && item.isAttuned && (
<span className={styles.icon}>
<AttunementIcon />
</span>
)}
{showLegacy && ItemUtils.isLegacy(item) && (
<span className={styles.legacy}>(Legacy)</span>
)}
</span>
{showLegacyBadge && ItemUtils.isLegacy(item) && (
<LegacyBadge variant="margin-left" />
)}
</>
);
};
+42
View File
@@ -0,0 +1,42 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { useMatch } from "react-router-dom";
import { Footer } from "@dndbeyond/ttui/components/Footer";
import { MegaMenu } from "@dndbeyond/ttui/components/MegaMenu";
import { Sitebar } from "@dndbeyond/ttui/components/Sitebar";
import config from "~/config";
import useUser from "~/hooks/useUser";
import styles from "./styles.module.css";
const BASE_PATHNAME = config.basePathname;
interface LayoutProps extends HTMLAttributes<HTMLDivElement> {}
export const Layout: FC<LayoutProps> = ({ children, ...props }) => {
const isDev = process.env.NODE_ENV === "development";
const user = useUser();
const matchSheet = useMatch(`${BASE_PATHNAME}/:characterId/`);
// Don't show the navigation in production
if (!isDev) return <>{children}</>;
return (
<div {...props}>
{/* TODO: fetch navItems */}
<div className={styles.siteStyles}>
<Sitebar user={user as any} navItems={[]} />
{/* TODO: fetch sources */}
<MegaMenu sources={[]} />
</div>
<div className={clsx(["container", styles.devContainer])}>{children}</div>
{!matchSheet && (
<div className={styles.siteStyles}>
<Footer />
</div>
)}
</div>
);
};
@@ -0,0 +1,41 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { v4 as uuidv4 } from "uuid";
import { LabelChip } from "@dndbeyond/ttui/components/LabelChip";
import { Link } from "../Link";
import styles from "./styles.module.css";
interface LegacyBadgeProps extends HTMLAttributes<HTMLDivElement> {
variant?: "margin-left";
}
export const LegacyBadge: FC<LegacyBadgeProps> = ({
variant,
className,
...props
}) => (
<LabelChip
className={clsx([styles.chip, variant && styles[variant], className])}
data-tooltip-id={`legacybadge_${uuidv4()}`}
tooltipContent={
<span>
This doesn't reflect the latest rules and lore.{" "}
<Link
className={styles.tooltipLink}
href="https://dndbeyond.com/legacy"
target="_blank"
rel="noopener noreferrer"
onClick={() => {}} //TODO - can fix this after we refactor Link to not stop propgation inside the component
>
Learn More
</Link>
</span>
}
tooltipClickable
{...props}
>
Legacy
</LabelChip>
);
+52
View File
@@ -0,0 +1,52 @@
import clsx from "clsx";
import { AnchorHTMLAttributes, FC } from "react";
import { Link as RouterLink } from "react-router-dom";
interface LinkProps
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "onClick"> {
onClick?: Function;
useTheme?: boolean;
useRouter?: boolean;
}
export const Link: FC<LinkProps> = ({
children,
className,
href,
onClick,
useTheme,
useRouter,
...props
}) => {
//TODO - refactor to handle stop propagation on the oustide of this component (about 7 or so files to change)
const handleClick = (e: React.MouseEvent): void => {
if (onClick) {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
onClick();
}
};
if (useRouter)
return (
<RouterLink
className={className}
onClick={handleClick}
to={href || ""}
{...props}
>
{children}
</RouterLink>
);
return (
<a
className={clsx(["ddbc-link", useTheme && "ddbc-theme-link", className])}
onClick={handleClick}
href={href}
{...props}
>
{children}
</a>
);
};
@@ -0,0 +1,65 @@
import { HTMLAttributes, useEffect, useRef } from "react";
import { Button } from "@dndbeyond/ttui/components/Button";
import { MaxCharactersMessageText } from "../MaxCharactersMessageText";
import styles from "./styles.module.css";
interface MaxCharactersDialogProps extends HTMLAttributes<HTMLDialogElement> {
open: boolean;
onClose: () => void;
useMyCharactersLink?: boolean;
}
export const MaxCharactersDialog: React.FC<MaxCharactersDialogProps> = ({
open,
onClose,
useMyCharactersLink,
...props
}) => {
const dialogRef = useRef<HTMLDialogElement>(null);
const handleClickBackdrop = (e) => {
const rect = dialogRef.current?.getBoundingClientRect();
const isClickInDialog =
rect &&
rect.top <= e.clientY &&
e.clientY <= rect.top + rect.height &&
rect.left <= e.clientX &&
e.clientX <= rect.left + rect.width;
if (!isClickInDialog) (dialogRef.current as any)?.close();
};
useEffect(() => {
if (open) {
// Hacky workaround since showModal() isn't
// supported by TypeScript <4.8.3
(dialogRef.current as any)?.showModal();
} else {
// Hacky workaround since close() isn't
// supported by TypeScript <4.8.3
(dialogRef.current as any)?.close();
}
}, [onClose, open]);
return (
<dialog
className={styles.maxCharactersDialog}
ref={dialogRef}
onClick={handleClickBackdrop}
{...props}
>
<header>
<h2 className={styles.dialogTitle}>Your character slots are full</h2>
</header>
<MaxCharactersMessageText useMyCharactersLink={useMyCharactersLink} />
<footer className={styles.dialogFooter}>
<Button href="/store/subscribe" variant="outline">
Subscribe
</Button>
<Button onClick={onClose}>Close</Button>
</footer>
</dialog>
);
};
@@ -0,0 +1,47 @@
import { FC, HTMLAttributes } from "react";
import styles from "./styles.module.css";
interface MaxCharactersMessageTextProps extends HTMLAttributes<HTMLDivElement> {
includeTitle?: boolean;
useLinks?: boolean;
useMyCharactersLink?: boolean;
}
export const MaxCharactersMessageText: FC<MaxCharactersMessageTextProps> = ({
includeTitle,
useLinks,
useMyCharactersLink,
...props
}) => {
const subscribe = useLinks ? (
<a className={styles.link} href="/store/subscribe">
Subscribe
</a>
) : (
"Subscribe"
);
const myCharacters =
useMyCharactersLink || useLinks ? (
<a className={styles.link} href="/characters">
My Characters
</a>
) : (
"My Characters"
);
return (
<div {...props}>
{includeTitle && (
<p className={styles.messageTitle}>
Oops! Your character slots are full.
</p>
)}
<p className={styles.messageSubtext}>
You&apos;re quite the adventurer! {subscribe} to unlock additional
slots, or return to {myCharacters} and delete a character to make room.
</p>
</div>
);
};
@@ -0,0 +1,117 @@
import clsx from "clsx";
import {
forwardRef,
HTMLAttributes,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { createPortal } from "react-dom";
import XMark from "@dndbeyond/fontawesome-cache/svgs/regular/xmark.svg";
import { Toast } from "@dndbeyond/ttui/components/Toast";
import { Notification } from "~/tools/js/Shared/stores/typings";
import styles from "./styles.module.css";
export interface NotificationSystemHandlers {
addNotification: (notification: Notification) => void;
removeNotification: (notification: Notification) => void;
}
const timeoutDuration = 5000;
interface NotificationSystemProps
extends Omit<HTMLAttributes<HTMLElement>, "open" | "contentEditable"> {}
/**
* NotificationSystem is a component that manages the display of notifications
* in toast messages which appear in the character sheet.
*/
export const NotificationSystem = forwardRef<
HTMLDialogElement,
NotificationSystemProps
>(({ className, ...props }, ref) => {
const [isOpen, setIsOpen] = useState(true);
const [notifications, setNotifications] = useState<Array<Notification>>([]);
const [notificationTimeoutHandle, setNotificationTimeoutHandle] = useState<
number | undefined
>();
const [portal, setPortal] = useState<HTMLDivElement | null>(null);
const addNotification = (notification) => {
if (notifications.filter((n) => n.uid === notification.uid).length === 0) {
setNotifications([...notifications, notification]);
}
clearTimeout(notificationTimeoutHandle);
(function openNotification() {
if (!isOpen) {
setIsOpen(true);
} else {
setNotificationTimeoutHandle(() => {
const handle = window.setTimeout(() => {
openNotification();
}, timeoutDuration + 100);
return handle;
});
}
})();
};
const removeNotification = (notification) => {
notifications[0].onRemove?.();
setNotifications(notifications.filter((n) => n.uid !== notification.uid));
};
useImperativeHandle<any, NotificationSystemHandlers>(
ref,
(): NotificationSystemHandlers => ({
addNotification,
removeNotification,
})
);
const handleClose = () => {
setIsOpen(false);
removeNotification(notifications[0]);
};
useEffect(() => {
const portalEl = document.createElement("div");
portalEl.classList.add("ct-notification__portal");
portalEl.style.zIndex = "100000";
portalEl.style.position = "fixed";
document.body.appendChild(portalEl);
setPortal(portalEl);
return () => {
document.body.removeChild(portalEl);
};
}, []);
return notifications[0] && portal
? createPortal(
<Toast
className={clsx([
styles.toast,
notifications[0].level && styles[notifications[0].level],
])}
open={isOpen}
onClose={handleClose}
autoHideDuration={timeoutDuration}
{...props}
>
<button
className={styles.closeButton}
onClick={handleClose}
aria-label="Dismiss notification"
>
<XMark className={styles.closeIcon} />
</button>
<p className={styles.title}>{notifications[0].title}</p>
<p className={styles.message}>{notifications[0].message}</p>
</Toast>,
portal
)
: null;
});
@@ -0,0 +1,95 @@
import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode } from "react";
import { Constants } from "@dndbeyond/character-rules-engine/es";
import { NumberDisplayType } from "~/types";
import styles from "./styles.module.css";
const { FEET_IN_MILES } = Constants;
export interface NumberDisplayProps extends HTMLAttributes<HTMLSpanElement> {
number: number | null;
type: NumberDisplayType;
isModified?: boolean;
size?: "large";
numberFallback?: ReactNode;
}
export const NumberDisplay: FC<NumberDisplayProps> = ({
number,
type,
isModified = false,
size,
numberFallback = "--",
className,
...props
}) => {
let label = "";
let sign = "";
switch (type) {
case "weightInLb":
label = "lb.";
number = number && Math.round((number + Number.EPSILON) * 100) / 100;
break;
case "distanceInFt":
if (number && number % FEET_IN_MILES === 0) {
number = number / FEET_IN_MILES;
label = `mile${Math.abs(number) === 1 ? "" : "s"}`;
} else {
label = "ft.";
}
break;
case "signed":
if (number != null) {
sign = number >= 0 ? "+" : "-";
number = Math.abs(number);
}
break;
default:
break;
}
return (
<span
className={clsx([
styles.numberDisplay,
type === "signed" && styles.signed,
type === "distanceInFt" && size && styles[size + "Distance"],
isModified && styles.modified,
size && styles[size],
className,
])}
{...props}
>
{type === "signed" && (
/* NOTE: aria-label needs to go on an interactable element, not a span.
This will need to be refactored onto the containing button.
*/
<span
className={clsx([
styles.sign,
size && styles[size + "Sign"],
styles.labelSignColor,
])}
aria-label={sign === "+" ? "plus" : "minus"}
>
{sign}
</span>
)}
<span>{number === null ? numberFallback : number}</span>
{type !== "signed" && (
<span
className={clsx([
styles.label,
size && styles[size + "Label"],
styles.labelSignColor,
])}
>
{label}
</span>
)}
</span>
);
};
+121
View File
@@ -0,0 +1,121 @@
import clsx from "clsx";
import React, {
FC,
ReactElement,
ReactNode,
cloneElement,
useEffect,
} from "react";
import { Dialog } from "@dndbeyond/ttui/components/Dialog";
import { useIsVisible } from "@dndbeyond/ttui/hooks/useIsVisible";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import styles from "./styles.module.css";
/**
* This is a popover component to handle dialogs that are dependent on a location from a trigger element.
* It uses the useIsVisible hook to handle the visibility of the dialog. The position of the dialog
* is determined by the position prop, which can be set to topLeft, topRight, left, right, bottomLeft, or bottomRight.
* The maxWidth prop can be used to set the maximum width of the dialog. The trigger prop is the element that will trigger
* the dialog to open.The popover component also handles the positioning of the dialog based on the trigger element. The
* handleToggle function toggles the visibility of the dialog, and the handleClose function closes the dialog. The popover
* component also adds an event listener to the cancel button in the dialog to close the dialog when clicked.
*
* In order for a cancel button to work, it must have a `data-cancel` attribute.
**/
interface PopoverProps {
className?: string;
children?: ReactNode;
position?:
| "topLeft"
| "topRight"
| "left"
| "right"
| "bottomLeft"
| "bottomRight"
| "bottom";
trigger: ReactElement;
maxWidth?: number;
variant?: "default" | "menu";
"data-testid"?: string;
}
export const Popover: FC<PopoverProps> = ({
children,
className,
maxWidth = 250,
position = "bottomRight",
trigger,
variant = "default",
...props
}) => {
const { ref: popoverRef, isVisible, setIsVisible } = useIsVisible(false);
const { isDarkMode } = useCharacterTheme();
const handleToggle = (e: MouseEvent) => {
e?.stopPropagation();
setIsVisible(!isVisible);
};
const handleClose = (e: MouseEvent) => {
e?.stopPropagation();
setIsVisible(false);
};
// Make a copy of the trigger element with an added `onClick` event listener
const triggerWithHandlers = cloneElement(trigger, {
onClick: (e: MouseEvent) => handleToggle(e),
});
useEffect(() => {
// Check to see if the popover has a cancel button based on `data-cancel` attribute
const cancelButton = popoverRef.current?.querySelector("[data-cancel]");
// If there is a cancel button, add an event listener to close the popover
if (cancelButton) {
cancelButton.addEventListener("click", handleClose);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [popoverRef.current]);
const mapChildrenWithProps = () => {
// Check if children is a ReactElement
if (React.isValidElement(children)) {
// Create a copy of the ReactElement and attach handleClose function to it
return cloneElement(children as ReactElement, {
handleClose,
});
}
// If not just return the children - means its a primitive type
else {
return children;
}
};
return (
<div
className={clsx([styles.popoverContainer, isDarkMode && styles.dark])}
ref={popoverRef}
>
{triggerWithHandlers}
<Dialog
className={clsx([
styles.popover,
styles[variant],
styles[position],
className,
])}
style={{ maxWidth }}
open={isVisible}
onClose={(e) => handleClose(e as any)} //have to do any because of type mismatch for events
onClick={(e) => e.stopPropagation()}
{...props}
>
{mapChildrenWithProps()}
</Dialog>
</div>
);
};
@@ -0,0 +1,54 @@
import { FC, ReactNode } from "react";
import { Button } from "../Button";
import styles from "./styles.module.css";
interface PopoverContentProps {
title?: string;
content: string;
confirmText?: ReactNode;
onConfirm?: () => void;
withCancel?: boolean;
handleClose?: () => void;
}
export const PopoverContent: FC<PopoverContentProps> = ({
title,
content,
confirmText = "Confirm",
onConfirm,
withCancel,
handleClose,
}) => (
<>
{title && <header className={styles.deletePopoverHeader}>{title}</header>}
<div className={styles.deletePopoverContent}>{content}</div>
<footer className={styles.deletePopoverFooter}>
{withCancel && (
<Button
size="x-small"
variant="outline"
color="secondary"
themed
data-cancel
>
Cancel
</Button>
)}
{onConfirm && (
<Button
onClick={() => {
if (handleClose) handleClose();
if (onConfirm) onConfirm();
}}
size="x-small"
variant="outline"
color="secondary"
themed
>
{confirmText}
</Button>
)}
</footer>
</>
);
@@ -0,0 +1,32 @@
import clsx from "clsx";
import { CharacterStatusSlug } from "@dndbeyond/character-rules-engine/es";
import styles from "./styles.module.css";
interface PremadeCharacterEditStatusProps {
characterStatus: CharacterStatusSlug | null;
isReadonly: boolean;
isBuilderView?: boolean;
}
export const PremadeCharacterEditStatus = ({
characterStatus,
isReadonly,
isBuilderView,
}: PremadeCharacterEditStatusProps) => {
return (
<>
{characterStatus === CharacterStatusSlug.PREMADE && !isReadonly && (
<div
className={clsx([
styles.editStateIndicator,
isBuilderView && styles.editStateIndicatorBuilder,
])}
>
Premade <strong>EDIT</strong> mode <strong>ENABLED</strong>
</div>
)}
</>
);
};
@@ -0,0 +1,56 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import { v4 as uuidv4 } from "uuid";
import { Tooltip } from "../Tooltip";
import styles from "./styles.module.css";
export interface ReferenceProps extends HTMLAttributes<HTMLDivElement> {
name: string | null;
tooltip?: string | null;
page?: number | null;
chapter?: number | null;
isDarkMode?: boolean;
}
/**
* This component acts as a reference to a specific page or chapter in a book
* where the information can be found.
*/
export const Reference: FC<ReferenceProps> = ({
chapter,
className,
isDarkMode,
name,
page,
tooltip,
...props
}) => {
const id = uuidv4();
const reference: string[] = [];
if (chapter) reference.push(`ch. ${chapter}`);
if (page) reference.push(`pg. ${page}`);
return (
<>
<p
className={clsx([styles.reference, className])}
data-tooltip-id={id}
data-tooltip-content={tooltip}
data-tooltip-delay-show={1500}
{...props}
>
<span className={styles.name}>{name}</span>
{reference.length > 0 && (
<span className={styles.ref}>, {reference.join(", ")}</span>
)}
</p>
<Tooltip
className={styles.tooltip}
id={id}
variant={isDarkMode ? "light" : "dark"}
/>
</>
);
};
@@ -0,0 +1,129 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useEffect, useState } from "react";
import { useFiltersContext } from "~/contexts/Filters";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import { SimpleSourceCategoryContract, Spell } from "~/types";
import { FilterGroup } from "../FilterGroup";
import { FilterButtonData } from "../FilterGroup/FilterGroup";
import styles from "./styles.module.css";
export interface SpellFilterProps extends HTMLAttributes<HTMLDivElement> {
spells: Array<Spell>;
filterQuery: string;
filterLevels: Array<number>;
onQueryChange: (value: string) => void;
onLevelFilterClick: (level: number) => void;
sourceCategories: Array<SimpleSourceCategoryContract>;
onSourceCategoryClick: (categoryId: number) => void;
filterSourceCategories: Array<number>;
themed?: boolean;
buttonSize?: "x-small" | "xx-small";
filterStyle?: "builder";
}
export const SpellFilter: FC<SpellFilterProps> = ({
spells,
filterQuery,
filterLevels,
onQueryChange,
onLevelFilterClick,
sourceCategories,
onSourceCategoryClick,
filterSourceCategories,
themed,
buttonSize = "xx-small",
filterStyle,
className,
...props
}) => {
const { ruleData, spellUtils, ruleDataUtils, formatUtils } =
useCharacterEngine();
const {
showSpellLevels,
showSpellSourceCategories,
setShowSpellLevels,
setShowSpellSourceCategories,
} = useFiltersContext();
const [spellLevels, setSpellLevels] = useState<Array<number>>([]);
useEffect(() => {
const activeSpellLevelCounts: Array<number> = [];
const spellsLevels: Array<number> = [];
const maxSpellLevel = ruleDataUtils.getMaxSpellLevel(ruleData);
for (let i = 0; i <= maxSpellLevel; i++) {
activeSpellLevelCounts.push(0);
spellsLevels.push(i);
}
spells.forEach((spell) => {
activeSpellLevelCounts[spellUtils.getLevel(spell)] += 1;
});
setSpellLevels(
spellsLevels.filter((level) => activeSpellLevelCounts[level] !== 0)
);
}, [spells]);
const filterButtons: Array<FilterButtonData> = spellLevels.map((level) => {
let buttonLabel: React.ReactNode;
if (level === 0) {
buttonLabel = formatUtils.renderSpellLevelAbbreviation(0);
} else {
buttonLabel = (
<div className={styles.buttonText}>
<span>{level}</span>
<span
className={clsx([
styles.ordinal,
buttonSize === "xx-small" && styles.ordinalSmall,
])}
>
{formatUtils.getOrdinalSuffix(level)}
</span>
</div>
);
}
return { type: level, label: buttonLabel, className: styles.filterButton };
});
const sourcesData: Array<FilterButtonData> = sourceCategories.map(
(sourceCategory) => {
return {
label: sourceCategory.name,
type: sourceCategory.id,
className: styles.sourceCategoryButton,
sortOrder: sourceCategory.sortOrder,
};
}
);
return (
<FilterGroup
searchPlaceholder={"Enter Spell Name"}
filterQuery={filterQuery}
filterButtonData={filterButtons}
activeFilterButtonTypes={filterLevels}
onQueryChange={onQueryChange}
onSourceCategoryClick={onSourceCategoryClick}
activeFilterSourceCategories={filterSourceCategories}
sourceCategoryButtonData={sourcesData}
themed={themed}
onFilterButtonClick={onLevelFilterClick}
buttonGroupLabel={"Filter By Spell Level"}
buttonSize={buttonSize}
filterStyle={filterStyle}
shouldOpenSourceCategoryButtons={showSpellSourceCategories}
shouldOpenFilterButtons={showSpellLevels}
onFilterButtonsCollapse={() => setShowSpellLevels(!showSpellLevels)}
onSourceCategoriesCollapse={() =>
setShowSpellSourceCategories(!showSpellSourceCategories)
}
className={className}
{...props}
/>
);
};
+126
View File
@@ -0,0 +1,126 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { Tooltip } from "@dndbeyond/character-common-components/es";
import {
SpellUtils,
EntityUtils,
FormatUtils,
BaseSpell,
DataOriginRefData,
characterEnvSelectors,
} from "@dndbeyond/character-rules-engine/es";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick";
import { ConcentrationIcon, RitualIcon } from "../../tools/js/smartComponents/";
import { LegacyBadge } from "../LegacyBadge";
import styles from "./styles.module.css";
interface Props extends HTMLAttributes<HTMLSpanElement> {
onClick?: () => void;
spell: BaseSpell;
showSpellLevel?: boolean;
showIcons?: boolean;
showExpandedType?: boolean;
showLegacy?: boolean;
showLegacyBadge?: boolean;
dataOriginRefData?: DataOriginRefData | null;
}
export const SpellName: FC<Props> = ({
onClick,
spell,
showSpellLevel = true,
showExpandedType = false,
showIcons = true,
dataOriginRefData = null,
showLegacy = false,
showLegacyBadge = false,
className,
...props
}) => {
const [expandedInfoText, setExpandedInfoText] = useState("");
const location = useSelector(characterEnvSelectors.getContext);
const { isDarkMode } = useCharacterTheme();
const getIconTheme = () => {
if (location === "BUILDER") return "dark";
return isDarkMode ? "gray" : "dark";
};
const handleClick = useUnpropagatedClick(onClick);
useEffect(() => {
if (dataOriginRefData) {
let expandedDataOriginRef = SpellUtils.getExpandedDataOriginRef(spell);
if (expandedDataOriginRef === null) {
setExpandedInfoText("");
} else {
setExpandedInfoText(
EntityUtils.getDataOriginRefName(
expandedDataOriginRef,
dataOriginRefData
)
);
}
}
}, []);
return (
<>
<span
className={styles.spellName}
onClick={onClick ? handleClick : undefined}
{...props}
>
{showExpandedType && expandedInfoText !== "" && (
<Tooltip
title={expandedInfoText}
className={styles.expanded}
isDarkMode={isDarkMode}
>
+
</Tooltip>
)}
{SpellUtils.getName(spell)}
{SpellUtils.isCustomized(spell) && (
<Tooltip
title="Spell is Customized"
className={styles.customized}
isDarkMode={isDarkMode}
>
*
</Tooltip>
)}
{showIcons && SpellUtils.getConcentration(spell) && (
<ConcentrationIcon
className={styles.icon}
themeMode={getIconTheme()}
/>
)}
{showIcons && SpellUtils.isRitual(spell) && (
<RitualIcon
className={clsx([styles.icon])}
themeMode={getIconTheme()}
/>
)}
{showSpellLevel && (
<span className={styles.level}>
({FormatUtils.renderSpellLevelShortName(SpellUtils.getLevel(spell))}
)
</span>
)}
{showLegacy && SpellUtils.isLegacy(spell) && (
<span className={styles.legacy}> Legacy</span>
)}
</span>
{showLegacyBadge && SpellUtils.isLegacy(spell) && (
<LegacyBadge variant="margin-left" />
)}
</>
);
};
@@ -0,0 +1,26 @@
import { FC, HTMLAttributes } from "react";
import styles from "./styles.module.css";
export interface SummaryListProps extends HTMLAttributes<HTMLDivElement> {
title: string;
list: Array<string>;
}
export const SummaryList: FC<SummaryListProps> = ({
title,
list,
...props
}) => {
if (!list.length) {
return null;
}
return (
<div {...props}>
<p className={styles.title}>
{title}: <span className={styles.listItems}>{list.join(", ")}</span>
</p>
</div>
);
};
@@ -0,0 +1,11 @@
import { useSwipeable } from "react-swipeable";
export const Swipeable = ({
handlerFunctions,
deltaOverride = 75,
...props
}) => {
const handlers = useSwipeable({ ...handlerFunctions, delta: deltaOverride });
return <div className="swipeable" {...handlers} {...props}></div>;
};
@@ -0,0 +1,98 @@
import clsx from "clsx";
import { FC, Fragment, HTMLAttributes, ReactNode, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { useCharacterTheme } from "~/contexts/CharacterTheme";
import styles from "./styles.module.css";
export interface TabFilterProps extends HTMLAttributes<HTMLDivElement> {
filters:
| {
label: ReactNode;
content: ReactNode;
badge?: ReactNode;
}[];
showAllTab?: boolean;
sharedChildren?: ReactNode;
}
export const TabFilter: FC<TabFilterProps> = ({
children,
className,
filters,
sharedChildren,
showAllTab = true,
...props
}) => {
const [activeFilter, setActiveFilter] = useState(0);
const { isDarkMode } = useCharacterTheme();
const activeIndex = showAllTab ? activeFilter - 1 : activeFilter;
const nonEmptyFilters = filters.filter((f) => f.label !== "");
const getDefaultLabel = (label: ReactNode) => {
if (typeof label === "string") return label;
return uuidv4();
};
const getAllContent = () => {
return (
nonEmptyFilters
// Remove optional tabs with duplicate content
.filter(
(f) =>
// Don't render tabs with icons such as ritual or concentration spells
typeof f.label === "string" &&
// Don't render tabs with the same content as the Attack or Limited Use tab
!getDefaultLabel(f.label).match(/(Attack|Limited Use)/)
)
// Return the content of the remaining tabs to be rendered
.map((f) => (
<Fragment key={getDefaultLabel(f.label)}>{f.content}</Fragment>
))
);
};
return (
<div
className={clsx([styles.tabFilter, isDarkMode && styles.dark, className])}
{...props}
>
<div className={styles.buttons} data-testid="tab-filters">
{showAllTab && (
<button
className={clsx([activeFilter === 0 && styles.active])}
onClick={() => setActiveFilter(0)}
data-testid="tab-filter-all"
>
All
</button>
)}
{nonEmptyFilters.map((filter, i) => {
const index = showAllTab ? i + 1 : i;
return (
<button
className={clsx([activeFilter === index && styles.active])}
onClick={() => setActiveFilter(index)}
data-testid={`tab-filter-${getDefaultLabel(filter.label)
.toLowerCase()
.replace(/\s/g, "-")}`}
key={`${index} + ${filter.label as string}`}
>
{filter.badge && (
<span className={styles.badge}>{filter.badge}</span>
)}
{filter.label}
</button>
);
})}
</div>
<div className={styles.content}>
{sharedChildren}
{!showAllTab || activeFilter !== 0
? nonEmptyFilters[activeIndex]?.content
: getAllContent()}
</div>
</div>
);
};
+107
View File
@@ -0,0 +1,107 @@
import clsx from "clsx";
import { FC, HTMLAttributes, ReactNode, useState } from "react";
import ChevronDown from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-down.svg";
import { Popover } from "../Popover";
import styles from "./styles.module.css";
interface TabItem {
label: ReactNode;
content: ReactNode;
className?: string;
id: string;
}
interface TabListProps extends HTMLAttributes<HTMLElement> {
tabs: Array<TabItem | null>;
variant?: "default" | "collapse" | "toggle";
defaultActiveId?: string;
maxNavItemsShow?: number;
}
export const TabList: FC<TabListProps> = ({
tabs,
defaultActiveId,
maxNavItemsShow = 7,
variant = "default",
...props
}) => {
const [activeTab, setActiveTab] = useState(
defaultActiveId ? defaultActiveId : tabs[0]?.id
);
const [isTabVisible, setIsTabVisible] = useState(activeTab !== "");
const handleTabClick = (id: string) => {
if (id === activeTab && variant === "collapse") {
setIsTabVisible(!isTabVisible);
}
if (id === activeTab && variant === "toggle") {
setActiveTab("");
}
if (id !== activeTab) {
setActiveTab(id);
setIsTabVisible(true);
}
};
const tabsWithContent = tabs.filter((tab) => tab);
const visibleTabs = tabsWithContent.slice(0, maxNavItemsShow);
const hiddenTabs = tabsWithContent.slice(maxNavItemsShow);
const selectedTab = tabs.find((tab) => tab?.id === activeTab);
return (
<div {...props}>
<menu className={styles.tabs}>
{/* List of visible tabs */}
{visibleTabs.map((tab: TabItem) => (
<li key={tab.id}>
<button
className={styles.tabButton}
role="radio"
aria-checked={activeTab === tab.id}
onClick={() => handleTabClick(tab.id)}
>
{tab.label}
{variant !== "default" && (
<ChevronDown className={!isTabVisible ? styles.closed : ""} />
)}
</button>
</li>
))}
{/* Toggle and menu for hidden tabs */}
{hiddenTabs.length > 0 && (
<li>
<Popover
className={styles.morePopover}
variant="menu"
trigger={<button className={styles.tabButton}>More</button>}
>
<menu className={styles.moreMenu}>
{hiddenTabs.map((tab: TabItem) => (
<li key={tab.id}>
<button
className={styles.tabButton}
role="radio"
aria-checked={activeTab === tab.id}
onClick={() => handleTabClick(tab.id)}
>
{tab.label}
</button>
</li>
))}
</menu>
</Popover>
</li>
)}
</menu>
{/* Content of the active tab */}
<div className={clsx([styles.tabContent, selectedTab?.className])}>
{isTabVisible && activeTab !== "" && selectedTab?.content}
</div>
</div>
);
};
+30
View File
@@ -0,0 +1,30 @@
import clsx from "clsx";
import { FC, HTMLAttributes } from "react";
import styles from "./styles.module.css";
export interface TagGroupProps extends HTMLAttributes<HTMLDivElement> {
label: string;
tags: Array<string>;
}
export const TagGroup: FC<TagGroupProps> = ({
label,
tags,
className,
...props
}) => {
if (!tags.length) return null;
return (
<div className={clsx([styles.tagGroup, className])} {...props}>
<div className={styles.label}>{label}:</div>
<div className={styles.group}>
{tags.map((tag) => (
<div className={styles.tag} key={tag}>
{tag}
</div>
))}
</div>
</div>
);
};
+75
View File
@@ -0,0 +1,75 @@
import clsx from "clsx";
import {
forwardRef,
HTMLAttributes,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import styles from "./styles.module.css";
export interface TextareaProps
extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
value?: string;
onInputBlur: (textValue: string) => void;
onInputKeyUp: () => void;
}
/**
* A component to be used as a text area with auto-resizing capabilities. This
* is used in the panes for notes and traits and needs to support text entry as
* well as programmatic updates.
*/
export const Textarea = forwardRef<HTMLDivElement, TextareaProps>(
(
{ className, value, onInputBlur, onInputKeyUp, placeholder, ...props },
forwardedRef
) => {
const [currentValue, setCurrentValue] = useState(value);
const ref = useRef<HTMLDivElement>(null);
// Combine the local ref with the forwarded ref
useImperativeHandle(forwardedRef, () => ref.current as HTMLDivElement);
const handleChange = (e) => {
const dataSet = e.target.parentNode.dataset;
dataSet.value = e.target.value;
setCurrentValue(e.target.value);
};
const handleBlur = (e) => onInputBlur(e.target.value);
const handleKeyUp = () => onInputKeyUp();
useEffect(() => {
if (ref.current) {
const wrapper = ref.current as HTMLDivElement;
const textarea = wrapper.children[0] as HTMLTextAreaElement;
// Set the data-value attribute to the value
wrapper.dataset.value = value;
// Set the textarea value to the value
textarea.value = value || "";
setCurrentValue(value);
}
}, [value]);
return (
<div
className={clsx([styles.textarea, className])}
ref={ref}
data-value={currentValue}
{...props}
>
<textarea
rows={1}
placeholder={placeholder}
onChange={handleChange}
onKeyUp={handleKeyUp}
onBlur={handleBlur}
value={currentValue}
/>
</div>
);
}
);
+65
View File
@@ -0,0 +1,65 @@
import clsx from "clsx";
import { useState, type FC, type HTMLAttributes } from "react";
import styles from "./styles.module.css";
import { useSelector } from "react-redux";
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
export interface ToggleProps
extends Omit<HTMLAttributes<HTMLButtonElement>, "onClick"> {
checked?: boolean;
color?: "primary" | "secondary" | "warning" | "error" | "info" | "success" | "themed";
onClick?: (isEnabled: boolean) => void;
onChangePromise?: (
newIsEnabled: boolean,
accept: () => void,
reject: () => void
) => void;
disabled?: boolean;
}
export const Toggle: FC<ToggleProps> = ({
checked,
className,
color = "primary",
onClick,
onChangePromise,
...props
}) => {
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
const [enabled, setEnabled] = useState(checked);
const handleClick = (evt: React.MouseEvent): void => {
if (!isReadonly) {
evt.stopPropagation();
evt.nativeEvent.stopImmediatePropagation();
if (onChangePromise) {
onChangePromise(
!enabled,
() => {
setEnabled(!enabled);
},
() => {}
);
} else if (onClick) {
onClick(!enabled);
setEnabled(!enabled);
}
}
};
return (
<button
className={clsx([
styles.toggle,
styles[color],
checked && styles.checked,
className,
])}
onClick={handleClick}
aria-pressed={checked}
{...props}
/>
);
};
+23
View File
@@ -0,0 +1,23 @@
import clsx from "clsx";
import { FC } from "react";
import {
Tooltip as TTUITooltip,
TooltipProps as TTUITooltipProps,
} from "@dndbeyond/ttui/components/Tooltip";
import styles from "./styles.module.css";
interface TooltipProps extends TTUITooltipProps {}
/**
* This tooltip component is a wrapper around the TTUI Tooltip component to provide styles which match the existing tooltip styles. It should be used in all new components so we can get rid of the tippy.js implementation. The TTUI component uses [React Tooltip](https://react-tooltip.com/) under the hood, so you can find all options [here](https://react-tooltip.com/docs/options).
*/
export const Tooltip: FC<TooltipProps> = ({ className, ...props }) => (
<TTUITooltip
className={clsx([styles.tooltip, className])}
delayShow={300}
{...props}
/>
);
+208
View File
@@ -0,0 +1,208 @@
import clsx from "clsx";
import { FC, HTMLAttributes, useEffect, useMemo, useState } from "react";
import {
CharacterUtils,
FormatUtils,
HelperUtils,
HtmlSelectOption,
RuleDataUtils,
} from "@dndbeyond/character-rules-engine";
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
import XpBar from "~/tools/js/smartComponents/XpBar";
import { Select } from "~/tools/js/smartComponents/legacy";
import { Button } from "../Button";
import styles from "./styles.module.css";
const XP_CHANGE_TYPE = {
ADD: "ADD",
REMOVE: "REMOVE",
};
interface XpManagerProps extends HTMLAttributes<HTMLDivElement> {
handleXpUpdates: (newXpTotal: number) => void;
shouldReset: boolean;
}
export const XpManager: FC<XpManagerProps> = ({
handleXpUpdates,
shouldReset,
...props
}) => {
const { experienceInfo: xpInfo, ruleData } = useCharacterEngine();
const [xpNew, setXpNew] = useState<number | null>(null);
const [xpChange, setXpChange] = useState<number | null>(null);
const [levelChosen, setLevelChosen] = useState<number | null>(null);
const [changeType, setChangeType] = useState<string>(XP_CHANGE_TYPE.ADD);
const [newXpTotal, setNewXpTotal] = useState<number>(xpInfo.currentLevelXp);
useEffect(() => {
let xpDiff: number = xpChange ?? 0;
if (changeType === XP_CHANGE_TYPE.REMOVE) {
xpDiff *= -1;
}
let newXpTotal: number = xpInfo.currentLevelXp;
if (xpDiff) {
newXpTotal += xpDiff;
} else if (levelChosen !== null) {
newXpTotal = CharacterUtils.deriveCurrentLevelXp(levelChosen, ruleData);
} else if (xpNew !== null) {
newXpTotal = xpNew;
}
newXpTotal = Math.min(
Math.max(0, newXpTotal),
CharacterUtils.deriveMaxXp(ruleData)
);
setNewXpTotal((prev) => {
if (newXpTotal !== prev) {
handleXpUpdates(newXpTotal);
}
return newXpTotal;
});
}, [
xpChange,
changeType,
levelChosen,
xpNew,
xpInfo,
ruleData,
handleXpUpdates,
]);
useEffect(() => {
if (shouldReset) {
setXpNew(null);
setXpChange(null);
setLevelChosen(null);
setChangeType(XP_CHANGE_TYPE.ADD);
setNewXpTotal(xpInfo.currentLevelXp);
}
}, [shouldReset, xpInfo]);
const levelOptions = useMemo(() => {
let levelOptions: Array<HtmlSelectOption> = [];
for (let i = 1; i <= RuleDataUtils.getMaxCharacterLevel(ruleData); i++) {
levelOptions.push({
label: `${i}`,
value: i,
});
}
return levelOptions;
}, [ruleData]);
const displayCurrentLevelXp = useMemo(() => {
return xpInfo.currentLevel === ruleData.maxCharacterLevel
? CharacterUtils.deriveCurrentLevelXp(
ruleData.maxCharacterLevel - 1,
ruleData
)
: xpInfo.currentLevelXp;
}, [xpInfo, ruleData]);
const handleChooseLevel = (value: string): void => {
setXpNew(null);
setXpChange(null);
setLevelChosen(HelperUtils.parseInputInt(value));
};
const handleXpChange = (evt: React.ChangeEvent<HTMLInputElement>): void => {
setXpNew(null);
setXpChange(HelperUtils.parseInputInt(evt.target.value));
setLevelChosen(null);
};
const handleXpSet = (evt: React.ChangeEvent<HTMLInputElement>): void => {
setXpNew(HelperUtils.parseInputInt(evt.target.value));
setXpChange(null);
setLevelChosen(null);
};
return (
<div {...props}>
<h2 className={styles.levelTotal}>
Current XP Total:{" "}
{FormatUtils.renderLocaleNumber(xpInfo.currentLevelXp)} (Level{" "}
{xpInfo.currentLevel})
</h2>
<div className={styles.xpBar}>
<XpBar
xp={xpInfo.currentLevelXp}
showCurrentMarker={true}
ruleData={ruleData}
/>
</div>
<div className={styles.currentLevelAmounts}>
<div>{FormatUtils.renderLocaleNumber(displayCurrentLevelXp)}</div>
<div>{FormatUtils.renderLocaleNumber(xpInfo.nextLevelXp)}</div>
</div>
<div className={clsx([styles.controls, styles.withDivider])}>
<div className={styles.control}>
<label id="set-level" className={styles.label}>
Set Level
</label>
<Select
aria-labelledby="set-level"
options={levelOptions}
onChange={handleChooseLevel}
value={levelChosen}
placeholder={"--"}
/>
</div>
<div className={clsx([styles.control, styles.setXp])}>
<label id="set-xp" className={styles.label}>
Set XP
</label>
<input
aria-labelledby="set-xp"
type="number"
value={xpNew === null ? "" : xpNew}
onChange={handleXpSet}
/>
</div>
<div className={styles.adjustGroup}>
<div className={styles.adjustTabs}>
<Button
variant="text"
size="xx-small"
className={clsx([
styles.tab,
changeType === XP_CHANGE_TYPE.ADD && styles.active,
])}
onClick={() => setChangeType(XP_CHANGE_TYPE.ADD)}
>
Add Xp
</Button>
<Button
variant="text"
size="xx-small"
className={clsx([
styles.tab,
changeType === XP_CHANGE_TYPE.REMOVE && styles.active,
])}
onClick={() => setChangeType(XP_CHANGE_TYPE.REMOVE)}
>
Remove Xp
</Button>
</div>
<input
type="number"
value={xpChange === null ? "" : xpChange}
onChange={handleXpChange}
placeholder="Type XP Value"
/>
</div>
</div>
<h2 className={clsx([styles.levelTotal, styles.withDivider])}>
New XP Total: {FormatUtils.renderLocaleNumber(newXpTotal)} (Level{" "}
{CharacterUtils.deriveXpLevel(newXpTotal, ruleData)})
</h2>
</div>
);
};