New source found from dndbeyond.com

This commit is contained in:
2026-07-08 01:00:09 -07:00
parent 9a983a6d7b
commit dcefa0d2f2
2865 changed files with 222467 additions and 49053 deletions
+14 -7
View File
@@ -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>
);
};
+11 -2
View File
@@ -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}
/>
);
+17 -17
View File
@@ -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";
+21
View File
@@ -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={
+17 -22
View File
@@ -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>
);
};
+58 -14
View File
@@ -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>
);
};
+3 -3
View File
@@ -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>
);
};
+17 -12
View File
@@ -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>
);
};
+545
View File
@@ -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>
);
};
+20 -8
View File
@@ -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
+73 -16
View File
@@ -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;
}
/**
+13 -19
View File
@@ -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);
}
};
+10 -3
View File
@@ -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])}>