New source found from dndbeyond.com
This commit is contained in:
@@ -14,13 +14,20 @@ import ChevronDown from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-down.sv
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export type AccordionVariant =
|
||||
| "default"
|
||||
| "text"
|
||||
| "paper"
|
||||
| "minimal"
|
||||
| "solid";
|
||||
|
||||
export interface AccordionProps extends HTMLAttributes<HTMLElement> {
|
||||
variant?: "default" | "text" | "paper";
|
||||
variant?: AccordionVariant;
|
||||
summary: ReactNode;
|
||||
description?: ReactNode;
|
||||
size?: "small" | "medium";
|
||||
size?: "xx-small" | "x-small" | "small" | "medium";
|
||||
forceShow?: boolean;
|
||||
useTheme?: boolean;
|
||||
themed?: boolean;
|
||||
resetOpen?: boolean;
|
||||
override?: boolean | null;
|
||||
handleIsOpen?: (id: string, isOpen: boolean) => void;
|
||||
@@ -40,8 +47,7 @@ export const Accordion: FC<AccordionProps> = ({
|
||||
variant = "default",
|
||||
children,
|
||||
forceShow = false,
|
||||
// style,
|
||||
useTheme,
|
||||
themed,
|
||||
resetOpen,
|
||||
handleIsOpen,
|
||||
override = null,
|
||||
@@ -101,7 +107,7 @@ export const Accordion: FC<AccordionProps> = ({
|
||||
styles.details,
|
||||
styles[size],
|
||||
styles[variant],
|
||||
useTheme && styles.theme,
|
||||
themed && styles.themed,
|
||||
showAlert && styles.alert,
|
||||
className,
|
||||
])}
|
||||
@@ -113,6 +119,7 @@ export const Accordion: FC<AccordionProps> = ({
|
||||
className={styles.summary}
|
||||
onClick={handleToggle}
|
||||
ref={summaryRef}
|
||||
data-testid="accordion-summary"
|
||||
>
|
||||
{summaryImage && (
|
||||
<img
|
||||
@@ -123,7 +130,7 @@ export const Accordion: FC<AccordionProps> = ({
|
||||
/>
|
||||
)}
|
||||
<div className={styles.heading}>
|
||||
<div>
|
||||
<div className={styles.summaryContent}>
|
||||
{summary}
|
||||
{summaryMetaItems && (
|
||||
<div className={styles.metaItems}>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface AccordionSummaryProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
imageUrl?: string;
|
||||
title: ReactNode;
|
||||
description?: string | string[];
|
||||
callout?: ReactNode;
|
||||
}
|
||||
|
||||
export const AccordionSummary: FC<AccordionSummaryProps> = ({
|
||||
callout,
|
||||
className,
|
||||
description,
|
||||
imageUrl,
|
||||
title,
|
||||
...props
|
||||
}) => {
|
||||
const buildDescription = () => {
|
||||
if (Array.isArray(description)) {
|
||||
return description.join(" • ");
|
||||
}
|
||||
return description;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.accordionSummary, className])} {...props}>
|
||||
{imageUrl && <img className={styles.avatar} src={imageUrl} alt="" />}
|
||||
<div className={styles.text}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
{description && (
|
||||
<p className={styles.description}>{buildDescription()}</p>
|
||||
)}
|
||||
</div>
|
||||
{callout && <div className={styles.callout}>{callout}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import clsx from "clsx";
|
||||
import { FC } from "react";
|
||||
import Color from "color";
|
||||
import { CSSProperties, FC } from "react";
|
||||
|
||||
import {
|
||||
Button as TtuiButton,
|
||||
@@ -33,7 +34,14 @@ export const Button: FC<ButtonProps> = ({
|
||||
const isCustomSize = size === "xx-small";
|
||||
const isCustomVariant = variant === "builder" || variant === "builder-text";
|
||||
const isCustomColor = color === "builder-green";
|
||||
const { isDarkMode } = useCharacterTheme();
|
||||
const { isDarkMode, themeColor } = useCharacterTheme();
|
||||
let overrideStyles: CSSProperties = {};
|
||||
if (isDarkMode && themed && variant === "solid" && size === "xx-small") {
|
||||
overrideStyles = {
|
||||
// Determine if the theme color is darker or lighter
|
||||
color: Color(themeColor).isDark() ? "" : "black",
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<TtuiButton
|
||||
@@ -53,6 +61,7 @@ export const Button: FC<ButtonProps> = ({
|
||||
variant={!isCustomVariant ? variant : undefined}
|
||||
// Children are included in props
|
||||
color={!isCustomColor ? color : undefined}
|
||||
style={overrideStyles}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -12,8 +12,7 @@ interface CheckboxProps extends TtuiCheckboxProps {
|
||||
themed?: boolean;
|
||||
darkMode?: boolean;
|
||||
variant?: "default" | "builder" | "sidebar";
|
||||
onClick?: (isEnabled: boolean) => void;
|
||||
onChangePromise?: (
|
||||
onChangeConfirm?: (
|
||||
newIsEnabled: boolean,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
@@ -26,51 +25,52 @@ export const Checkbox: FC<CheckboxProps> = ({
|
||||
variant = "default",
|
||||
darkMode,
|
||||
checked = false,
|
||||
onChangePromise,
|
||||
onClick,
|
||||
onChangeConfirm,
|
||||
onChange,
|
||||
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;
|
||||
const newCheckedState = evt.target.checked;
|
||||
|
||||
// promise-based logic
|
||||
if (onChangePromise) {
|
||||
onChangePromise(
|
||||
if (onChangeConfirm) {
|
||||
onChangeConfirm(
|
||||
newCheckedState,
|
||||
() => {
|
||||
// Promise accepted, update the state
|
||||
setIsChecked(newCheckedState);
|
||||
},
|
||||
() => {
|
||||
// Promise rejected, do nothing
|
||||
// Promise rejected, reset the checkbox to its previous state
|
||||
setIsChecked(!newCheckedState);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Update the state immediately if no promise handling - only IF onClick is provided
|
||||
if (onClick) {
|
||||
onClick(newCheckedState);
|
||||
if (onChange) {
|
||||
onChange(evt);
|
||||
setIsChecked(newCheckedState);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsChecked(checked);
|
||||
}, [checked]);
|
||||
|
||||
return (
|
||||
<TtuiCheckbox
|
||||
className={clsx([
|
||||
styles.checkbox,
|
||||
styles[variant],
|
||||
themed && styles.themed,
|
||||
darkMode && styles[darkMode ? "darkMode" : "lightMode"],
|
||||
className,
|
||||
variant === "default" && styles.default,
|
||||
variant === "builder" && styles.builder,
|
||||
])}
|
||||
onChange={handleChange}
|
||||
aria-pressed={isChecked}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import clsx from "clsx";
|
||||
import { orderBy } from "lodash";
|
||||
import { ChangeEvent, FC, HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import { Accordion, AccordionProps } from "../Accordion";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface CheckboxInfo {
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
initiallyEnabled: boolean;
|
||||
label: string;
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
onChangeConfirm?: (
|
||||
newIsEnabled: boolean,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
sortOrder?: number;
|
||||
value?: string | number;
|
||||
}
|
||||
|
||||
interface CheckboxGroupProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
accordionDefaultOpen?: boolean;
|
||||
accordionSummary?: string;
|
||||
checkboxes: Array<CheckboxInfo>;
|
||||
disabled?: boolean;
|
||||
description?: ReactNode;
|
||||
name: string;
|
||||
onToggleAll?: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
showAccordion?: boolean;
|
||||
themed?: boolean;
|
||||
toggleAllLabel?: string;
|
||||
title: ReactNode;
|
||||
variant?: "default" | "inline" | "collapse";
|
||||
}
|
||||
|
||||
export const CheckboxGroup: FC<CheckboxGroupProps> = ({
|
||||
accordionSummary = "Options",
|
||||
accordionDefaultOpen,
|
||||
checkboxes,
|
||||
className,
|
||||
disabled,
|
||||
description,
|
||||
name,
|
||||
onToggleAll,
|
||||
showAccordion,
|
||||
themed,
|
||||
title,
|
||||
toggleAllLabel = "Enable All Options",
|
||||
variant = "default",
|
||||
...props
|
||||
}) => {
|
||||
const isInlineVariant = variant === "inline";
|
||||
const isCollapseVariant = variant === "collapse";
|
||||
const sortedCheckboxes = orderBy(checkboxes, "sortOrder");
|
||||
|
||||
const allChecksEnabled = sortedCheckboxes.every(
|
||||
(checkbox) => checkbox.initiallyEnabled
|
||||
);
|
||||
|
||||
// Determine whether to render a div or an Accordion
|
||||
const ContentWrapper = showAccordion ? Accordion : "div";
|
||||
// Set props specific to Accordion if needed
|
||||
const accordionProps = showAccordion
|
||||
? {
|
||||
summary: accordionSummary,
|
||||
forceShow: accordionDefaultOpen,
|
||||
variant: "text",
|
||||
}
|
||||
: {};
|
||||
|
||||
const checkboxList = sortedCheckboxes.map((checkbox, i) => {
|
||||
const id = `${name}-${i}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={styles.checkboxOption}
|
||||
data-testid="form-checkbox-group"
|
||||
>
|
||||
<Checkbox
|
||||
className={styles.checkbox}
|
||||
id={id}
|
||||
name={name}
|
||||
checked={checkbox.initiallyEnabled}
|
||||
disabled={disabled || checkbox.disabled}
|
||||
themed={themed}
|
||||
onChange={checkbox.onChange}
|
||||
onChangeConfirm={checkbox.onChangeConfirm}
|
||||
value={checkbox.value}
|
||||
/>
|
||||
<div className={styles.optionContent}>
|
||||
<label className={styles.label} htmlFor={id}>
|
||||
{checkbox.label}
|
||||
</label>
|
||||
{checkbox.description && (
|
||||
<div
|
||||
className={styles.description}
|
||||
dangerouslySetInnerHTML={{ __html: checkbox.description }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** Handle Collapse Variant separately since it's structure is different */
|
||||
if (isCollapseVariant)
|
||||
return (
|
||||
<Accordion
|
||||
className={clsx([
|
||||
styles.checkboxGroup,
|
||||
styles.collapse,
|
||||
themed && styles.themed,
|
||||
className,
|
||||
])}
|
||||
summary={<p className={styles.checkboxGroupTitle}>{title}</p>}
|
||||
variant="text"
|
||||
forceShow={accordionDefaultOpen}
|
||||
{...props}
|
||||
>
|
||||
{description && (
|
||||
<div className={styles.checkboxGroupDescription}>{description}</div>
|
||||
)}
|
||||
{onToggleAll && (
|
||||
<Checkbox
|
||||
className={clsx([styles.checkbox, styles.toggleAll])}
|
||||
id={`${name}-toggle-all`}
|
||||
checked={allChecksEnabled}
|
||||
aria-label="Check/Uncheck all"
|
||||
onChange={onToggleAll}
|
||||
themed={themed}
|
||||
label={toggleAllLabel}
|
||||
/>
|
||||
)}
|
||||
{checkboxList}
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx([
|
||||
styles.checkboxGroup,
|
||||
styles[variant],
|
||||
themed && styles.themed,
|
||||
className,
|
||||
])}
|
||||
{...props}
|
||||
>
|
||||
<header>
|
||||
<div className={styles.checkboxGroupHeader}>
|
||||
<p className={styles.checkboxGroupTitle}>{title}</p>
|
||||
{isInlineVariant && checkboxes.length < 2 && (
|
||||
<Checkbox
|
||||
className={styles.checkbox}
|
||||
id={`${name}-0`}
|
||||
name={name}
|
||||
checked={checkboxes[0].initiallyEnabled}
|
||||
disabled={disabled || checkboxes[0].disabled}
|
||||
themed={themed}
|
||||
onChange={checkboxes[0].onChange}
|
||||
onChangeConfirm={checkboxes[0].onChangeConfirm}
|
||||
value={checkboxes[0].value}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<div className={styles.checkboxGroupDescription}>{description}</div>
|
||||
)}
|
||||
</header>
|
||||
{!isInlineVariant && onToggleAll && (
|
||||
<Checkbox
|
||||
className={clsx([styles.checkbox, styles.toggleAll])}
|
||||
id={`${name}-toggle-all`}
|
||||
checked={allChecksEnabled}
|
||||
aria-label="Check/Uncheck all"
|
||||
onChange={onToggleAll}
|
||||
themed={themed}
|
||||
label={toggleAllLabel}
|
||||
/>
|
||||
)}
|
||||
{!isInlineVariant && (
|
||||
<ContentWrapper
|
||||
className={styles.checkboxOptions}
|
||||
{...(accordionProps as AccordionProps)}
|
||||
>
|
||||
{checkboxList}
|
||||
</ContentWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,8 @@ 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 { Dialog, DialogProps } from "~/components/Dialog";
|
||||
|
||||
import { Button, ButtonProps } from "../Button";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import clsx from "clsx";
|
||||
import { FC } from "react";
|
||||
|
||||
import {
|
||||
Dialog as TtuiDialog,
|
||||
DialogProps as TtuiDialogProps,
|
||||
} from "@dndbeyond/ttui/components/Dialog";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
/**
|
||||
* A wrapper around the TTUI Dialog component to apply custom styles. This is necessary because the
|
||||
* transform property conflicts with styles for the select component. Using this component to
|
||||
* remove those styles resolves the issue.
|
||||
*/
|
||||
|
||||
export interface DialogProps extends TtuiDialogProps {}
|
||||
|
||||
export const Dialog: FC<DialogProps> = ({ className, ...props }) => {
|
||||
return <TtuiDialog className={clsx(styles.dialog, className)} {...props} />;
|
||||
};
|
||||
@@ -1,12 +1,13 @@
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import {
|
||||
characterActions,
|
||||
ChoiceUtils,
|
||||
ContainerManager,
|
||||
} from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
import { useDispatch } from "~/hooks/useDispatch";
|
||||
import { DetailChoice } from "~/tools/js/Shared/containers/DetailChoice";
|
||||
import { CharClass, Choice, ClassDefinitionContract, Feat } from "~/types";
|
||||
|
||||
@@ -15,6 +16,7 @@ export interface FeatureChoiceProps extends HTMLAttributes<HTMLDivElement> {
|
||||
charClass?: CharClass;
|
||||
featsData: Feat[];
|
||||
subclassData?: ClassDefinitionContract[];
|
||||
itemShoppe?: ContainerManager | null;
|
||||
onChoiceChange: (
|
||||
choiceId: string,
|
||||
type: number,
|
||||
@@ -33,6 +35,7 @@ export const FeatureChoice: FC<FeatureChoiceProps> = ({
|
||||
choice,
|
||||
featsData,
|
||||
subclassData,
|
||||
itemShoppe,
|
||||
onChoiceChange,
|
||||
className,
|
||||
collapseDescription,
|
||||
@@ -69,13 +72,14 @@ export const FeatureChoice: FC<FeatureChoiceProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const { description, options, featSubChoices } =
|
||||
const { description, options, featSubChoices, introDescription } =
|
||||
ChoiceUtils.getSortedChoiceOptionsForFeaturesInfo(
|
||||
choice,
|
||||
featsData,
|
||||
featLookup,
|
||||
charClass || null,
|
||||
subclassData || [],
|
||||
itemShoppe || null,
|
||||
preferences,
|
||||
prerequisiteData,
|
||||
ruleData,
|
||||
@@ -90,6 +94,7 @@ export const FeatureChoice: FC<FeatureChoiceProps> = ({
|
||||
options={options}
|
||||
onChange={handleChoiceChange}
|
||||
description={description || ""}
|
||||
descriptionIntro={introDescription || ""}
|
||||
choiceInfo={choiceInfo}
|
||||
classId={charClass && classUtils.getId(charClass)}
|
||||
showBackgroundProficiencyOptions={true}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, useEffect, useState } from "react";
|
||||
import { FC, HTMLAttributes, useContext, useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import {
|
||||
@@ -9,13 +9,17 @@ import {
|
||||
Choice,
|
||||
ChoiceUtils,
|
||||
ClassDefinitionContract,
|
||||
ClassFeature,
|
||||
ClassFeatureUtils,
|
||||
ClassUtils,
|
||||
Constants,
|
||||
ContainerManager,
|
||||
Feat,
|
||||
FeatUtils,
|
||||
} from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { Heading } from "~/subApps/sheet/components/Sidebar/components/Heading";
|
||||
import { InventoryManagerContext } from "~/tools/js/Shared/managers/InventoryManagerContext";
|
||||
import { appEnvSelectors } from "~/tools/js/Shared/selectors";
|
||||
import { AppLoggerUtils } from "~/tools/js/Shared/utils";
|
||||
import LoadingPlaceholder from "~/tools/js/smartComponents/LoadingPlaceholder";
|
||||
@@ -35,7 +39,9 @@ export interface FeatureChoicesProps extends HTMLAttributes<HTMLDivElement> {
|
||||
) => void;
|
||||
collapseDescription?: boolean;
|
||||
charClass?: CharClass;
|
||||
classFeature?: ClassFeature;
|
||||
shouldFetch?: boolean; // Optional prop to control fetching behavior
|
||||
itemShoppe?: ContainerManager | null; // Optional prop for cases where the itemShoppe is fetched from the parent component (only used if the Feature Choices component is rendered multiple times in the same parent - e.g. in the Item Plans Manage Pane)
|
||||
}
|
||||
|
||||
export const FeatureChoices: FC<FeatureChoicesProps> = ({
|
||||
@@ -45,7 +51,9 @@ export const FeatureChoices: FC<FeatureChoicesProps> = ({
|
||||
onChoiceChange,
|
||||
collapseDescription,
|
||||
charClass,
|
||||
classFeature,
|
||||
shouldFetch = true, // Default to true if not provided
|
||||
itemShoppe = null,
|
||||
...props
|
||||
}) => {
|
||||
const isReadonly = useSelector(appEnvSelectors.getIsReadonly);
|
||||
@@ -56,14 +64,20 @@ export const FeatureChoices: FC<FeatureChoicesProps> = ({
|
||||
apiCreatorSelectors.makeLoadAvailableSubclasses
|
||||
);
|
||||
|
||||
const { inventoryManager } = useContext(InventoryManagerContext);
|
||||
|
||||
const [subclassData, setSubclassData] = useState<
|
||||
Array<ClassDefinitionContract>
|
||||
>([]);
|
||||
const [featsData, setFeatsData] = useState<Array<Feat>>([]);
|
||||
const [localItemShoppe, setLocalItemShoppe] =
|
||||
useState<ContainerManager | null>(itemShoppe);
|
||||
const [featLoadingStatus, setFeatLoadingStatus] =
|
||||
useState<DataLoadingStatusEnum>(DataLoadingStatusEnum.NOT_INITIALIZED);
|
||||
const [subclassLoadingStatus, setSubclassLoadingStatus] =
|
||||
useState<DataLoadingStatusEnum>(DataLoadingStatusEnum.NOT_INITIALIZED);
|
||||
const [itemsLoadingStatus, setItemsLoadingStatus] =
|
||||
useState<DataLoadingStatusEnum>(DataLoadingStatusEnum.NOT_INITIALIZED);
|
||||
|
||||
const hasFeatChoice = choices.some(
|
||||
(choice) =>
|
||||
@@ -77,15 +91,23 @@ export const FeatureChoices: FC<FeatureChoicesProps> = ({
|
||||
Constants.BuilderChoiceTypeEnum.SUB_CLASS_OPTION
|
||||
);
|
||||
|
||||
const hasItemMappings = classFeature
|
||||
? ClassFeatureUtils.getHasItemMappings(classFeature)
|
||||
: false;
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
if (
|
||||
hasFeatChoice &&
|
||||
shouldFetch &&
|
||||
featLoadingStatus === DataLoadingStatusEnum.NOT_INITIALIZED
|
||||
featLoadingStatus !== DataLoadingStatusEnum.LOADED
|
||||
) {
|
||||
setFeatLoadingStatus(DataLoadingStatusEnum.LOADING);
|
||||
|
||||
loadAvailableFeats()
|
||||
loadAvailableFeats({
|
||||
signal: abortController.signal,
|
||||
})
|
||||
.then((response) => {
|
||||
let featsData: Array<Feat> = [];
|
||||
|
||||
@@ -99,17 +121,22 @@ export const FeatureChoices: FC<FeatureChoicesProps> = ({
|
||||
setFeatsData(featsData);
|
||||
setFeatLoadingStatus(DataLoadingStatusEnum.LOADED);
|
||||
})
|
||||
.catch(AppLoggerUtils.handleAdhocApiError);
|
||||
.catch((e) => {
|
||||
if (abortController.signal.aborted) return;
|
||||
AppLoggerUtils.handleAdhocApiError(e);
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
charClass &&
|
||||
hasSubclassChoice &&
|
||||
shouldFetch &&
|
||||
subclassLoadingStatus === DataLoadingStatusEnum.NOT_INITIALIZED
|
||||
subclassLoadingStatus !== DataLoadingStatusEnum.LOADED
|
||||
) {
|
||||
setSubclassLoadingStatus(DataLoadingStatusEnum.LOADING);
|
||||
loadAvailableSubclasses(ClassUtils.getId(charClass))
|
||||
loadAvailableSubclasses(ClassUtils.getId(charClass), {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
.then((response) => {
|
||||
let subclassData: Array<ClassDefinitionContract> = [];
|
||||
|
||||
@@ -121,17 +148,55 @@ export const FeatureChoices: FC<FeatureChoicesProps> = ({
|
||||
setSubclassData(subclassData);
|
||||
setSubclassLoadingStatus(DataLoadingStatusEnum.LOADED);
|
||||
})
|
||||
.catch(AppLoggerUtils.handleAdhocApiError);
|
||||
.catch((e) => {
|
||||
if (abortController.signal.aborted) return;
|
||||
AppLoggerUtils.handleAdhocApiError(e);
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
localItemShoppe === null &&
|
||||
hasItemMappings &&
|
||||
shouldFetch &&
|
||||
itemsLoadingStatus !== DataLoadingStatusEnum.LOADED
|
||||
) {
|
||||
if (inventoryManager) {
|
||||
setItemsLoadingStatus(DataLoadingStatusEnum.LOADING);
|
||||
|
||||
inventoryManager
|
||||
.getInventoryShoppe({
|
||||
onSuccess: (shoppeContainer: ContainerManager) => {
|
||||
setLocalItemShoppe(shoppeContainer);
|
||||
setItemsLoadingStatus(DataLoadingStatusEnum.LOADED);
|
||||
},
|
||||
additionalApiConfig: {
|
||||
signal: abortController.signal,
|
||||
},
|
||||
})
|
||||
.catch((e) => {
|
||||
if (abortController.signal.aborted) return;
|
||||
AppLoggerUtils.handleAdhocApiError(e);
|
||||
});
|
||||
} else {
|
||||
setItemsLoadingStatus(DataLoadingStatusEnum.LOADED);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [
|
||||
hasFeatChoice,
|
||||
hasSubclassChoice,
|
||||
hasItemMappings,
|
||||
subclassLoadingStatus,
|
||||
featLoadingStatus,
|
||||
itemsLoadingStatus,
|
||||
charClass,
|
||||
loadAvailableFeats,
|
||||
loadAvailableSubclasses,
|
||||
shouldFetch,
|
||||
inventoryManager,
|
||||
]);
|
||||
|
||||
const isDataLoaded = (): boolean => {
|
||||
@@ -145,6 +210,13 @@ export const FeatureChoices: FC<FeatureChoicesProps> = ({
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (itemShoppe !== null) {
|
||||
return true;
|
||||
} else if (hasItemMappings) {
|
||||
if (itemsLoadingStatus !== DataLoadingStatusEnum.LOADED) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -163,6 +235,7 @@ export const FeatureChoices: FC<FeatureChoicesProps> = ({
|
||||
charClass={charClass}
|
||||
choice={choice}
|
||||
featsData={featsData}
|
||||
itemShoppe={localItemShoppe}
|
||||
subclassData={subclassData}
|
||||
className={styles.choice}
|
||||
key={ChoiceUtils.getId(choice)}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { StepType, TourContext, TourProvider } from "@reactour/tour";
|
||||
import clsx from "clsx";
|
||||
import { Dispatch, FC, HTMLAttributes, SetStateAction } from "react";
|
||||
|
||||
import QuestionIcon from "@dndbeyond/fontawesome-cache/svgs/solid/question.svg";
|
||||
|
||||
import { Button } from "../Button";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface GuidedTourProps extends HTMLAttributes<HTMLDivElement> {
|
||||
afterOpen?: (target: Element | null) => void;
|
||||
beforeClose?: (target: Element | null) => void;
|
||||
cookieName?: string;
|
||||
setStep?: SetStateAction<any>;
|
||||
showOnFirstLoad?: boolean;
|
||||
steps?: StepType[];
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export const GuidedTour: FC<GuidedTourProps> = ({
|
||||
afterOpen,
|
||||
beforeClose,
|
||||
children,
|
||||
className,
|
||||
cookieName = "ddbGuidedTour",
|
||||
setStep,
|
||||
showOnFirstLoad,
|
||||
step = 0,
|
||||
steps = [],
|
||||
...props
|
||||
}) => {
|
||||
const handleBeforeClose = () => {
|
||||
const html = document.querySelector("html");
|
||||
if (html) html.style.overflowY = "visible";
|
||||
if (beforeClose) beforeClose(null);
|
||||
};
|
||||
|
||||
const handleAfterOpen = () => {
|
||||
const html = document.querySelector("html");
|
||||
if (html) html.style.overflowY = "hidden";
|
||||
if (afterOpen) afterOpen(null);
|
||||
};
|
||||
|
||||
const handleLoad = (setIsOpen: Dispatch<SetStateAction<boolean>>) => {
|
||||
if (showOnFirstLoad) {
|
||||
const viewedTour = localStorage.getItem(cookieName);
|
||||
|
||||
if (!viewedTour) {
|
||||
setIsOpen(true);
|
||||
localStorage.setItem(cookieName, "true");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const PrevButton = ({ Button, ...props }) => (
|
||||
<Button {...props} kind="prev" hideArrow={step === 0} />
|
||||
);
|
||||
|
||||
const NextButton = ({ Button, ...props }) => (
|
||||
<Button
|
||||
{...props}
|
||||
kind="next"
|
||||
hideArrow={step === (steps?.length || 0) - 1}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.guidedTour, className])} {...props}>
|
||||
<TourProvider
|
||||
steps={steps}
|
||||
currentStep={step}
|
||||
setCurrentStep={setStep}
|
||||
afterOpen={handleAfterOpen}
|
||||
beforeClose={handleBeforeClose}
|
||||
showDots={false}
|
||||
prevButton={PrevButton}
|
||||
nextButton={NextButton}
|
||||
>
|
||||
<TourContext.Consumer>
|
||||
{({ setIsOpen }) => (
|
||||
<div onLoad={() => handleLoad(setIsOpen)}>
|
||||
{children}
|
||||
<Button
|
||||
className={styles.tourButton}
|
||||
onClick={() => setIsOpen(true)}
|
||||
color="info"
|
||||
size="small"
|
||||
aria-label="Open guided tour"
|
||||
>
|
||||
<QuestionIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TourContext.Consumer>
|
||||
</TourProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useTour } from "@reactour/tour";
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import { Button } from "../Button";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface GuidedTourStepProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, "content"> {
|
||||
title: string;
|
||||
content: ReactNode;
|
||||
showClose?: boolean;
|
||||
}
|
||||
|
||||
export const GuidedTourStep: FC<GuidedTourStepProps> = ({
|
||||
className,
|
||||
content,
|
||||
showClose,
|
||||
title,
|
||||
...props
|
||||
}) => {
|
||||
const { setIsOpen } = useTour();
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.guidedTourStep, className])} {...props}>
|
||||
<p className={styles.title}>{title}</p>
|
||||
<div className={styles.content}>{content}</div>
|
||||
{showClose && (
|
||||
<Button
|
||||
className={styles.closeButton}
|
||||
size="small"
|
||||
variant="outline"
|
||||
themed
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Start Playing!
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from "clsx";
|
||||
import { FC } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
@@ -16,12 +17,14 @@ export interface HelperTextAccordionProps
|
||||
|
||||
export const HelperTextAccordion: FC<HelperTextAccordionProps> = ({
|
||||
builderHelperText,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{builderHelperText.map((helperText, idx) => (
|
||||
<Accordion
|
||||
className={clsx([styles.accordion, className])}
|
||||
{...props}
|
||||
key={uuidv4()}
|
||||
summary={
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
import { InfoItem as TtuiInfoItem } from "@dndbeyond/ttui/components/InfoItem";
|
||||
|
||||
import { useUnpropagatedClick } from "~/hooks/useUnpropagatedClick";
|
||||
import type { FC, HTMLAttributes } from "react";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface InfoItemProps extends HTMLAttributes<HTMLElement> {
|
||||
interface InfoItemProps extends HTMLAttributes<HTMLDivElement> {
|
||||
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,
|
||||
inline = true,
|
||||
label,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const handleClick = useUnpropagatedClick(onClick);
|
||||
|
||||
return (
|
||||
<TtuiInfoItem
|
||||
className={clsx([styles.item, className])}
|
||||
onClick={onClick ? handleClick : undefined}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}) => (
|
||||
<div
|
||||
className={clsx([styles.item, inline && styles.inline, className])}
|
||||
role="listitem"
|
||||
{...props}
|
||||
>
|
||||
<p className={styles.label}>{label}</p>
|
||||
<p className={styles.value}>{children}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, MouseEvent } from "react";
|
||||
|
||||
import {
|
||||
AccessUtils,
|
||||
CampaignUtils,
|
||||
FormatUtils,
|
||||
Infusion,
|
||||
InfusionUtils,
|
||||
RuleDataUtils,
|
||||
Constants,
|
||||
} from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import { MarketplaceCta } from "~/components/MarketplaceCta";
|
||||
import { useCharacterEngine } from "~/hooks/useCharacterEngine";
|
||||
|
||||
import Snippet from "../../tools/js/smartComponents/Snippet";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "onClick"> {
|
||||
infusion: Infusion;
|
||||
onClick?: (infusion: Infusion) => void;
|
||||
}
|
||||
export const InfusionPreview: FC<Props> = ({
|
||||
infusion,
|
||||
onClick,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const { ruleData, snippetData, proficiencyBonus, partyInfo } =
|
||||
useCharacterEngine();
|
||||
|
||||
const itemMappingId = InfusionUtils.getInventoryMappingId(infusion);
|
||||
const partyRestrictions = partyInfo
|
||||
? CampaignUtils.getPartyInventoryRestrictions(partyInfo)
|
||||
: [];
|
||||
|
||||
const isInfusedItemInParty = partyRestrictions.some(
|
||||
(restriction) =>
|
||||
restriction.type === Constants.PartyRestrictionTypeEnum.INFUSION &&
|
||||
restriction.inventoryMappingId === itemMappingId
|
||||
);
|
||||
|
||||
const handleClick = (evt: MouseEvent): void => {
|
||||
if (onClick) {
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
onClick(infusion);
|
||||
}
|
||||
};
|
||||
|
||||
//if you have access to the infusion via entitlments or the infused item is in the party - you can see the details, othwise show marketplace cta
|
||||
const isAccessible =
|
||||
AccessUtils.isAccessible(InfusionUtils.getAccessType(infusion)) ||
|
||||
isInfusedItemInParty;
|
||||
|
||||
const getSourceNames = (): string | null => {
|
||||
const sources = InfusionUtils.getSources(infusion);
|
||||
let sourceNames: Array<string> = [];
|
||||
if (sources.length > 0) {
|
||||
sources.forEach((source) => {
|
||||
let sourceInfo = RuleDataUtils.getSourceDataInfo(
|
||||
source.sourceId,
|
||||
ruleData
|
||||
);
|
||||
|
||||
if (sourceInfo !== null && sourceInfo.description !== null) {
|
||||
sourceNames.push(sourceInfo.description);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return sourceNames.length > 0
|
||||
? FormatUtils.renderNonOxfordCommaList(sourceNames)
|
||||
: null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx([onClick && styles.isInteractive, className])}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.name}>
|
||||
Infusion: {InfusionUtils.getName(infusion)}
|
||||
</div>
|
||||
<div className={styles.snippet}>
|
||||
{isAccessible ? (
|
||||
<Snippet
|
||||
snippetData={snippetData}
|
||||
classLevel={
|
||||
InfusionUtils.getSelectedModifierData(infusion)?.value ?? null
|
||||
}
|
||||
proficiencyBonus={proficiencyBonus}
|
||||
>
|
||||
{InfusionUtils.getSnippet(infusion)}
|
||||
</Snippet>
|
||||
) : (
|
||||
<MarketplaceCta
|
||||
sourceName={getSourceNames()}
|
||||
description="To unlock this infusion, check out the Marketplace to view purchase options."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
import { useMatch } from "react-router-dom";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useMatch, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { NavigationMenu } from "@dndbeyond/navigation-menu";
|
||||
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";
|
||||
@@ -13,9 +13,40 @@ import styles from "./styles.module.css";
|
||||
|
||||
const BASE_PATHNAME = config.basePathname;
|
||||
|
||||
const NOTIFICATIONS_CACHE_KEY = "dndbNotificationsCache";
|
||||
|
||||
const seedNotificationsCache = () => {
|
||||
try {
|
||||
const existing = localStorage.getItem(NOTIFICATIONS_CACHE_KEY);
|
||||
const parsed = existing ? JSON.parse(existing) : null;
|
||||
const isValid =
|
||||
parsed &&
|
||||
typeof parsed.expiresAtMs === "number" &&
|
||||
Date.now() < parsed.expiresAtMs;
|
||||
if (!isValid) {
|
||||
localStorage.setItem(
|
||||
NOTIFICATIONS_CACHE_KEY,
|
||||
JSON.stringify({
|
||||
expiresAtMs: Date.now() + 60 * 60 * 1000,
|
||||
counts: {
|
||||
unreadNotifications: null,
|
||||
unreadMessages: null,
|
||||
unreadReports: null,
|
||||
unreadAppeals: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
};
|
||||
|
||||
interface LayoutProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const Layout: FC<LayoutProps> = ({ children, ...props }) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const isVttView = searchParams.get("view") === "vtt";
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const user = useUser();
|
||||
const matchSheet = useMatch(`${BASE_PATHNAME}/:characterId/`);
|
||||
@@ -23,20 +54,33 @@ export const Layout: FC<LayoutProps> = ({ children, ...props }) => {
|
||||
// Don't show the navigation in production
|
||||
if (!isDev) return <>{children}</>;
|
||||
|
||||
// Seed the notifications cache so NavigationMenu skips the JWT fetch in local
|
||||
// dev, where the auth service is not proxied.
|
||||
seedNotificationsCache();
|
||||
|
||||
const navigationUser =
|
||||
user && user.avatarUrl
|
||||
? {
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
subscriptionTier: user.subscriptionTier as "Master",
|
||||
roles: user.roles,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
{/* TODO: fetch navItems */}
|
||||
<div className={styles.siteStyles}>
|
||||
<Sitebar user={user as any} navItems={[]} />
|
||||
{/* TODO: fetch sources */}
|
||||
<MegaMenu enablePartyWizard={true} sources={[]} />
|
||||
</div>
|
||||
{!isVttView &&
|
||||
createPortal(
|
||||
<NavigationMenu user={navigationUser} />,
|
||||
document.getElementById("header-wrapper")!
|
||||
)}
|
||||
<div className={clsx(["container", styles.devContainer])}>{children}</div>
|
||||
{!matchSheet && (
|
||||
<div className={styles.siteStyles}>
|
||||
<Footer />
|
||||
</div>
|
||||
)}
|
||||
{!isVttView &&
|
||||
(!matchSheet ||
|
||||
matchSheet.pathname === "/characters/builder" ||
|
||||
matchSheet.pathname === "/characters/premade") &&
|
||||
createPortal(<Footer />, document.getElementById("footer-wrapper")!)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ interface LinkProps
|
||||
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "onClick"> {
|
||||
onClick?: Function;
|
||||
useTheme?: boolean;
|
||||
useRouter?: boolean;
|
||||
userouter?: boolean;
|
||||
}
|
||||
|
||||
export const Link: FC<LinkProps> = ({
|
||||
@@ -15,7 +15,7 @@ export const Link: FC<LinkProps> = ({
|
||||
href,
|
||||
onClick,
|
||||
useTheme,
|
||||
useRouter,
|
||||
userouter,
|
||||
...props
|
||||
}) => {
|
||||
//TODO - refactor to handle stop propagation on the oustide of this component (about 7 or so files to change)
|
||||
@@ -27,7 +27,7 @@ export const Link: FC<LinkProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (useRouter)
|
||||
if (userouter)
|
||||
return (
|
||||
<RouterLink
|
||||
className={className}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { clsx } from "clsx";
|
||||
import { FC, HTMLAttributes } from "react";
|
||||
|
||||
import { FormatUtils } from "@dndbeyond/character-rules-engine/es";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
sourceName?: string | null;
|
||||
sourceNames?: Array<string>;
|
||||
url?: string;
|
||||
description?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
export const MarketplaceCta: FC<Props> = ({
|
||||
sourceName = null,
|
||||
sourceNames,
|
||||
url = "/marketplace",
|
||||
description = null,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const allSourceNames: Array<string> = [];
|
||||
|
||||
if (sourceName) {
|
||||
allSourceNames.push(sourceName);
|
||||
}
|
||||
|
||||
if (sourceNames) {
|
||||
allSourceNames.push(...sourceNames);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx([styles.cta, className])} {...props}>
|
||||
<a href={url} target="_blank" className={styles.link} rel="noreferrer">
|
||||
<div className={styles.content}>
|
||||
{allSourceNames.length > 0 && (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerIntro}>Available in</div>
|
||||
<div className={styles.headerTitle}>
|
||||
{FormatUtils.renderNonOxfordCommaList(allSourceNames)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className={styles.description}>{description}</div>
|
||||
)}
|
||||
<div className={styles.button}>Go To Marketplace</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,65 +1,74 @@
|
||||
import { HTMLAttributes, useEffect, useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { Button } from "@dndbeyond/ttui/components/Button";
|
||||
import CloseIcon from "@dndbeyond/fontawesome-cache/svgs/solid/x.svg";
|
||||
|
||||
import { Button } from "../Button";
|
||||
import { Dialog, DialogProps } from "../Dialog";
|
||||
import { MaxCharactersMessageText } from "../MaxCharactersMessageText";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface MaxCharactersDialogProps extends HTMLAttributes<HTMLDialogElement> {
|
||||
interface MaxCharactersDialogProps
|
||||
extends Omit<DialogProps, "onClose" | "open"> {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
useMyCharactersLink?: boolean;
|
||||
hasLockedCharacters?: boolean;
|
||||
}
|
||||
|
||||
export const MaxCharactersDialog: React.FC<MaxCharactersDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
useMyCharactersLink,
|
||||
hasLockedCharacters,
|
||||
className,
|
||||
...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}
|
||||
<Dialog
|
||||
className={clsx([styles.dialog, className])}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
modal
|
||||
{...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
|
||||
<header className={styles.header}>
|
||||
<h2 className={styles.title}>Your character slots are full</h2>
|
||||
<Button
|
||||
className={styles.closeButton}
|
||||
variant="tool"
|
||||
onClick={onClose}
|
||||
aria-label="Close Modal"
|
||||
>
|
||||
<CloseIcon />
|
||||
</Button>
|
||||
</header>
|
||||
<div className={styles.content}>
|
||||
<MaxCharactersMessageText
|
||||
hasLockedCharacters={hasLockedCharacters}
|
||||
useMyCharactersLink={useMyCharactersLink}
|
||||
/>
|
||||
</div>
|
||||
<footer className={styles.footer}>
|
||||
{!hasLockedCharacters && (
|
||||
<Button
|
||||
className={styles.cardButton}
|
||||
href="/store/subscribe"
|
||||
color="info"
|
||||
size="x-small"
|
||||
variant="outline"
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className={styles.cardButton}
|
||||
color="info"
|
||||
size="x-small"
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</footer>
|
||||
</dialog>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,12 +6,14 @@ interface MaxCharactersMessageTextProps extends HTMLAttributes<HTMLDivElement> {
|
||||
includeTitle?: boolean;
|
||||
useLinks?: boolean;
|
||||
useMyCharactersLink?: boolean;
|
||||
hasLockedCharacters?: boolean;
|
||||
}
|
||||
|
||||
export const MaxCharactersMessageText: FC<MaxCharactersMessageTextProps> = ({
|
||||
includeTitle,
|
||||
useLinks,
|
||||
useMyCharactersLink,
|
||||
hasLockedCharacters,
|
||||
...props
|
||||
}) => {
|
||||
const subscribe = useLinks ? (
|
||||
@@ -31,6 +33,21 @@ export const MaxCharactersMessageText: FC<MaxCharactersMessageTextProps> = ({
|
||||
"My Characters"
|
||||
);
|
||||
|
||||
if (hasLockedCharacters) {
|
||||
return (
|
||||
<div {...props}>
|
||||
<p className={styles.messageSubtext}>
|
||||
Your character slots are full. To create a new character, select which
|
||||
characters to keep using the options below, or{" "}
|
||||
<a className={styles.link} href="/store/subscribe">
|
||||
subscribe
|
||||
</a>{" "}
|
||||
to unlock additional slots.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
{includeTitle && (
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { HTMLAttributes, FC, ReactNode } from "react";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface NoResultsFoundProps extends HTMLAttributes<HTMLDivElement> {
|
||||
message?: ReactNode;
|
||||
}
|
||||
|
||||
export const NoResultsFound: FC<NoResultsFoundProps> = ({
|
||||
message = "No results found",
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<p className={styles.noResults} {...props}>
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
@@ -7,21 +7,23 @@ import React, {
|
||||
useEffect,
|
||||
} from "react";
|
||||
|
||||
import { Dialog } from "@dndbeyond/ttui/components/Dialog";
|
||||
import { useIsVisible } from "@dndbeyond/ttui/hooks/useIsVisible";
|
||||
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
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.
|
||||
* 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.
|
||||
**/
|
||||
@@ -36,7 +38,7 @@ interface PopoverProps {
|
||||
| "bottomLeft"
|
||||
| "bottomRight"
|
||||
| "bottom";
|
||||
trigger: ReactElement;
|
||||
trigger: ReactElement<{ onClick?: (e: MouseEvent) => void }>;
|
||||
maxWidth?: number;
|
||||
variant?: "default" | "menu";
|
||||
"data-testid"?: string;
|
||||
@@ -85,9 +87,12 @@ export const Popover: FC<PopoverProps> = ({
|
||||
// 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,
|
||||
});
|
||||
return cloneElement(
|
||||
children as ReactElement<{ handleClose: (e: MouseEvent) => void }>,
|
||||
{
|
||||
handleClose,
|
||||
}
|
||||
);
|
||||
}
|
||||
// If not just return the children - means its a primitive type
|
||||
else {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { FC } from "react";
|
||||
|
||||
import { PreferencePrivacyTypeEnum } from "~/constants";
|
||||
|
||||
import { RadioGroup, RadioGroupProps } from "../RadioGroup";
|
||||
|
||||
export interface PrivacyTypeRadioProps
|
||||
extends Omit<RadioGroupProps, "name" | "title" | "options"> {}
|
||||
|
||||
export const PrivacyTypeRadio: FC<PrivacyTypeRadioProps> = (props) => (
|
||||
<RadioGroup
|
||||
title="Character Privacy"
|
||||
name="privacyType"
|
||||
options={[
|
||||
{
|
||||
value: PreferencePrivacyTypeEnum.CAMPAIGN_ONLY,
|
||||
label: "Campaign Only",
|
||||
description:
|
||||
"Only others within a Campaign you have joined can view your Characters.",
|
||||
},
|
||||
{
|
||||
value: PreferencePrivacyTypeEnum.PUBLIC,
|
||||
label: "Public",
|
||||
description: "Anyone with a shared link can view your Characters.",
|
||||
},
|
||||
{
|
||||
value: PreferencePrivacyTypeEnum.PRIVATE,
|
||||
label: "Private",
|
||||
description: "Only you can view your Characters.",
|
||||
},
|
||||
]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,117 @@
|
||||
import clsx from "clsx";
|
||||
import { ChangeEvent, FC, HTMLAttributes, useRef, useState } from "react";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface RadioOption {
|
||||
label: string;
|
||||
value: string | number;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface RadioGroupProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
|
||||
name: string;
|
||||
initialValue?: string | number;
|
||||
title: string;
|
||||
options: RadioOption[];
|
||||
description?: string;
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
onChangeConfirm?: (
|
||||
e: ChangeEvent<HTMLInputElement>,
|
||||
newValue: string | number,
|
||||
oldValue: string | number,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
themed?: boolean;
|
||||
}
|
||||
|
||||
export const RadioGroup: FC<RadioGroupProps> = ({
|
||||
className,
|
||||
name,
|
||||
initialValue,
|
||||
title,
|
||||
options,
|
||||
description,
|
||||
disabled,
|
||||
onChange,
|
||||
onChangeConfirm,
|
||||
themed,
|
||||
...props
|
||||
}) => {
|
||||
const radioGroupRef = useRef<HTMLDivElement>(null!);
|
||||
const [selectedValue, setSelectedValue] = useState(
|
||||
initialValue || options[0]?.value
|
||||
);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue =
|
||||
typeof selectedValue === "number"
|
||||
? Number(e.target.value)
|
||||
: e.target.value;
|
||||
|
||||
if (onChangeConfirm) {
|
||||
onChangeConfirm(
|
||||
e,
|
||||
newValue,
|
||||
selectedValue,
|
||||
() => {
|
||||
if (onChange) onChange(e);
|
||||
setSelectedValue(newValue);
|
||||
},
|
||||
() => {
|
||||
// noop
|
||||
}
|
||||
);
|
||||
} else {
|
||||
if (onChange) onChange(e);
|
||||
setSelectedValue(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx([styles.radioGroup, themed && styles.themed, className])}
|
||||
ref={radioGroupRef}
|
||||
{...props}
|
||||
>
|
||||
<legend>{title}</legend>
|
||||
{description && <p className={styles.subtitle}>{description}</p>}
|
||||
<div className={styles.options}>
|
||||
{options?.map((option) => {
|
||||
const id = `${name}_${option.value}`;
|
||||
const checked = String(selectedValue) === String(option.value);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.option}
|
||||
key={id}
|
||||
data-testid="radio-option-container"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
id={id}
|
||||
onChange={handleChange}
|
||||
value={option.value}
|
||||
aria-checked={checked}
|
||||
disabled={disabled || option.disabled}
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
<label className={styles.label} htmlFor={id}>
|
||||
{option.label}
|
||||
</label>
|
||||
{option.description && (
|
||||
<p className={styles.description}>{option.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,545 @@
|
||||
import clsx from "clsx";
|
||||
import groupBy from "lodash/groupBy";
|
||||
import {
|
||||
ChangeEvent,
|
||||
CSSProperties,
|
||||
FC,
|
||||
SelectHTMLAttributes,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
FeatureChoiceOption,
|
||||
HtmlSelectOption,
|
||||
HtmlSelectOptionGroup,
|
||||
} from "@dndbeyond/character-rules-engine";
|
||||
import Check from "@dndbeyond/fontawesome-cache/svgs/regular/check.svg";
|
||||
import ChevronDown from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-down.svg";
|
||||
import { useIsVisible } from "@dndbeyond/ttui/hooks/useIsVisible";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
groupLabel?: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface SelectProps
|
||||
extends Omit<
|
||||
SelectHTMLAttributes<HTMLDivElement>,
|
||||
"value" | "onChange" | "name" | "size"
|
||||
> {
|
||||
options: (FeatureChoiceOption | HtmlSelectOption | HtmlSelectOptionGroup)[];
|
||||
value: string | number | null;
|
||||
onChange?: (value: string | number) => void;
|
||||
onChangeConfirm?: (
|
||||
newValue: string,
|
||||
oldValue: string,
|
||||
accept: () => void,
|
||||
reject: () => void
|
||||
) => void;
|
||||
placeholder?: string;
|
||||
searchThreshold?: number | null;
|
||||
searchPlaceholder?: string;
|
||||
isHighlighted?: boolean;
|
||||
name: string;
|
||||
optionsWidth?: "medium" | "large" | "x-large";
|
||||
hidePlaceholderOption?: boolean;
|
||||
size?: "small" | "medium";
|
||||
resetAfterChoice?: boolean;
|
||||
}
|
||||
|
||||
export const Select: FC<SelectProps> = ({
|
||||
className,
|
||||
placeholder = "-- Choose an Option --",
|
||||
name,
|
||||
onChange,
|
||||
onChangeConfirm,
|
||||
options,
|
||||
value,
|
||||
disabled,
|
||||
searchThreshold = 10,
|
||||
searchPlaceholder = "Search...",
|
||||
isHighlighted,
|
||||
optionsWidth = "medium",
|
||||
hidePlaceholderOption,
|
||||
size = "medium",
|
||||
resetAfterChoice,
|
||||
...props
|
||||
}) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null!);
|
||||
const searchRef = useRef<HTMLInputElement>(null!);
|
||||
const optionsRef = useRef<HTMLDivElement>(null!);
|
||||
const optionsListRef = useRef<HTMLDivElement>(null!);
|
||||
const isSearchActive = useRef(false);
|
||||
const { ref: selectRef, isVisible, setIsVisible } = useIsVisible(false);
|
||||
const [internalValue, setInternalValue] = useState(value || "");
|
||||
const [query, setQuery] = useState("");
|
||||
const [focused, setFocused] = useState(0);
|
||||
const [buttonPosition, setButtonPosition] = useState<DOMRect | null>(null);
|
||||
|
||||
const getOptions = () => {
|
||||
let counter = 1;
|
||||
const optionArr: SelectOption[] = [];
|
||||
|
||||
options.forEach((option: FeatureChoiceOption | HtmlSelectOptionGroup) => {
|
||||
if ("optGroupLabel" in option) {
|
||||
option.options.forEach((opt: HtmlSelectOption) => {
|
||||
optionArr.push({
|
||||
label: opt.label || "",
|
||||
value: opt.value,
|
||||
groupLabel: option.optGroupLabel,
|
||||
index: counter,
|
||||
});
|
||||
counter++;
|
||||
});
|
||||
} else {
|
||||
optionArr.push({
|
||||
label: option.label || "",
|
||||
value: option.value,
|
||||
index: counter,
|
||||
});
|
||||
counter++;
|
||||
}
|
||||
});
|
||||
|
||||
return optionArr;
|
||||
};
|
||||
|
||||
const [results, setResults] = useState(getOptions());
|
||||
const currentSelection = results.find(
|
||||
(option) => String(option.value) === String(internalValue)
|
||||
);
|
||||
|
||||
const scrollIntoView = useCallback(() => {
|
||||
if (!optionsRef.current) return;
|
||||
|
||||
const optionList = optionsRef.current.querySelector(
|
||||
`.${styles.optionList}`
|
||||
);
|
||||
if (!optionList) return;
|
||||
|
||||
let targetElement: Element | null = null;
|
||||
|
||||
// Look for the focused option first (including placeholder at index 0)
|
||||
if (focused >= 0) {
|
||||
targetElement = optionList.querySelector(`[data-index="${focused}"]`);
|
||||
}
|
||||
// If no focused option, look for the currently selected option
|
||||
else {
|
||||
targetElement = optionList.querySelector(`[aria-selected="true"]`);
|
||||
}
|
||||
|
||||
// Scroll the target element into view within the dropdown container
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({
|
||||
behavior: "auto",
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
}
|
||||
}, [focused]);
|
||||
|
||||
const buildOptionsHtml = (options: SelectOption[]) => [
|
||||
...options.map((option) => (
|
||||
<label
|
||||
className={clsx([
|
||||
styles.option,
|
||||
focused === option.index && styles.focused,
|
||||
])}
|
||||
aria-selected={
|
||||
(value === null && query === "" && option.index === 0) ||
|
||||
String(internalValue) === String(option.value)
|
||||
}
|
||||
key={option.value}
|
||||
data-index={option.index}
|
||||
onMouseEnter={() => setFocused(option.index)}
|
||||
>
|
||||
<Check className={styles.check} aria-label={option.label} />
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={option.value}
|
||||
onClick={() => handleClickOption(option.value)}
|
||||
onChange={handleChange}
|
||||
checked={value === option.value}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
)),
|
||||
];
|
||||
|
||||
const getOptionsList = () => {
|
||||
// Group items by groupLabel
|
||||
const grouped = groupBy(results, "groupLabel");
|
||||
// Turn grouped object into an array with groups and values
|
||||
const entries = Object.entries(grouped);
|
||||
// Map over grouped items to render
|
||||
return entries.map(([groupName, options]) => {
|
||||
// If there is a valid groupName
|
||||
if (groupName !== "undefined") {
|
||||
return (
|
||||
<div className={styles.optionGroup} key={groupName}>
|
||||
<label className={styles.groupLabel}>{groupName}</label>
|
||||
{buildOptionsHtml(options)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// If the groupName is "undefined"
|
||||
return buildOptionsHtml(options);
|
||||
});
|
||||
};
|
||||
|
||||
const getOptionsPosition = (): CSSProperties | undefined => {
|
||||
const maxHeight = 300; // Maximum height of the options dropdown
|
||||
// If the button exists, set styles based on its position
|
||||
if (buttonPosition && optionsListRef.current) {
|
||||
// This logic checks to see if the options should be above or below the button
|
||||
const buttonBottom = buttonPosition.y + buttonPosition.height;
|
||||
// Get search height to account for that in the height calculation
|
||||
const searchHeight = searchRef.current?.scrollHeight || 0;
|
||||
// Get the height of the options list
|
||||
const optionsListHeight = optionsListRef.current.scrollHeight;
|
||||
// Calculate the height of the options list (capped at maxHeight)
|
||||
const optionsHeight =
|
||||
optionsListHeight + searchHeight > maxHeight
|
||||
? maxHeight - searchHeight
|
||||
: optionsListHeight + searchHeight;
|
||||
// Determine if there is enoughy room below the button to show the entire options list
|
||||
const isEnoughRoomBelow =
|
||||
buttonBottom + optionsHeight < window.innerHeight;
|
||||
const selectStyle = selectRef.current?.style;
|
||||
|
||||
selectStyle?.setProperty("--options-width", `${buttonPosition.width}px`);
|
||||
selectStyle?.setProperty(
|
||||
"--options-top",
|
||||
isEnoughRoomBelow
|
||||
? `${buttonBottom}px`
|
||||
: `${buttonPosition.y - optionsHeight + 8}px`
|
||||
);
|
||||
selectStyle?.setProperty("--options-height", `${optionsHeight}px`);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleToggleVisibility = () => {
|
||||
const isHidden = isVisible;
|
||||
setIsVisible(!isHidden);
|
||||
|
||||
if (!isHidden) {
|
||||
// Get the position of the button when it is clicked
|
||||
if (buttonRef.current)
|
||||
setButtonPosition(buttonRef.current.getBoundingClientRect());
|
||||
|
||||
// Reset state and scroll to selected option
|
||||
setFocused(0);
|
||||
setQuery("");
|
||||
setResults(getOptions());
|
||||
searchRef.current?.focus();
|
||||
|
||||
// Use setTimeout to ensure the DOM is updated before scrolling
|
||||
setTimeout(() => {
|
||||
scrollIntoView();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (onChangeConfirm) {
|
||||
// If a confirm modal is needed, use that handler
|
||||
onChangeConfirm(
|
||||
e.target.value,
|
||||
String(internalValue),
|
||||
() => {
|
||||
if (onChange) {
|
||||
onChange(e.target.value);
|
||||
}
|
||||
setInternalValue(e.target.value);
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
// If no confirm modal is needed, use the normal handler
|
||||
else {
|
||||
if (onChange) onChange?.(e.target.value);
|
||||
setInternalValue(e.target.value);
|
||||
}
|
||||
// Reset state
|
||||
handleReset();
|
||||
};
|
||||
|
||||
const handleClickOption = (value: string | number) => {
|
||||
if (value === currentSelection?.value) {
|
||||
handleReset();
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (resetAfterChoice) setInternalValue("");
|
||||
setIsVisible(false);
|
||||
setQuery("");
|
||||
setResults(getOptions());
|
||||
};
|
||||
|
||||
const handleSearch = (e) => {
|
||||
// Reset results to default
|
||||
if (!e.target.value) {
|
||||
setResults(getOptions());
|
||||
} else {
|
||||
const filteredOptions = getOptions()
|
||||
.filter((opt) =>
|
||||
opt.label.toLowerCase().includes(e.target.value.toLowerCase())
|
||||
)
|
||||
.map((opt, index) => ({
|
||||
...opt,
|
||||
index,
|
||||
}));
|
||||
setResults(filteredOptions);
|
||||
}
|
||||
setQuery(e.target.value);
|
||||
};
|
||||
|
||||
const handleKeypress = (e) => {
|
||||
// Get the currently focused element in the DOM
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
if (isVisible) {
|
||||
// Go to the next option
|
||||
if (focused < results.length) {
|
||||
setFocused(focused + 1);
|
||||
}
|
||||
// If there are no more options, go back to the top (placeholder at index 0)
|
||||
else {
|
||||
setFocused(0);
|
||||
}
|
||||
}
|
||||
// If the button is focused and the dropdown is closed, open it
|
||||
else if (activeElement === buttonRef.current) {
|
||||
setIsVisible(true);
|
||||
setFocused(currentSelection?.index || 0);
|
||||
}
|
||||
scrollIntoView();
|
||||
break;
|
||||
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
if (isVisible) {
|
||||
// Go to the previous option
|
||||
if (focused > 0) {
|
||||
setFocused(focused - 1);
|
||||
}
|
||||
// If there are no previous options, go back to the bottom
|
||||
else {
|
||||
setFocused(results.length);
|
||||
}
|
||||
}
|
||||
// If the button is focused and the dropdown is closed, open it
|
||||
else if (activeElement === buttonRef.current) {
|
||||
setIsVisible(true);
|
||||
setFocused(currentSelection?.index || results.length);
|
||||
}
|
||||
scrollIntoView();
|
||||
break;
|
||||
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
// If the dropdown is open, select the focused option
|
||||
if (isVisible) {
|
||||
if (focused === 0) {
|
||||
// Handle the placeholder option (empty value)
|
||||
handleChange({
|
||||
target: { value: "" },
|
||||
} as ChangeEvent<HTMLInputElement>);
|
||||
buttonRef.current.focus();
|
||||
} else {
|
||||
const focusedOption = results.find((opt) => opt.index === focused);
|
||||
if (focusedOption) {
|
||||
handleChange({
|
||||
target: { value: focusedOption.value.toString() },
|
||||
} as ChangeEvent<HTMLInputElement>);
|
||||
buttonRef.current.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
// If the button is focused and the dropdown is closed, open it
|
||||
else if (activeElement === buttonRef.current) {
|
||||
handleToggleVisibility();
|
||||
}
|
||||
scrollIntoView();
|
||||
break;
|
||||
|
||||
case "Escape":
|
||||
handleReset();
|
||||
break;
|
||||
|
||||
case "Tab":
|
||||
if (isVisible) {
|
||||
// If the search is focused when tab is pressed go to item
|
||||
if (document.activeElement === searchRef.current) {
|
||||
e.preventDefault();
|
||||
setFocused(currentSelection?.index || 0);
|
||||
scrollIntoView();
|
||||
searchRef.current.blur();
|
||||
} else {
|
||||
setFocused(0);
|
||||
searchRef.current?.focus();
|
||||
handleToggleVisibility();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const selectElement = selectRef.current;
|
||||
if (!selectElement) return;
|
||||
|
||||
selectElement.addEventListener("keydown", handleKeypress);
|
||||
|
||||
return () => {
|
||||
selectElement.removeEventListener("keydown", handleKeypress);
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
// keep the search active is searchThreshold is set and results are more than the threshold - this prevents the search from going away if the search results are less than the threshold after a query is entered
|
||||
isSearchActive.current =
|
||||
!!searchThreshold &&
|
||||
searchThreshold >= 0 &&
|
||||
results.length > searchThreshold;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// If the select is visible, prevent the page from scrolling. This prevents us from having to
|
||||
// recalculate the position of the option group on scroll.
|
||||
document.body.style.overflowY = isVisible ? "hidden" : "unset";
|
||||
getOptionsPosition();
|
||||
|
||||
// Scroll to the selected item when the dropdown becomes visible
|
||||
if (isVisible) {
|
||||
setTimeout(() => {
|
||||
scrollIntoView();
|
||||
}, 0);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isVisible, scrollIntoView]);
|
||||
|
||||
const optionsKey = useMemo(
|
||||
() => options.map((o) => ("value" in o ? o.value : o.optGroupLabel)).join(","),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[options]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSearchActive.current) {
|
||||
setResults(getOptions());
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [optionsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSelection) {
|
||||
setFocused(currentSelection.index);
|
||||
}
|
||||
}, [currentSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
setInternalValue(value || "");
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={selectRef}
|
||||
className={clsx([
|
||||
styles.select,
|
||||
isHighlighted && styles.highlighted,
|
||||
className,
|
||||
])}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<button
|
||||
className={clsx([styles.button, styles[size]])}
|
||||
id={`${name}Button`}
|
||||
onClick={(e) => handleToggleVisibility()}
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={`${name}Dropdown`}
|
||||
aria-labelledby={`${name}Label`}
|
||||
aria-expanded={isVisible}
|
||||
tabIndex={0}
|
||||
disabled={disabled || false}
|
||||
ref={buttonRef}
|
||||
>
|
||||
<span className={styles.buttonText}>
|
||||
{currentSelection ? currentSelection?.label : placeholder}
|
||||
</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<div
|
||||
ref={optionsRef}
|
||||
className={clsx([styles.options, styles[optionsWidth]])}
|
||||
role="group"
|
||||
id={`${name}Dropdown`}
|
||||
>
|
||||
{isSearchActive.current && (
|
||||
<input
|
||||
className={styles.search}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={handleSearch}
|
||||
placeholder={searchPlaceholder}
|
||||
ref={searchRef}
|
||||
/>
|
||||
)}
|
||||
<div ref={optionsListRef} className={styles.optionList} role="menu">
|
||||
{!!searchThreshold && query.length > 0 && results.length < 1 && (
|
||||
<label className={styles.notFound}>
|
||||
No results found for <strong>{query}</strong>
|
||||
</label>
|
||||
)}
|
||||
{!query && !hidePlaceholderOption && (
|
||||
<label
|
||||
className={clsx([styles.option, focused === 0 && styles.focused])}
|
||||
aria-selected={!internalValue && query === ""}
|
||||
data-index={0}
|
||||
onMouseEnter={() => setFocused(0)}
|
||||
>
|
||||
<Check className={styles.check} />
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={""}
|
||||
onClick={() => handleClickOption("")}
|
||||
onChange={handleChange}
|
||||
checked={value === ""}
|
||||
/>
|
||||
{placeholder}
|
||||
</label>
|
||||
)}
|
||||
{getOptionsList()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,18 +1,25 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, Fragment, HTMLAttributes, ReactNode, useState } from "react";
|
||||
import {
|
||||
FC,
|
||||
Fragment,
|
||||
HTMLAttributes,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { useCharacterTheme } from "~/contexts/CharacterTheme";
|
||||
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface TabFilterOption {
|
||||
label: ReactNode;
|
||||
content: ReactNode;
|
||||
badge?: ReactNode;
|
||||
}
|
||||
export interface TabFilterProps extends HTMLAttributes<HTMLDivElement> {
|
||||
filters:
|
||||
| {
|
||||
label: ReactNode;
|
||||
content: ReactNode;
|
||||
badge?: ReactNode;
|
||||
}[];
|
||||
filters: Array<TabFilterOption>;
|
||||
showAllTab?: boolean;
|
||||
sharedChildren?: ReactNode;
|
||||
}
|
||||
@@ -30,6 +37,11 @@ export const TabFilter: FC<TabFilterProps> = ({
|
||||
const activeIndex = showAllTab ? activeFilter - 1 : activeFilter;
|
||||
const nonEmptyFilters = filters.filter((f) => f.label !== "");
|
||||
|
||||
// Reset the active filter to the first tab when the filter options change
|
||||
useEffect(() => {
|
||||
setActiveFilter(0);
|
||||
}, [filters.length]);
|
||||
|
||||
const getDefaultLabel = (label: ReactNode) => {
|
||||
if (typeof label === "string") return label;
|
||||
return uuidv4();
|
||||
@@ -87,7 +99,7 @@ export const TabFilter: FC<TabFilterProps> = ({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.content} data-scrollable-container="true">
|
||||
{sharedChildren}
|
||||
{!showAllTab || activeFilter !== 0
|
||||
? nonEmptyFilters[activeIndex]?.content
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import clsx from "clsx";
|
||||
import { FC, HTMLAttributes, ReactNode, useState } from "react";
|
||||
import {
|
||||
FC,
|
||||
HTMLAttributes,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import ChevronDown from "@dndbeyond/fontawesome-cache/svgs/solid/chevron-down.svg";
|
||||
|
||||
import { Popover } from "../Popover";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
/**
|
||||
* This component renders a set of tabs with associated content panels.
|
||||
* It supports different variants for tab behavior, including default,
|
||||
* collapse, and toggle modes. Tabs can be limited in number of visible
|
||||
* items, with overflow handled via a "More" dropdown menu.
|
||||
*
|
||||
** Variants:
|
||||
* - default: Standard tab behavior where clicking a tab shows its content.
|
||||
* - collapse: Always has a selected tab, but clicking it again collapses the content.
|
||||
* - fullwidth: Tabs stretch to fill the container width.
|
||||
*/
|
||||
|
||||
interface TabItem {
|
||||
label: ReactNode;
|
||||
content: ReactNode;
|
||||
@@ -15,44 +34,79 @@ interface TabItem {
|
||||
|
||||
interface TabListProps extends HTMLAttributes<HTMLElement> {
|
||||
tabs: Array<TabItem | null>;
|
||||
variant?: "default" | "collapse" | "toggle";
|
||||
variant?: "default" | "collapse" | "fullwidth";
|
||||
defaultActiveId?: string;
|
||||
maxNavItemsShow?: number;
|
||||
onChangeTab?: (id: string) => void;
|
||||
themed?: boolean;
|
||||
forceShow?: boolean;
|
||||
}
|
||||
|
||||
export const TabList: FC<TabListProps> = ({
|
||||
className,
|
||||
tabs,
|
||||
defaultActiveId,
|
||||
maxNavItemsShow = 7,
|
||||
variant = "default",
|
||||
onChangeTab,
|
||||
themed,
|
||||
forceShow,
|
||||
...props
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState(
|
||||
defaultActiveId ? defaultActiveId : tabs[0]?.id
|
||||
const tabListRef = useRef<HTMLDivElement>(null!);
|
||||
const [activeTab, setActiveTab] = useState(defaultActiveId || tabs[0]?.id);
|
||||
const [isTabVisible, setIsTabVisible] = useState(
|
||||
forceShow ?? activeTab !== ""
|
||||
);
|
||||
const [isTabVisible, setIsTabVisible] = useState(activeTab !== "");
|
||||
|
||||
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);
|
||||
const isToggleable = variant === "collapse";
|
||||
|
||||
const handleTabClick = (id: string) => {
|
||||
if (id === activeTab && variant === "collapse") {
|
||||
setIsTabVisible(!isTabVisible);
|
||||
}
|
||||
|
||||
if (id === activeTab && variant === "toggle") {
|
||||
setActiveTab("");
|
||||
}
|
||||
|
||||
if (id !== activeTab) {
|
||||
setActiveTab(id);
|
||||
setIsTabVisible(true);
|
||||
|
||||
if (onChangeTab) {
|
||||
onChangeTab(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
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);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
// Update CSS variable for number of tabs
|
||||
const tabListStyles = tabListRef.current?.style;
|
||||
if (tabListStyles) {
|
||||
tabListStyles.setProperty("--tab-count", tabs.length.toString());
|
||||
tabListStyles.setProperty(
|
||||
"--tab-active-position",
|
||||
tabs.indexOf(selectedTab as TabItem).toString()
|
||||
);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[tabs.length, selectedTab]
|
||||
);
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
<div
|
||||
className={clsx([
|
||||
styles.tabList,
|
||||
styles[variant],
|
||||
themed && styles.themed,
|
||||
className,
|
||||
])}
|
||||
ref={tabListRef}
|
||||
{...props}
|
||||
>
|
||||
<menu className={styles.tabs}>
|
||||
{/* List of visible tabs */}
|
||||
{visibleTabs.map((tab: TabItem) => (
|
||||
@@ -62,10 +116,13 @@ export const TabList: FC<TabListProps> = ({
|
||||
role="radio"
|
||||
aria-checked={activeTab === tab.id}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
data-testid={tab.id}
|
||||
>
|
||||
{tab.label}
|
||||
{variant !== "default" && (
|
||||
<ChevronDown className={!isTabVisible ? styles.closed : ""} />
|
||||
{isToggleable && (
|
||||
<ChevronDown
|
||||
className={clsx([!isTabVisible && styles.closed])}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface TextareaProps
|
||||
value?: string;
|
||||
onInputBlur: (textValue: string) => void;
|
||||
onInputKeyUp: () => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
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;
|
||||
@@ -26,26 +23,23 @@ export const Toggle: FC<ToggleProps> = ({
|
||||
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();
|
||||
evt.stopPropagation();
|
||||
evt.nativeEvent.stopImmediatePropagation();
|
||||
|
||||
if (onChangePromise) {
|
||||
onChangePromise(
|
||||
!enabled,
|
||||
() => {
|
||||
setEnabled(!enabled);
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
} else if (onClick) {
|
||||
onClick(!enabled);
|
||||
setEnabled(!enabled);
|
||||
}
|
||||
if (onChangePromise) {
|
||||
onChangePromise(
|
||||
!enabled,
|
||||
() => {
|
||||
setEnabled(!enabled);
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
} else if (onClick) {
|
||||
onClick(!enabled);
|
||||
setEnabled(!enabled);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
RuleDataUtils,
|
||||
} from "@dndbeyond/character-rules-engine";
|
||||
|
||||
import { Select } from "~/components/Select";
|
||||
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";
|
||||
@@ -144,15 +144,22 @@ export const XpManager: FC<XpManagerProps> = ({
|
||||
</div>
|
||||
<div className={clsx([styles.controls, styles.withDivider])}>
|
||||
<div className={styles.control}>
|
||||
<label id="set-level" className={styles.label}>
|
||||
<label
|
||||
id="set-level-label"
|
||||
htmlFor="set-levelButton"
|
||||
className={styles.label}
|
||||
>
|
||||
Set Level
|
||||
</label>
|
||||
<Select
|
||||
aria-labelledby="set-level"
|
||||
className={styles.levelSelect}
|
||||
aria-labelledby="set-level-label"
|
||||
options={levelOptions}
|
||||
onChange={handleChooseLevel}
|
||||
value={levelChosen}
|
||||
placeholder={"--"}
|
||||
name="set-level"
|
||||
searchThreshold={null}
|
||||
/>
|
||||
</div>
|
||||
<div className={clsx([styles.control, styles.setXp])}>
|
||||
|
||||
Reference in New Issue
Block a user