From a00e175aebf4b8cb312eee41c853469eb38d668d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20M=20J=20Barata=20Ribeiro?= <122732773+Barata-Ribeiro@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:54:04 -0300 Subject: [PATCH 1/8] feat(hooks): add useAsRef hook for managing mutable references --- app/hooks/use-as-ref.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 app/hooks/use-as-ref.ts diff --git a/app/hooks/use-as-ref.ts b/app/hooks/use-as-ref.ts new file mode 100644 index 0000000..8dd4bdf --- /dev/null +++ b/app/hooks/use-as-ref.ts @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { useIsomorphicLayoutEffect } from '~/hooks/use-isomorphic-layout-effect'; + +function useAsRef(props: T) { + const ref = React.useRef(props); + + useIsomorphicLayoutEffect(() => { + ref.current = props; + }); + + return ref; +} + +export { useAsRef }; From 4001329fb5821b2f056617c418d117d4134df43e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20M=20J=20Barata=20Ribeiro?= <122732773+Barata-Ribeiro@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:54:13 -0300 Subject: [PATCH 2/8] feat(lib): add composeRefs and useComposedRefs utilities for managing multiple refs --- app/lib/compose-refs.ts | 66 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 app/lib/compose-refs.ts diff --git a/app/lib/compose-refs.ts b/app/lib/compose-refs.ts new file mode 100644 index 0000000..a416b69 --- /dev/null +++ b/app/lib/compose-refs.ts @@ -0,0 +1,66 @@ +/** + * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx + */ + +import * as React from 'react'; + +type PossibleRef = React.Ref | undefined; + +/** + * Set a given ref to a given value + * This utility takes care of different types of refs: callback refs and RefObject(s) + */ +function setRef(ref: PossibleRef, value: T) { + if (typeof ref === 'function') { + return ref(value); + } + + if (ref !== null && ref !== undefined) { + ref.current = value; + } +} + +/** + * A utility to compose multiple refs together + * Accepts callback refs and RefObject(s) + */ +function composeRefs(...refs: PossibleRef[]): React.RefCallback { + return (node) => { + let hasCleanup = false; + const cleanups = refs.map((ref) => { + const cleanup = setRef(ref, node); + if (!hasCleanup && typeof cleanup === 'function') { + hasCleanup = true; + } + return cleanup; + }); + + // React <19 will log an error to the console if a callback ref returns a + // value. We don't use ref cleanups internally so this will only happen if a + // user's ref callback returns a value, which we only expect if they are + // using the cleanup functionality added in React 19. + if (hasCleanup) { + return () => { + for (let i = 0; i < cleanups.length; i++) { + const cleanup = cleanups[i]; + if (typeof cleanup === 'function') { + cleanup(); + } else { + setRef(refs[i], null); + } + } + }; + } + }; +} + +/** + * A custom hook that composes multiple refs + * Accepts callback refs and RefObject(s) + */ +function useComposedRefs(...refs: PossibleRef[]): React.RefCallback { + // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values + return React.useCallback(composeRefs(...refs), refs); +} + +export { composeRefs, useComposedRefs }; From 714b7c9cfe3174b92d536b694928889389a29f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20M=20J=20Barata=20Ribeiro?= <122732773+Barata-Ribeiro@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:54:20 -0300 Subject: [PATCH 3/8] feat(UI): add Portal component for rendering children into a DOM node --- app/components/portal.tsx | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 app/components/portal.tsx diff --git a/app/components/portal.tsx b/app/components/portal.tsx new file mode 100644 index 0000000..dece1d4 --- /dev/null +++ b/app/components/portal.tsx @@ -0,0 +1,36 @@ +import { Slot as SlotPrimitive } from 'radix-ui'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; + +type SlotProps = React.ComponentProps; + +interface PortalProps extends SlotProps { + container?: Element | DocumentFragment | null; +} + +function subscribe() { + return () => {}; +} + +function getSnapshot() { + return true; +} + +function getServerSnapshot() { + return false; +} + +function Portal(props: PortalProps) { + const { container: containerProp, ...portalProps } = props; + + const mounted = React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + const container = containerProp ?? (mounted ? globalThis.document?.body : null); + + if (!container) return null; + + return ReactDOM.createPortal(, container); +} + +export { Portal }; +export type { PortalProps }; From 431678d2fb6dd09e2998c51b89239ec1d0bb9bce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20M=20J=20Barata=20Ribeiro?= <122732773+Barata-Ribeiro@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:54:28 -0300 Subject: [PATCH 4/8] feat(UI): implement ActionBar component with context and keyboard navigation --- app/components/ui/action-bar.tsx | 629 +++++++++++++++++++++++++++++++ 1 file changed, 629 insertions(+) create mode 100644 app/components/ui/action-bar.tsx diff --git a/app/components/ui/action-bar.tsx b/app/components/ui/action-bar.tsx new file mode 100644 index 0000000..e9a4105 --- /dev/null +++ b/app/components/ui/action-bar.tsx @@ -0,0 +1,629 @@ +import { Direction as DirectionPrimitive, Slot as SlotPrimitive } from 'radix-ui'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { Button } from '~/components/ui/button'; +import { useAsRef } from '~/hooks/use-as-ref'; +import { useIsomorphicLayoutEffect } from '~/hooks/use-isomorphic-layout-effect'; +import { useComposedRefs } from '~/lib/compose-refs'; +import { cn } from '~/lib/utils'; + +const ROOT_NAME = 'ActionBar'; +const GROUP_NAME = 'ActionBarGroup'; +const ITEM_NAME = 'ActionBarItem'; +const CLOSE_NAME = 'ActionBarClose'; +const SEPARATOR_NAME = 'ActionBarSeparator'; +const ITEM_SELECT = 'actionbar.itemSelect'; +const ENTRY_FOCUS = 'actionbarFocusGroup.onEntryFocus'; +const EVENT_OPTIONS = { bubbles: false, cancelable: true }; + +type Direction = 'ltr' | 'rtl'; +type Orientation = 'horizontal' | 'vertical'; + +interface DivProps extends React.ComponentProps<'div'> { + asChild?: boolean; +} + +type RootElement = React.ComponentRef; +type ItemElement = React.ComponentRef; +type CloseElement = React.ComponentRef; + +function focusFirst(candidates: React.RefObject[], preventScroll = false) { + const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement; + for (const candidateRef of candidates) { + const candidate = candidateRef.current; + if (!candidate) continue; + if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return; + candidate.focus({ preventScroll }); + if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return; + } +} + +function wrapArray(array: T[], startIndex: number) { + return array.map((_, index) => array[(startIndex + index) % array.length] as T); +} + +function getDirectionAwareKey(key: string, dir?: Direction) { + if (dir !== 'rtl') return key; + const reversedKey = key === 'ArrowRight' ? 'ArrowLeft' : key; + return key === 'ArrowLeft' ? 'ArrowRight' : reversedKey; +} + +interface ItemData { + id: string; + ref: React.RefObject; + disabled: boolean; +} + +interface ActionBarContextValue { + onOpenChange?: (open: boolean) => void; + dir: Direction; + orientation: Orientation; + loop: boolean; +} + +const ActionBarContext = React.createContext(null); + +function useActionBarContext(consumerName: string) { + const context = React.useContext(ActionBarContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); + } + return context; +} + +interface FocusContextValue { + tabStopId: string | null; + onItemFocus: (tabStopId: string) => void; + onItemShiftTab: () => void; + onFocusableItemAdd: () => void; + onFocusableItemRemove: () => void; + onItemRegister: (item: ItemData) => void; + onItemUnregister: (id: string) => void; + getItems: () => ItemData[]; +} + +const FocusContext = React.createContext(null); + +function useFocusContext(consumerName: string) { + const context = React.useContext(FocusContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`FocusProvider\``); + } + return context; +} + +interface ActionBarProps extends DivProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; + onEscapeKeyDown?: (event: KeyboardEvent) => void; + align?: 'start' | 'center' | 'end'; + alignOffset?: number; + side?: 'top' | 'bottom'; + sideOffset?: number; + portalContainer?: Element | DocumentFragment | null; + dir?: Direction; + orientation?: Orientation; + loop?: boolean; +} + +function ActionBar(props: Readonly) { + const { + open = false, + onOpenChange, + onEscapeKeyDown, + side = 'bottom', + alignOffset = 0, + align = 'center', + sideOffset = 16, + portalContainer: portalContainerProp, + dir: dirProp, + orientation = 'horizontal', + loop = true, + className, + style, + ref, + asChild, + ...rootProps + } = props; + + const [mounted, setMounted] = React.useState(false); + + const rootRef = React.useRef(null); + const composedRef = useComposedRefs(ref, rootRef); + + const propsRef = useAsRef({ + onEscapeKeyDown, + onOpenChange, + }); + + const dir = DirectionPrimitive.useDirection(dirProp); + + React.useLayoutEffect(() => { + setMounted(true); + }, []); + + React.useEffect(() => { + if (!open) return; + + const ownerDocument = rootRef.current?.ownerDocument ?? document; + + function onKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + propsRef.current.onEscapeKeyDown?.(event); + if (!event.defaultPrevented) { + propsRef.current.onOpenChange?.(false); + } + } + } + + ownerDocument.addEventListener('keydown', onKeyDown); + return () => ownerDocument.removeEventListener('keydown', onKeyDown); + }, [open, propsRef]); + + const contextValue = React.useMemo( + () => ({ + onOpenChange, + dir, + orientation, + loop, + }), + [onOpenChange, dir, orientation, loop], + ); + + const portalContainer = portalContainerProp ?? (mounted ? globalThis.document?.body : null); + + if (!portalContainer || !open) return null; + + const RootPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + {ReactDOM.createPortal( + , + portalContainer, + )} + + ); +} + +function ActionBarSelection(props: Readonly) { + const { className, asChild, ...selectionProps } = props; + + const SelectionPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + ); +} + +function ActionBarGroup(props: Readonly) { + const { + onBlur: onBlurProp, + onFocus: onFocusProp, + onMouseDown: onMouseDownProp, + className, + asChild, + ref, + ...groupProps + } = props; + + const [tabStopId, setTabStopId] = React.useState(null); + const [isTabbingBackOut, setIsTabbingBackOut] = React.useState(false); + const [focusableItemCount, setFocusableItemCount] = React.useState(0); + + const groupRef = React.useRef(null); + const composedRef = useComposedRefs(ref, groupRef); + const isClickFocusRef = React.useRef(false); + const itemsRef = React.useRef>(new Map()); + + const { dir, orientation } = useActionBarContext(GROUP_NAME); + + const onItemFocus = React.useCallback((tabStopId: string) => { + setTabStopId(tabStopId); + }, []); + + const onItemShiftTab = React.useCallback(() => { + setIsTabbingBackOut(true); + }, []); + + const onFocusableItemAdd = React.useCallback(() => { + setFocusableItemCount((prevCount) => prevCount + 1); + }, []); + + const onFocusableItemRemove = React.useCallback(() => { + setFocusableItemCount((prevCount) => prevCount - 1); + }, []); + + const onItemRegister = React.useCallback((item: ItemData) => { + itemsRef.current.set(item.id, item); + }, []); + + const onItemUnregister = React.useCallback((id: string) => { + itemsRef.current.delete(id); + }, []); + + const getItems = React.useCallback(() => { + return Array.from(itemsRef.current.values()) + .filter((item) => item.ref.current) + .sort((a, b) => { + const elementA = a.ref.current; + const elementB = b.ref.current; + if (!elementA || !elementB) return 0; + const position = elementA.compareDocumentPosition(elementB); + if (position & Node.DOCUMENT_POSITION_FOLLOWING) { + return -1; + } + if (position & Node.DOCUMENT_POSITION_PRECEDING) { + return 1; + } + return 0; + }); + }, []); + + const onBlur = React.useCallback( + (event: React.FocusEvent) => { + onBlurProp?.(event); + if (event.defaultPrevented) return; + + setIsTabbingBackOut(false); + }, + [onBlurProp], + ); + + const onFocus = React.useCallback( + (event: React.FocusEvent) => { + onFocusProp?.(event); + if (event.defaultPrevented) return; + + const isKeyboardFocus = !isClickFocusRef.current; + if (event.target === event.currentTarget && isKeyboardFocus && !isTabbingBackOut) { + const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS); + event.currentTarget.dispatchEvent(entryFocusEvent); + + if (!entryFocusEvent.defaultPrevented) { + const items = Array.from(itemsRef.current.values()).filter((item) => !item.disabled); + const currentItem = items.find((item) => item.id === tabStopId); + + const candidateItems = [currentItem, ...items].filter(Boolean) as ItemData[]; + const candidateRefs = candidateItems.map((item) => item.ref); + focusFirst(candidateRefs, false); + } + } + isClickFocusRef.current = false; + }, + [onFocusProp, isTabbingBackOut, tabStopId], + ); + + const onMouseDown = React.useCallback( + (event: React.MouseEvent) => { + onMouseDownProp?.(event); + if (event.defaultPrevented) return; + + isClickFocusRef.current = true; + }, + [onMouseDownProp], + ); + + const focusContextValue = React.useMemo( + () => ({ + tabStopId, + onItemFocus, + onItemShiftTab, + onFocusableItemAdd, + onFocusableItemRemove, + onItemRegister, + onItemUnregister, + getItems, + }), + [ + tabStopId, + onItemFocus, + onItemShiftTab, + onFocusableItemAdd, + onFocusableItemRemove, + onItemRegister, + onItemUnregister, + getItems, + ], + ); + + const GroupPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + + + ); +} + +interface ActionBarItemProps extends Omit, 'onSelect'> { + onSelect?: (event: Event) => void; +} + +function ActionBarItem(props: Readonly) { + const { + onSelect, + onClick: onClickProp, + onFocus: onFocusProp, + onKeyDown: onKeyDownProp, + onMouseDown: onMouseDownProp, + className, + disabled, + ref, + ...itemProps + } = props; + + const itemRef = React.useRef(null); + const composedRef = useComposedRefs(ref, itemRef); + const isMouseClickRef = React.useRef(false); + + const { onOpenChange, dir, orientation, loop } = useActionBarContext(ITEM_NAME); + const focusContext = useFocusContext(ITEM_NAME); + + const itemId = React.useId(); + const isTabStop = focusContext.tabStopId === itemId; + + useIsomorphicLayoutEffect(() => { + focusContext.onItemRegister({ + id: itemId, + ref: itemRef, + disabled: !!disabled, + }); + + if (!disabled) { + focusContext.onFocusableItemAdd(); + } + + return () => { + focusContext.onItemUnregister(itemId); + if (!disabled) { + focusContext.onFocusableItemRemove(); + } + }; + }, [focusContext, itemId, disabled]); + + const onClick = React.useCallback( + (event: React.MouseEvent) => { + onClickProp?.(event); + if (event.defaultPrevented) return; + + const item = itemRef.current; + if (!item) return; + + const itemSelectEvent = new CustomEvent(ITEM_SELECT, { + bubbles: true, + cancelable: true, + }); + + item.addEventListener(ITEM_SELECT, (event) => onSelect?.(event), { + once: true, + }); + + item.dispatchEvent(itemSelectEvent); + + if (!itemSelectEvent.defaultPrevented) { + onOpenChange?.(false); + } + }, + [onClickProp, onOpenChange, onSelect], + ); + + const onFocus = React.useCallback( + (event: React.FocusEvent) => { + onFocusProp?.(event); + if (event.defaultPrevented) return; + + focusContext.onItemFocus(itemId); + isMouseClickRef.current = false; + }, + [onFocusProp, focusContext, itemId], + ); + + const onKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + onKeyDownProp?.(event); + if (event.defaultPrevented) return; + + if (event.key === 'Tab' && event.shiftKey) { + focusContext.onItemShiftTab(); + return; + } + + if (event.target !== event.currentTarget) return; + + const key = getDirectionAwareKey(event.key, dir); + let focusIntent: 'first' | 'last' | 'prev' | 'next' | undefined; + + if (orientation === 'horizontal') { + if (key === 'ArrowLeft') focusIntent = 'prev'; + else if (key === 'ArrowRight') focusIntent = 'next'; + else if (key === 'Home') focusIntent = 'first'; + else if (key === 'End') focusIntent = 'last'; + } else if (key === 'ArrowUp') focusIntent = 'prev'; + else if (key === 'ArrowDown') focusIntent = 'next'; + else if (key === 'Home') focusIntent = 'first'; + else if (key === 'End') focusIntent = 'last'; + + if (focusIntent !== undefined) { + if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return; + event.preventDefault(); + + const items = focusContext.getItems().filter((item) => !item.disabled); + let candidateRefs = items.map((item) => item.ref); + + if (focusIntent === 'last') { + candidateRefs.reverse(); + } else if (focusIntent === 'prev' || focusIntent === 'next') { + if (focusIntent === 'prev') candidateRefs.reverse(); + const currentIndex = candidateRefs.findIndex((ref) => ref.current === event.currentTarget); + candidateRefs = loop + ? wrapArray(candidateRefs, currentIndex + 1) + : candidateRefs.slice(currentIndex + 1); + } + + queueMicrotask(() => focusFirst(candidateRefs)); + } + }, + [onKeyDownProp, focusContext, dir, orientation, loop], + ); + + const onMouseDown = React.useCallback( + (event: React.MouseEvent) => { + onMouseDownProp?.(event); + if (event.defaultPrevented) return; + + isMouseClickRef.current = true; + + if (disabled) { + event.preventDefault(); + } else { + focusContext.onItemFocus(itemId); + } + }, + [onMouseDownProp, focusContext, itemId, disabled], + ); + + return ( + - - - - - -
- {hasRolledDice && - (() => { - const { outcome, outcomeClassName } = calculateResults( - currentRoll.regularDiceRoll, - currentRoll.hungerDiceRoll, - currentRoll.difficulty, - ); - - return ( -
- {outcome} -
- ); - })()} - {hasRolledDice ? ( -
-
- {currentRoll.regularDiceRoll.map(({ result, id }) => { - switch (true) { - case result < 6: - return ( - - onItemSelect({ id, result }, checked as boolean) - } - > + {fieldState.invalid && ( + + )} + + )} + /> + + + + + + + + + +
+ {hasRolledDice && + (() => { + const { outcome, outcomeClassName } = calculateResults( + currentRoll.regularDiceRoll, + currentRoll.hungerDiceRoll, + currentRoll.difficulty, + ); + + return ( +
+ {outcome} +
+ ); + })()} + {hasRolledDice ? ( +
+
+ {currentRoll.regularDiceRoll.map(({ result, id }) => { + switch (true) { + case result < 6: + return ( + + onItemSelect({ id, result }, checked as boolean) + } + > + {`Regular + + ); + case result >= 6 && result < 10: + return ( + + onItemSelect({ id, result }, checked as boolean) + } + > + {`Regular + + ); + case result === 10: + return ( + + onItemSelect({ id, result }, checked as boolean) + } + > + {`Regular + + ); + default: + return null; + } + })} + {currentRoll.hungerDiceRoll.map(({ id, result }) => { + switch (true) { + case result > 1 && result < 6: + return ( {`Regular - - ); - case result >= 6 && result < 10: - return ( - - onItemSelect({ id, result }, checked as boolean) - } - > + ); + case result >= 6 && result < 10: + return ( {`Regular - - ); - case result === 10: - return ( - - onItemSelect({ id, result }, checked as boolean) - } - > + ); + case result === 10: + return ( {`Regular - - ); - default: - return null; - } - })} - {currentRoll.hungerDiceRoll.map(({ id, result }) => { - switch (true) { - case result > 1 && result < 6: - return ( - {`Hunger - ); - case result >= 6 && result < 10: - return ( - {`Hunger - ); - case result === 10: - return ( - {`Hunger - ); - case result === 1: - return ( - {`Hunger - ); - default: - return null; - } - })} + ); + case result === 1: + return ( + {`Hunger + ); + default: + return null; + } + })} +
-
- ) : ( -

No dice rolled yet.

- )} -
- - - -

- Disclaimer: Dice Images from the{' '} - No dice rolled yet.

+ )} +
+ + + +

+ Disclaimer: Dice Images from the{' '} + + 5th Edition Art Pack + {' '} + from White Wolf. +

+
+ + + 0} + onOpenChange={onOpenChange} + > + + {selectedRegularDice.size} selected + + + + + + + + + Willpower Reroll + + - 5th Edition Art Pack - {' '} - from White Wolf. -

- - + Cancel +
+
+
+ ); } From d96640556011aac57f4d182c31cedf0d6bbd13f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20M=20J=20Barata=20Ribeiro?= <122732773+Barata-Ribeiro@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:21:05 -0300 Subject: [PATCH 8/8] feat(dice-roller): add vibration feedback for dice rolling and selection actions --- .../utilities/dice-roller/vtm-dice-roller.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/components/pages/utilities/dice-roller/vtm-dice-roller.tsx b/app/components/pages/utilities/dice-roller/vtm-dice-roller.tsx index 9f44989..651b270 100644 --- a/app/components/pages/utilities/dice-roller/vtm-dice-roller.tsx +++ b/app/components/pages/utilities/dice-roller/vtm-dice-roller.tsx @@ -111,6 +111,10 @@ export default function VtmDiceRoller() { const id = crypto.randomUUID(); setCurrentRoll((prev) => ({ ...prev, hungerDiceRoll: [...prev.hungerDiceRoll, { id, result: roll }] })); } + + if (navigator.vibrate) { + navigator.vibrate([100, 50, 100]); + } } const onSubmitFn = useCallback( @@ -246,6 +250,10 @@ export default function VtmDiceRoller() { return prev; } + if (navigator.vibrate) { + navigator.vibrate(50); + } + next.add(die.id); } else { next.delete(die.id); @@ -263,6 +271,10 @@ export default function VtmDiceRoller() { const onClearSelection = useCallback(() => { setSelectedRegularDice(new Set()); + + if (navigator.vibrate) { + navigator.vibrate(100); + } }, []); const onWillpowerReroll = useCallback(() => { @@ -282,6 +294,10 @@ export default function VtmDiceRoller() { setCurrentRoll((prev) => ({ ...prev, regularDiceRoll: newRegularDiceRoll })); setSelectedRegularDice(new Set()); + + if (navigator.vibrate) { + navigator.vibrate([100, 50, 100]); + } }, [currentRoll.regularDiceRoll, selectedRegularDice]); const hasRolledDice = currentRoll.regularDiceRoll.length > 0 || currentRoll.hungerDiceRoll.length > 0;