diff --git a/ddb_main/components/Accordion/Accordion.tsx b/ddb_main/components/Accordion/Accordion.tsx index 9231649..efe58cc 100644 --- a/ddb_main/components/Accordion/Accordion.tsx +++ b/ddb_main/components/Accordion/Accordion.tsx @@ -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 { - 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 = ({ variant = "default", children, forceShow = false, - // style, - useTheme, + themed, resetOpen, handleIsOpen, override = null, @@ -101,7 +107,7 @@ export const Accordion: FC = ({ styles.details, styles[size], styles[variant], - useTheme && styles.theme, + themed && styles.themed, showAlert && styles.alert, className, ])} @@ -113,6 +119,7 @@ export const Accordion: FC = ({ className={styles.summary} onClick={handleToggle} ref={summaryRef} + data-testid="accordion-summary" > {summaryImage && ( = ({ /> )}
-
+
{summary} {summaryMetaItems && (
diff --git a/ddb_main/components/AccordionSummary/AccordionSummary.tsx b/ddb_main/components/AccordionSummary/AccordionSummary.tsx new file mode 100644 index 0000000..416b0d1 --- /dev/null +++ b/ddb_main/components/AccordionSummary/AccordionSummary.tsx @@ -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, "title"> { + imageUrl?: string; + title: ReactNode; + description?: string | string[]; + callout?: ReactNode; +} + +export const AccordionSummary: FC = ({ + callout, + className, + description, + imageUrl, + title, + ...props +}) => { + const buildDescription = () => { + if (Array.isArray(description)) { + return description.join(" • "); + } + return description; + }; + + return ( +
+ {imageUrl && } +
+
{title}
+ {description && ( +

{buildDescription()}

+ )} +
+ {callout &&
{callout}
} +
+ ); +}; diff --git a/ddb_main/components/Button/Button.tsx b/ddb_main/components/Button/Button.tsx index d24ca24..827c2a0 100644 --- a/ddb_main/components/Button/Button.tsx +++ b/ddb_main/components/Button/Button.tsx @@ -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 = ({ 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 ( = ({ variant={!isCustomVariant ? variant : undefined} // Children are included in props color={!isCustomColor ? color : undefined} + style={overrideStyles} {...props} /> ); diff --git a/ddb_main/components/Checkbox/Checkbox.tsx b/ddb_main/components/Checkbox/Checkbox.tsx index 555ac17..28e6fa2 100644 --- a/ddb_main/components/Checkbox/Checkbox.tsx +++ b/ddb_main/components/Checkbox/Checkbox.tsx @@ -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 = ({ variant = "default", darkMode, checked = false, - onChangePromise, - onClick, + onChangeConfirm, + onChange, disabled, ...props }) => { const [isChecked, setIsChecked] = useState(checked); - useEffect(() => { - setIsChecked(checked); - }, [checked]); - const handleChange = (evt: ChangeEvent): 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 ( ) => void; + onChangeConfirm?: ( + newIsEnabled: boolean, + accept: () => void, + reject: () => void + ) => void; + sortOrder?: number; + value?: string | number; +} + +interface CheckboxGroupProps + extends Omit, "title"> { + accordionDefaultOpen?: boolean; + accordionSummary?: string; + checkboxes: Array; + disabled?: boolean; + description?: ReactNode; + name: string; + onToggleAll?: (e: ChangeEvent) => void; + showAccordion?: boolean; + themed?: boolean; + toggleAllLabel?: string; + title: ReactNode; + variant?: "default" | "inline" | "collapse"; +} + +export const CheckboxGroup: FC = ({ + 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 ( +
+ +
+ + {checkbox.description && ( +
+ )} +
+
+ ); + }); + + /** Handle Collapse Variant separately since it's structure is different */ + if (isCollapseVariant) + return ( + {title}

} + variant="text" + forceShow={accordionDefaultOpen} + {...props} + > + {description && ( +
{description}
+ )} + {onToggleAll && ( + + )} + {checkboxList} +
+ ); + + return ( +
+
+
+

{title}

+ {isInlineVariant && checkboxes.length < 2 && ( + + )} +
+ {description && ( +
{description}
+ )} +
+ {!isInlineVariant && onToggleAll && ( + + )} + {!isInlineVariant && ( + + {checkboxList} + + )} +
+ ); +}; diff --git a/ddb_main/components/ConfirmModal/ConfirmModal.tsx b/ddb_main/components/ConfirmModal/ConfirmModal.tsx index 98d5f13..ef7e33a 100644 --- a/ddb_main/components/ConfirmModal/ConfirmModal.tsx +++ b/ddb_main/components/ConfirmModal/ConfirmModal.tsx @@ -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"; diff --git a/ddb_main/components/Dialog/Dialog.tsx b/ddb_main/components/Dialog/Dialog.tsx new file mode 100644 index 0000000..752f706 --- /dev/null +++ b/ddb_main/components/Dialog/Dialog.tsx @@ -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 = ({ className, ...props }) => { + return ; +}; diff --git a/ddb_main/components/FeatureChoices/FeatureChoice/FeatureChoice.tsx b/ddb_main/components/FeatureChoices/FeatureChoice/FeatureChoice.tsx index 29e3f47..72d929e 100644 --- a/ddb_main/components/FeatureChoices/FeatureChoice/FeatureChoice.tsx +++ b/ddb_main/components/FeatureChoices/FeatureChoice/FeatureChoice.tsx @@ -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 { charClass?: CharClass; featsData: Feat[]; subclassData?: ClassDefinitionContract[]; + itemShoppe?: ContainerManager | null; onChoiceChange: ( choiceId: string, type: number, @@ -33,6 +35,7 @@ export const FeatureChoice: FC = ({ choice, featsData, subclassData, + itemShoppe, onChoiceChange, className, collapseDescription, @@ -69,13 +72,14 @@ export const FeatureChoice: FC = ({ } }; - 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 = ({ options={options} onChange={handleChoiceChange} description={description || ""} + descriptionIntro={introDescription || ""} choiceInfo={choiceInfo} classId={charClass && classUtils.getId(charClass)} showBackgroundProficiencyOptions={true} diff --git a/ddb_main/components/FeatureChoices/FeatureChoices.tsx b/ddb_main/components/FeatureChoices/FeatureChoices.tsx index 2d602c5..e4c5413 100644 --- a/ddb_main/components/FeatureChoices/FeatureChoices.tsx +++ b/ddb_main/components/FeatureChoices/FeatureChoices.tsx @@ -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 { ) => 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 = ({ @@ -45,7 +51,9 @@ export const FeatureChoices: FC = ({ 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 = ({ apiCreatorSelectors.makeLoadAvailableSubclasses ); + const { inventoryManager } = useContext(InventoryManagerContext); + const [subclassData, setSubclassData] = useState< Array >([]); const [featsData, setFeatsData] = useState>([]); + const [localItemShoppe, setLocalItemShoppe] = + useState(itemShoppe); const [featLoadingStatus, setFeatLoadingStatus] = useState(DataLoadingStatusEnum.NOT_INITIALIZED); const [subclassLoadingStatus, setSubclassLoadingStatus] = useState(DataLoadingStatusEnum.NOT_INITIALIZED); + const [itemsLoadingStatus, setItemsLoadingStatus] = + useState(DataLoadingStatusEnum.NOT_INITIALIZED); const hasFeatChoice = choices.some( (choice) => @@ -77,15 +91,23 @@ export const FeatureChoices: FC = ({ 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 = []; @@ -99,17 +121,22 @@ export const FeatureChoices: FC = ({ 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 = []; @@ -121,17 +148,55 @@ export const FeatureChoices: FC = ({ 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 = ({ 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 = ({ charClass={charClass} choice={choice} featsData={featsData} + itemShoppe={localItemShoppe} subclassData={subclassData} className={styles.choice} key={ChoiceUtils.getId(choice)} diff --git a/ddb_main/components/GuidedTour/GuidedTour.tsx b/ddb_main/components/GuidedTour/GuidedTour.tsx new file mode 100644 index 0000000..f92297b --- /dev/null +++ b/ddb_main/components/GuidedTour/GuidedTour.tsx @@ -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 { + afterOpen?: (target: Element | null) => void; + beforeClose?: (target: Element | null) => void; + cookieName?: string; + setStep?: SetStateAction; + showOnFirstLoad?: boolean; + steps?: StepType[]; + step?: number; +} + +export const GuidedTour: FC = ({ + 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>) => { + if (showOnFirstLoad) { + const viewedTour = localStorage.getItem(cookieName); + + if (!viewedTour) { + setIsOpen(true); + localStorage.setItem(cookieName, "true"); + } + } + }; + + const PrevButton = ({ Button, ...props }) => ( + +
+ )} + + +
+ ); +}; diff --git a/ddb_main/components/GuidedTourStep/GuidedTourStep.tsx b/ddb_main/components/GuidedTourStep/GuidedTourStep.tsx new file mode 100644 index 0000000..7152806 --- /dev/null +++ b/ddb_main/components/GuidedTourStep/GuidedTourStep.tsx @@ -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, "content"> { + title: string; + content: ReactNode; + showClose?: boolean; +} + +export const GuidedTourStep: FC = ({ + className, + content, + showClose, + title, + ...props +}) => { + const { setIsOpen } = useTour(); + + return ( +
+

{title}

+
{content}
+ {showClose && ( + + )} +
+ ); +}; diff --git a/ddb_main/components/HelperTextAccordion/HelperTextAccordion.tsx b/ddb_main/components/HelperTextAccordion/HelperTextAccordion.tsx index 90c3000..6b6e4dd 100644 --- a/ddb_main/components/HelperTextAccordion/HelperTextAccordion.tsx +++ b/ddb_main/components/HelperTextAccordion/HelperTextAccordion.tsx @@ -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 = ({ builderHelperText, + className, ...props }) => { return ( <> {builderHelperText.map((helperText, idx) => ( { +interface InfoItemProps extends HTMLAttributes { 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 = ({ className, - onClick, + inline = true, + label, + children, ...props -}) => { - const handleClick = useUnpropagatedClick(onClick); - - return ( - - ); -}; +}) => ( +
+

{label}

+

{children}

+
+); diff --git a/ddb_main/components/InfusionPreview/InfusionPreview.tsx b/ddb_main/components/InfusionPreview/InfusionPreview.tsx new file mode 100644 index 0000000..2b781bd --- /dev/null +++ b/ddb_main/components/InfusionPreview/InfusionPreview.tsx @@ -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, "onClick"> { + infusion: Infusion; + onClick?: (infusion: Infusion) => void; +} +export const InfusionPreview: FC = ({ + 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 = []; + 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 ( +
+
+ Infusion: {InfusionUtils.getName(infusion)} +
+
+ {isAccessible ? ( + + {InfusionUtils.getSnippet(infusion)} + + ) : ( + + )} +
+
+ ); +}; diff --git a/ddb_main/components/Layout/Layout.tsx b/ddb_main/components/Layout/Layout.tsx index 77480b1..82b60bd 100644 --- a/ddb_main/components/Layout/Layout.tsx +++ b/ddb_main/components/Layout/Layout.tsx @@ -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 {} export const Layout: FC = ({ 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 = ({ 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 (
- {/* TODO: fetch navItems */} -
- - {/* TODO: fetch sources */} - -
+ {!isVttView && + createPortal( + , + document.getElementById("header-wrapper")! + )}
{children}
- {!matchSheet && ( -
-
-
- )} + {!isVttView && + (!matchSheet || + matchSheet.pathname === "/characters/builder" || + matchSheet.pathname === "/characters/premade") && + createPortal(
, document.getElementById("footer-wrapper")!)}
); }; diff --git a/ddb_main/components/Link/Link.tsx b/ddb_main/components/Link/Link.tsx index 786fef9..5a663d1 100644 --- a/ddb_main/components/Link/Link.tsx +++ b/ddb_main/components/Link/Link.tsx @@ -6,7 +6,7 @@ interface LinkProps extends Omit, "onClick"> { onClick?: Function; useTheme?: boolean; - useRouter?: boolean; + userouter?: boolean; } export const Link: FC = ({ @@ -15,7 +15,7 @@ export const Link: FC = ({ 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 = ({ } }; - if (useRouter) + if (userouter) return ( { + sourceName?: string | null; + sourceNames?: Array; + url?: string; + description?: string | null; + className?: string; +} +export const MarketplaceCta: FC = ({ + sourceName = null, + sourceNames, + url = "/marketplace", + description = null, + className, + ...props +}) => { + const allSourceNames: Array = []; + + if (sourceName) { + allSourceNames.push(sourceName); + } + + if (sourceNames) { + allSourceNames.push(...sourceNames); + } + + return ( + + ); +}; diff --git a/ddb_main/components/MaxCharactersDialog/MaxCharactersDialog.tsx b/ddb_main/components/MaxCharactersDialog/MaxCharactersDialog.tsx index 4e3a684..c2c2580 100644 --- a/ddb_main/components/MaxCharactersDialog/MaxCharactersDialog.tsx +++ b/ddb_main/components/MaxCharactersDialog/MaxCharactersDialog.tsx @@ -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 { +interface MaxCharactersDialogProps + extends Omit { open: boolean; onClose: () => void; useMyCharactersLink?: boolean; + hasLockedCharacters?: boolean; } export const MaxCharactersDialog: React.FC = ({ open, onClose, useMyCharactersLink, + hasLockedCharacters, + className, ...props }) => { - const dialogRef = useRef(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 ( - -
-

Your character slots are full

-
- -
- + +
+ +
+
+ {!hasLockedCharacters && ( + + )} + -
-
+ ); }; diff --git a/ddb_main/components/MaxCharactersMessageText/MaxCharactersMessageText.tsx b/ddb_main/components/MaxCharactersMessageText/MaxCharactersMessageText.tsx index 9258b6a..c66696f 100644 --- a/ddb_main/components/MaxCharactersMessageText/MaxCharactersMessageText.tsx +++ b/ddb_main/components/MaxCharactersMessageText/MaxCharactersMessageText.tsx @@ -6,12 +6,14 @@ interface MaxCharactersMessageTextProps extends HTMLAttributes { includeTitle?: boolean; useLinks?: boolean; useMyCharactersLink?: boolean; + hasLockedCharacters?: boolean; } export const MaxCharactersMessageText: FC = ({ includeTitle, useLinks, useMyCharactersLink, + hasLockedCharacters, ...props }) => { const subscribe = useLinks ? ( @@ -31,6 +33,21 @@ export const MaxCharactersMessageText: FC = ({ "My Characters" ); + if (hasLockedCharacters) { + return ( +
+

+ Your character slots are full. To create a new character, select which + characters to keep using the options below, or{" "} + + subscribe + {" "} + to unlock additional slots. +

+
+ ); + } + return (
{includeTitle && ( diff --git a/ddb_main/components/NoResultsFound/NoResultsFound.tsx b/ddb_main/components/NoResultsFound/NoResultsFound.tsx new file mode 100644 index 0000000..406a096 --- /dev/null +++ b/ddb_main/components/NoResultsFound/NoResultsFound.tsx @@ -0,0 +1,18 @@ +import { HTMLAttributes, FC, ReactNode } from "react"; + +import styles from "./styles.module.css"; + +export interface NoResultsFoundProps extends HTMLAttributes { + message?: ReactNode; +} + +export const NoResultsFound: FC = ({ + message = "No results found", + ...props +}) => { + return ( +

+ {message} +

+ ); +}; diff --git a/ddb_main/components/Popover/Popover.tsx b/ddb_main/components/Popover/Popover.tsx index fcac944..9ccd365 100644 --- a/ddb_main/components/Popover/Popover.tsx +++ b/ddb_main/components/Popover/Popover.tsx @@ -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 = ({ // 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 { diff --git a/ddb_main/components/PrivacyTypeRadio/PrivacyTypeRadio.tsx b/ddb_main/components/PrivacyTypeRadio/PrivacyTypeRadio.tsx new file mode 100644 index 0000000..cd6af8c --- /dev/null +++ b/ddb_main/components/PrivacyTypeRadio/PrivacyTypeRadio.tsx @@ -0,0 +1,34 @@ +import { FC } from "react"; + +import { PreferencePrivacyTypeEnum } from "~/constants"; + +import { RadioGroup, RadioGroupProps } from "../RadioGroup"; + +export interface PrivacyTypeRadioProps + extends Omit {} + +export const PrivacyTypeRadio: FC = (props) => ( + +); diff --git a/ddb_main/components/RadioGroup/RadioGroup.tsx b/ddb_main/components/RadioGroup/RadioGroup.tsx new file mode 100644 index 0000000..6fbbe6c --- /dev/null +++ b/ddb_main/components/RadioGroup/RadioGroup.tsx @@ -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, "onChange"> { + name: string; + initialValue?: string | number; + title: string; + options: RadioOption[]; + description?: string; + onChange?: (e: ChangeEvent) => void; + onChangeConfirm?: ( + e: ChangeEvent, + newValue: string | number, + oldValue: string | number, + accept: () => void, + reject: () => void + ) => void; + disabled?: boolean; + themed?: boolean; +} + +export const RadioGroup: FC = ({ + className, + name, + initialValue, + title, + options, + description, + disabled, + onChange, + onChangeConfirm, + themed, + ...props +}) => { + const radioGroupRef = useRef(null!); + const [selectedValue, setSelectedValue] = useState( + initialValue || options[0]?.value + ); + + const handleChange = (e: ChangeEvent) => { + 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 ( +
+ {title} + {description &&

{description}

} +
+ {options?.map((option) => { + const id = `${name}_${option.value}`; + const checked = String(selectedValue) === String(option.value); + + return ( +
+ +
+ + {option.description && ( +

{option.description}

+ )} +
+
+ ); + })} +
+
+ ); +}; diff --git a/ddb_main/components/Select/Select.tsx b/ddb_main/components/Select/Select.tsx new file mode 100644 index 0000000..5573f9c --- /dev/null +++ b/ddb_main/components/Select/Select.tsx @@ -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, + "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 = ({ + 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(null!); + const searchRef = useRef(null!); + const optionsRef = useRef(null!); + const optionsListRef = useRef(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(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) => ( + + )), + ]; + + 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 ( +
+ + {buildOptionsHtml(options)} +
+ ); + } + // 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) => { + 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); + buttonRef.current.focus(); + } else { + const focusedOption = results.find((opt) => opt.index === focused); + if (focusedOption) { + handleChange({ + target: { value: focusedOption.value.toString() }, + } as ChangeEvent); + 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 ( +
{ + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + }} + {...props} + > + +
+ {isSearchActive.current && ( + + )} +
+ {!!searchThreshold && query.length > 0 && results.length < 1 && ( + + )} + {!query && !hidePlaceholderOption && ( + + )} + {getOptionsList()} +
+
+
+ ); +}; diff --git a/ddb_main/components/TabFilter/TabFilter.tsx b/ddb_main/components/TabFilter/TabFilter.tsx index 3367aa5..6433615 100644 --- a/ddb_main/components/TabFilter/TabFilter.tsx +++ b/ddb_main/components/TabFilter/TabFilter.tsx @@ -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 { - filters: - | { - label: ReactNode; - content: ReactNode; - badge?: ReactNode; - }[]; + filters: Array; showAllTab?: boolean; sharedChildren?: ReactNode; } @@ -30,6 +37,11 @@ export const TabFilter: FC = ({ 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 = ({ ); })}
-
+
{sharedChildren} {!showAllTab || activeFilter !== 0 ? nonEmptyFilters[activeIndex]?.content diff --git a/ddb_main/components/TabList/TabList.tsx b/ddb_main/components/TabList/TabList.tsx index 100f05a..f996f3a 100644 --- a/ddb_main/components/TabList/TabList.tsx +++ b/ddb_main/components/TabList/TabList.tsx @@ -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 { tabs: Array; - variant?: "default" | "collapse" | "toggle"; + variant?: "default" | "collapse" | "fullwidth"; defaultActiveId?: string; maxNavItemsShow?: number; + onChangeTab?: (id: string) => void; + themed?: boolean; + forceShow?: boolean; } export const TabList: FC = ({ + className, tabs, defaultActiveId, maxNavItemsShow = 7, variant = "default", + onChangeTab, + themed, + forceShow, ...props }) => { - const [activeTab, setActiveTab] = useState( - defaultActiveId ? defaultActiveId : tabs[0]?.id + const tabListRef = useRef(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 ( -
+
{/* List of visible tabs */} {visibleTabs.map((tab: TabItem) => ( @@ -62,10 +116,13 @@ export const TabList: FC = ({ role="radio" aria-checked={activeTab === tab.id} onClick={() => handleTabClick(tab.id)} + data-testid={tab.id} > {tab.label} - {variant !== "default" && ( - + {isToggleable && ( + )} diff --git a/ddb_main/components/Textarea/Textarea.tsx b/ddb_main/components/Textarea/Textarea.tsx index 54d33c8..51cd3f3 100644 --- a/ddb_main/components/Textarea/Textarea.tsx +++ b/ddb_main/components/Textarea/Textarea.tsx @@ -15,6 +15,7 @@ export interface TextareaProps value?: string; onInputBlur: (textValue: string) => void; onInputKeyUp: () => void; + placeholder?: string; } /** diff --git a/ddb_main/components/Toggle/Toggle.tsx b/ddb_main/components/Toggle/Toggle.tsx index 62f93dc..108a89c 100644 --- a/ddb_main/components/Toggle/Toggle.tsx +++ b/ddb_main/components/Toggle/Toggle.tsx @@ -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, "onClick"> { - checked?: boolean; color?: "primary" | "secondary" | "warning" | "error" | "info" | "success" | "themed"; onClick?: (isEnabled: boolean) => void; @@ -26,26 +23,23 @@ export const Toggle: FC = ({ 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); } }; diff --git a/ddb_main/components/XpManager/XpManager.tsx b/ddb_main/components/XpManager/XpManager.tsx index 1609299..caa2bcb 100644 --- a/ddb_main/components/XpManager/XpManager.tsx +++ b/ddb_main/components/XpManager/XpManager.tsx @@ -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 = ({
-