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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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'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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user