From bfdde48d6987c80be0d95ce4c04bf42202f68029 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Mon, 22 Apr 2024 20:50:57 -0400 Subject: [PATCH] roving focus helper --- .../lib/bits/accordion/accordion.svelte.ts | 80 +++++------ .../accordion/components/accordion.svelte | 4 + .../bits-ui/src/lib/bits/accordion/types.ts | 3 + .../bits/radio-group/radio-group.svelte.ts | 127 +++++++----------- .../bits-ui/src/lib/bits/radio-group/types.ts | 2 + .../bits-ui/src/lib/bits/tabs/tabs.svelte.ts | 82 +++-------- .../bits/toggle-group/toggle-group.svelte.ts | 5 +- 7 files changed, 115 insertions(+), 188 deletions(-) diff --git a/packages/bits-ui/src/lib/bits/accordion/accordion.svelte.ts b/packages/bits-ui/src/lib/bits/accordion/accordion.svelte.ts index ff8be6e22..57c3bbb73 100644 --- a/packages/bits-ui/src/lib/bits/accordion/accordion.svelte.ts +++ b/packages/bits-ui/src/lib/bits/accordion/accordion.svelte.ts @@ -7,19 +7,25 @@ import { afterTick, getAriaDisabled, getAriaExpanded, - getAttrAndSelector, + getAriaOrientation, getDataDisabled, getDataOpenClosed, + getDataOrientation, kbd, useNodeById, verifyContextDeps, } from "$lib/internal/index.js"; +import { + type UseRovingFocusReturn, + useRovingFocus, +} from "$lib/internal/use-roving-focus.svelte.js"; +import type { Orientation } from "$lib/shared/index.js"; -const [ROOT_ATTR] = getAttrAndSelector("accordion-root"); -const [TRIGGER_ATTR, TRIGGER_SELECTOR] = getAttrAndSelector("accordion-trigger"); -const [CONTENT_ATTR] = getAttrAndSelector("accordion-content"); -const [ITEM_ATTR] = getAttrAndSelector("accordion-item"); -const [HEADER_ATTR] = getAttrAndSelector("accordion-header"); +const ROOT_ATTR = "accordion-root"; +const TRIGGER_ATTR = "accordion-trigger"; +const CONTENT_ATTR = "accordion-content"; +const ITEM_ATTR = "accordion-item"; +const HEADER_ATTR = "accordion-header"; // // BASE @@ -28,30 +34,37 @@ const [HEADER_ATTR] = getAttrAndSelector("accordion-header"); type AccordionBaseStateProps = ReadonlyBoxedValues<{ id: string; disabled: boolean; + orientation: Orientation; + loop: boolean; }>; class AccordionBaseState { id = undefined as unknown as ReadonlyBox; node: Box; - disabled: ReadonlyBox; + disabled = undefined as unknown as ReadonlyBox; + #loop = undefined as unknown as AccordionBaseStateProps["loop"]; + orientation = undefined as unknown as AccordionBaseStateProps["orientation"]; + rovingFocusGroup = undefined as unknown as UseRovingFocusReturn; constructor(props: AccordionBaseStateProps) { this.id = props.id; this.disabled = props.disabled; - this.node = useNodeById(this.id); - } - - getTriggerNodes() { - const node = this.node.value; - if (!node) return []; - return Array.from(node.querySelectorAll(TRIGGER_SELECTOR)).filter( - (el) => !el.hasAttribute("data-disabled") - ); + this.orientation = props.orientation; + this.#loop = props.loop; + this.rovingFocusGroup = useRovingFocus({ + rootNode: this.node, + candidateSelector: TRIGGER_ATTR, + loop: this.#loop, + orientation: this.orientation, + }); } props = $derived({ id: this.id.value, + "data-orientation": getDataOrientation(this.orientation.value), + "data-disabled": getDataDisabled(this.disabled.value), + "aria-orientation": getAriaOrientation(this.orientation.value), [ROOT_ATTR]: "", } as const); } @@ -189,31 +202,13 @@ class AccordionTriggerState { }; #onkeydown = (e: KeyboardEvent) => { - const handledKeys = [kbd.ARROW_DOWN, kbd.ARROW_UP, kbd.HOME, kbd.END, kbd.SPACE, kbd.ENTER]; - if (this.#isDisabled || !handledKeys.includes(e.key)) return; - - e.preventDefault(); - if (e.key === kbd.SPACE || e.key === kbd.ENTER) { + e.preventDefault(); this.#itemState.updateValue(); return; } - if (!this.#root.node.value || !this.#node.value) return; - - const candidateItems = this.#root.getTriggerNodes(); - if (!candidateItems.length) return; - - const currentIndex = candidateItems.indexOf(this.#node.value); - - const keyToIndex = { - [kbd.ARROW_DOWN]: (currentIndex + 1) % candidateItems.length, - [kbd.ARROW_UP]: (currentIndex - 1 + candidateItems.length) % candidateItems.length, - [kbd.HOME]: 0, - [kbd.END]: candidateItems.length - 1, - }; - - candidateItems[keyToIndex[e.key]!]?.focus(); + this.#root.rovingFocusGroup.handleKeydown(this.#node.value, e); }; props = $derived({ @@ -223,7 +218,9 @@ class AccordionTriggerState { "aria-disabled": getAriaDisabled(this.#isDisabled), "data-disabled": getDataDisabled(this.#isDisabled), "data-state": getDataOpenClosed(this.#itemState.isSelected), + "data-orientation": getDataOrientation(this.#root.orientation.value), [TRIGGER_ATTR]: "", + tabindex: 0, // onclick: this.#onclick, onkeydown: this.#onkeydown, @@ -305,6 +302,7 @@ class AccordionContentState { id: this.#id.value, "data-state": getDataOpenClosed(this.item.isSelected), "data-disabled": getDataDisabled(this.item.isDisabled), + "data-orientation": getDataOrientation(this.item.root.orientation.value), [CONTENT_ATTR]: "", style: { "--bits-accordion-content-height": `${this.#height}px`, @@ -330,6 +328,7 @@ class AccordionHeaderState { "aria-level": this.#level.value, "data-heading-level": this.#level.value, "data-state": getDataOpenClosed(this.#item.isSelected), + "data-orientation": getDataOrientation(this.#item.root.orientation.value), [HEADER_ATTR]: "", } as const); } @@ -346,9 +345,12 @@ type AccordionState = AccordionSingleState | AccordionMultiState; type InitAccordionProps = { type: "single" | "multiple"; value: Box | Box; - id: ReadonlyBox; - disabled: ReadonlyBox; -}; +} & ReadonlyBoxedValues<{ + id: string; + disabled: boolean; + orientation: Orientation; + loop: boolean; +}>; export function setAccordionRootState(props: InitAccordionProps) { const { type, ...rest } = props; diff --git a/packages/bits-ui/src/lib/bits/accordion/components/accordion.svelte b/packages/bits-ui/src/lib/bits/accordion/components/accordion.svelte index 6373a5534..f5d1f54d4 100644 --- a/packages/bits-ui/src/lib/bits/accordion/components/accordion.svelte +++ b/packages/bits-ui/src/lib/bits/accordion/components/accordion.svelte @@ -13,6 +13,8 @@ el = $bindable(), id = useId(), onValueChange, + loop = true, + orientation = "vertical", ...restProps }: RootProps = $props(); @@ -29,6 +31,8 @@ ) as Box | Box, id: readonlyBox(() => id), disabled: readonlyBox(() => disabled), + loop: readonlyBox(() => loop), + orientation: readonlyBox(() => orientation), }); const mergedProps = $derived(mergeProps(restProps, rootState.props)); diff --git a/packages/bits-ui/src/lib/bits/accordion/types.ts b/packages/bits-ui/src/lib/bits/accordion/types.ts index ec0e688e9..709ed5d27 100644 --- a/packages/bits-ui/src/lib/bits/accordion/types.ts +++ b/packages/bits-ui/src/lib/bits/accordion/types.ts @@ -7,9 +7,12 @@ import type { TransitionParams, WithAsChild, } from "$lib/internal/index.js"; +import type { Orientation } from "$lib/shared/index.js"; type BaseAccordionProps = { disabled?: boolean; + loop?: boolean; + orientation?: Orientation; }; export type SingleAccordionProps = BaseAccordionProps & { diff --git a/packages/bits-ui/src/lib/bits/radio-group/radio-group.svelte.ts b/packages/bits-ui/src/lib/bits/radio-group/radio-group.svelte.ts index 43794baa8..4726e6840 100644 --- a/packages/bits-ui/src/lib/bits/radio-group/radio-group.svelte.ts +++ b/packages/bits-ui/src/lib/bits/radio-group/radio-group.svelte.ts @@ -5,7 +5,6 @@ import { type EventCallback, type ReadonlyBoxedValues, boxedState, - composeHandlers, getAriaChecked, getAriaRequired, getDataDisabled, @@ -18,6 +17,13 @@ import { verifyContextDeps, } from "$lib/internal/index.js"; import type { Orientation } from "$lib/shared/index.js"; +import { + type UseRovingFocusReturn, + useRovingFocus, +} from "$lib/internal/use-roving-focus.svelte.js"; + +const ROOT_ATTR = "radio-group-root"; +const ITEM_ATTR = "radio-group-item"; type RadioGroupRootStateProps = ReadonlyBoxedValues<{ id: string; @@ -38,15 +44,7 @@ class RadioGroupRootState { orientation = undefined as unknown as RadioGroupRootStateProps["orientation"]; name: RadioGroupRootStateProps["name"]; value: RadioGroupRootStateProps["value"]; - activeTabIndexNode = boxedState(null); - props = $derived({ - id: this.id.value, - role: "radiogroup", - "aria-required": getAriaRequired(this.required.value), - "data-disabled": getDataDisabled(this.disabled.value), - "data-orientation": this.orientation.value, - "data-radio-group": "", - } as const); + rovingFocusGroup = undefined as unknown as UseRovingFocusReturn; constructor(props: RadioGroupRootStateProps) { this.id = props.id; @@ -57,6 +55,12 @@ class RadioGroupRootState { this.name = props.name; this.value = props.value; this.node = useNodeById(this.id); + this.rovingFocusGroup = useRovingFocus({ + rootNode: this.node, + candidateSelector: ITEM_ATTR, + loop: this.loop, + orientation: this.orientation, + }); } isChecked(value: string) { @@ -67,14 +71,6 @@ class RadioGroupRootState { this.value.value = value; } - getRadioItemNodes() { - if (!this.node.value) return []; - - return Array.from(this.node.value.querySelectorAll("[data-radio-group-item]")).filter( - (el): el is HTMLElement => el instanceof HTMLElement && !el.dataset.disabled - ); - } - createItem(props: RadioGroupItemStateProps) { return new RadioGroupItemState(props, this); } @@ -82,6 +78,15 @@ class RadioGroupRootState { createInput() { return new RadioGroupInputState(this); } + + props = $derived({ + id: this.id.value, + role: "radiogroup", + "aria-required": getAriaRequired(this.required.value), + "data-disabled": getDataDisabled(this.disabled.value), + "data-orientation": this.orientation.value, + [ROOT_ATTR]: "", + } as const); } // @@ -102,88 +107,50 @@ class RadioGroupItemState { #root = undefined as unknown as RadioGroupRootState; #disabled = undefined as unknown as RadioGroupItemStateProps["disabled"]; #value = undefined as unknown as RadioGroupItemStateProps["value"]; - #composedClick = undefined as unknown as EventCallback; - #composedKeydown = undefined as unknown as EventCallback; checked = $derived(this.#root.value.value === this.#value.value); #isDisabled = $derived(this.#disabled.value || this.#root.disabled.value); #isChecked = $derived(this.#root.isChecked(this.#value.value)); - props = $derived({ - id: this.#id.value, - disabled: this.#isDisabled ? true : undefined, - "data-value": this.#value.value, - "data-orientation": this.#root.orientation.value, - "data-disabled": getDataDisabled(this.#isDisabled), - "data-state": this.#isChecked ? "checked" : "unchecked", - "aria-checked": getAriaChecked(this.#isChecked), - "data-radio-group-item": "", - type: "button", - role: "radio", - tabIndex: this.#root.activeTabIndexNode.value === this.#node.value ? 0 : -1, - // - onclick: this.#composedClick, - onkeydown: this.#composedKeydown, - } as const); - - indicatorProps = $derived({ - "data-disabled": getDataDisabled(this.#isDisabled), - "data-state": this.#isChecked ? "checked" : "unchecked", - "data-radio-group-item-indicator": "", - } as const); constructor(props: RadioGroupItemStateProps, root: RadioGroupRootState) { this.#disabled = props.disabled; this.#value = props.value; this.#root = root; this.#id = props.id; - this.#composedClick = composeHandlers(props.onclick, this.#onclick); - this.#composedKeydown = composeHandlers(props.onkeydown, this.#onkeydown); this.#node = useNodeById(this.#id); - - $effect(() => { - if (!this.#node.value) return; - if (!this.#root.isChecked(this.#value.value)) return; - this.#root.activeTabIndexNode.value = this.#node.value; - }); } #onclick = () => { this.#root.selectValue(this.#value.value); }; - #onkeydown = (e: KeyboardEvent) => { - const node = this.#node.value; - const rootNode = this.#root.node.value; - if (!node || !rootNode) return; - const items = this.#root.getRadioItemNodes(); - if (!items.length) return; - - const currentIndex = items.indexOf(node); - - const dir = getElemDirection(rootNode); - const { nextKey, prevKey } = getDirectionalKeys(dir, this.#root.orientation.value); - - const loop = this.#root.loop.value; - - const keyToIndex = { - [nextKey]: currentIndex + 1, - [prevKey]: currentIndex - 1, - [kbd.HOME]: 0, - [kbd.END]: items.length - 1, - }; + #onfocus = () => { + this.#root.selectValue(this.#value.value); + }; - let itemIndex = keyToIndex[e.key]; - if (itemIndex === undefined) return; - e.preventDefault(); + #onkeydown = (e: KeyboardEvent) => { + this.#root.rovingFocusGroup.handleKeydown(this.#node.value, e); + }; - if (itemIndex < 0 && loop) itemIndex = items.length - 1; - else if (itemIndex === items.length && loop) itemIndex = 0; + #tabIndex = $derived(this.#root.rovingFocusGroup.getTabIndex(this.#node.value).value); - const itemToFocus = items[itemIndex]; - if (!itemToFocus) return; - itemToFocus.focus(); - this.#root.selectValue(itemToFocus.dataset.value as string); - }; + props = $derived({ + id: this.#id.value, + disabled: this.#isDisabled ? true : undefined, + "data-value": this.#value.value, + "data-orientation": this.#root.orientation.value, + "data-disabled": getDataDisabled(this.#isDisabled), + "data-state": this.#isChecked ? "checked" : "unchecked", + "aria-checked": getAriaChecked(this.#isChecked), + [ITEM_ATTR]: "", + type: "button", + role: "radio", + tabindex: this.#tabIndex, + // + onclick: this.#onclick, + onkeydown: this.#onkeydown, + onfocus: this.#onfocus, + } as const); } // diff --git a/packages/bits-ui/src/lib/bits/radio-group/types.ts b/packages/bits-ui/src/lib/bits/radio-group/types.ts index c7e3275bf..ecfbd1a14 100644 --- a/packages/bits-ui/src/lib/bits/radio-group/types.ts +++ b/packages/bits-ui/src/lib/bits/radio-group/types.ts @@ -81,6 +81,8 @@ export type RadioGroupItemPropsWithoutHTML = Omit< onclick?: EventCallback; onkeydown?: EventCallback; + + onfocus?: EventCallback; }, { checked: boolean } >, diff --git a/packages/bits-ui/src/lib/bits/tabs/tabs.svelte.ts b/packages/bits-ui/src/lib/bits/tabs/tabs.svelte.ts index c23cd3113..7e3951e1b 100644 --- a/packages/bits-ui/src/lib/bits/tabs/tabs.svelte.ts +++ b/packages/bits-ui/src/lib/bits/tabs/tabs.svelte.ts @@ -18,11 +18,15 @@ import { verifyContextDeps, } from "$lib/internal/index.js"; import type { Orientation } from "$lib/shared/index.js"; +import { + type UseRovingFocusReturn, + useRovingFocus, +} from "$lib/internal/use-roving-focus.svelte.js"; -const [ROOT_ATTR] = getAttrAndSelector("tabs-root"); -const [LIST_ATTR] = getAttrAndSelector("tabs-list"); -const [TRIGGER_ATTR, TRIGGER_SELECTOR] = getAttrAndSelector("tabs-trigger"); -const [CONTENT_ATTR] = getAttrAndSelector("tabs-content"); +const ROOT_ATTR = "tabs-root"; +const LIST_ATTR = "tabs-list"; +const TRIGGER_ATTR = "tabs-trigger"; +const CONTENT_ATTR = "tabs-content"; type TabsRootStateProps = ReadonlyBoxedValues<{ id: string; @@ -42,9 +46,8 @@ class TabsRootState { loop = undefined as unknown as TabsRootStateProps["loop"]; activationMode = undefined as unknown as TabsRootStateProps["activationMode"]; value = undefined as unknown as TabsRootStateProps["value"]; - activeTabId = boxedState(""); - anyActive = $derived(this.value.value !== ""); disabled = undefined as unknown as TabsRootStateProps["disabled"]; + rovingFocusGroup = undefined as unknown as UseRovingFocusReturn; constructor(props: TabsRootStateProps) { this.id = props.id; @@ -54,14 +57,12 @@ class TabsRootState { this.value = props.value; this.disabled = props.disabled; this.node = useNodeById(this.id); - } - - getTriggerNodes() { - const node = this.node.value; - if (!node) return []; - return Array.from(node.querySelectorAll(TRIGGER_SELECTOR)).filter( - (el) => !el.hasAttribute("data-disabled") - ); + this.rovingFocusGroup = useRovingFocus({ + candidateSelector: TRIGGER_ATTR, + rootNode: this.node, + loop: this.loop, + orientation: this.orientation, + }); } setValue(v: string) { @@ -160,59 +161,10 @@ class TabsTriggerState { this.activate(); return; } - - const node = this.#node.value; - const rootNode = this.#root.node.value; - if (!node || !rootNode) return; - - const items = this.#root.getTriggerNodes(); - if (!items.length) return; - const currentIndex = items.indexOf(node); - const dir = getElemDirection(rootNode); - const { nextKey, prevKey } = getDirectionalKeys(dir, this.#root.orientation.value); - - const loop = this.#root.loop.value; - - const keyToIndex = { - [nextKey]: currentIndex + 1, - [prevKey]: currentIndex - 1, - [kbd.HOME]: 0, - [kbd.END]: items.length - 1, - }; - - let itemIndex = keyToIndex[e.key]; - if (itemIndex === undefined) return; - e.preventDefault(); - - if (itemIndex < 0 && loop) { - itemIndex = items.length - 1; - } else if (itemIndex === items.length && loop) { - itemIndex = 0; - } - - const itemToFocus = items[itemIndex]; - if (!itemToFocus) return; - itemToFocus.focus(); - this.#root.activeTabId.value = itemToFocus.id; + this.#root.rovingFocusGroup.handleKeydown(this.#node.value, e); }; - #tabIndex = $derived.by(() => { - const node = this.#node.value; - if (!node) return -1; - const items = this.#root.getTriggerNodes(); - const anyActive = this.#root.anyActive; - if (!anyActive && items[0] === node) { - this.#root.activeTabId.value = this.#id.value; - return 0; - } else if (!this.#root.activeTabId.value && items[0] === node) { - this.#root.activeTabId.value = this.#id.value; - return 0; - } else if (this.#id.value === this.#root.activeTabId.value) { - return 0; - } - - return -1; - }); + #tabIndex = $derived(this.#root.rovingFocusGroup.getTabIndex(this.#node.value).value); props = $derived({ id: this.#id.value, diff --git a/packages/bits-ui/src/lib/bits/toggle-group/toggle-group.svelte.ts b/packages/bits-ui/src/lib/bits/toggle-group/toggle-group.svelte.ts index dddb7c818..673f48e8d 100644 --- a/packages/bits-ui/src/lib/bits/toggle-group/toggle-group.svelte.ts +++ b/packages/bits-ui/src/lib/bits/toggle-group/toggle-group.svelte.ts @@ -1,8 +1,7 @@ -import { getContext, setContext, untrack } from "svelte"; +import { getContext, setContext } from "svelte"; import { getAriaChecked, getAriaPressed, - getAttrAndSelector, getDataDisabled, getDataOrientation, } from "$lib/internal/attrs.js"; @@ -13,8 +12,6 @@ import { boxedState, } from "$lib/internal/box.svelte.js"; import { kbd } from "$lib/internal/kbd.js"; -import { getDirectionalKeys } from "$lib/internal/get-directional-keys.js"; -import { getElemDirection } from "$lib/internal/locale.js"; import { useNodeById } from "$lib/internal/use-node-by-id.svelte.js"; import type { Orientation } from "$lib/shared/index.js"; import { verifyContextDeps } from "$lib/internal/context.js";