Skip to content

Commit

Permalink
roving focus helper
Browse files Browse the repository at this point in the history
  • Loading branch information
huntabyte committed Apr 23, 2024
1 parent 39e8e1b commit bfdde48
Show file tree
Hide file tree
Showing 7 changed files with 115 additions and 188 deletions.
80 changes: 41 additions & 39 deletions packages/bits-ui/src/lib/bits/accordion/accordion.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string>;
node: Box<HTMLElement | null>;
disabled: ReadonlyBox<boolean>;
disabled = undefined as unknown as ReadonlyBox<boolean>;
#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<HTMLElement>(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);
}
Expand Down Expand Up @@ -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({
Expand All @@ -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,
Expand Down Expand Up @@ -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`,
Expand All @@ -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);
}
Expand All @@ -346,9 +345,12 @@ type AccordionState = AccordionSingleState | AccordionMultiState;
type InitAccordionProps = {
type: "single" | "multiple";
value: Box<string> | Box<string[]>;
id: ReadonlyBox<string>;
disabled: ReadonlyBox<boolean>;
};
} & ReadonlyBoxedValues<{
id: string;
disabled: boolean;
orientation: Orientation;
loop: boolean;
}>;

export function setAccordionRootState(props: InitAccordionProps) {
const { type, ...rest } = props;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
el = $bindable(),
id = useId(),
onValueChange,
loop = true,
orientation = "vertical",
...restProps
}: RootProps = $props();
Expand All @@ -29,6 +31,8 @@
) as Box<string> | Box<string[]>,
id: readonlyBox(() => id),
disabled: readonlyBox(() => disabled),
loop: readonlyBox(() => loop),
orientation: readonlyBox(() => orientation),
});
const mergedProps = $derived(mergeProps(restProps, rootState.props));
Expand Down
3 changes: 3 additions & 0 deletions packages/bits-ui/src/lib/bits/accordion/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand Down
127 changes: 47 additions & 80 deletions packages/bits-ui/src/lib/bits/radio-group/radio-group.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
type EventCallback,
type ReadonlyBoxedValues,
boxedState,
composeHandlers,
getAriaChecked,
getAriaRequired,
getDataDisabled,
Expand All @@ -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;
Expand All @@ -38,15 +44,7 @@ class RadioGroupRootState {
orientation = undefined as unknown as RadioGroupRootStateProps["orientation"];
name: RadioGroupRootStateProps["name"];
value: RadioGroupRootStateProps["value"];
activeTabIndexNode = boxedState<HTMLElement | null>(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;
Expand All @@ -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) {
Expand All @@ -67,21 +71,22 @@ 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);
}

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);
}

//
Expand All @@ -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<MouseEvent>;
#composedKeydown = undefined as unknown as EventCallback<KeyboardEvent>;
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);
}

//
Expand Down
2 changes: 2 additions & 0 deletions packages/bits-ui/src/lib/bits/radio-group/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export type RadioGroupItemPropsWithoutHTML = Omit<
onclick?: EventCallback<MouseEvent>;

onkeydown?: EventCallback<KeyboardEvent>;

onfocus?: EventCallback<FocusEvent>;
},
{ checked: boolean }
>,
Expand Down
Loading

0 comments on commit bfdde48

Please sign in to comment.