diff --git a/.changeset/public-crabs-joke.md b/.changeset/public-crabs-joke.md new file mode 100644 index 000000000..9e8e4a980 --- /dev/null +++ b/.changeset/public-crabs-joke.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/focus": patch +--- + +Add focusGroup diff --git a/packages/focus/README.md b/packages/focus/README.md index 3939c28ae..52996ceba 100644 --- a/packages/focus/README.md +++ b/packages/focus/README.md @@ -17,6 +17,7 @@ The native `autofocus` attribute only works on page load, which makes it incompa - [`createAutofocus`](#createautofocus) - Reactive primitive to autofocus an element on render. - [`createFocusTrap`](#createfocustrap) - Traps focus inside a given DOM element. - [`createFocusRestore`](#createfocusrestore) - Restores focus to the previously focused element, without trapping. +- [`createFocusGroup`](#createfocusgroup) - Imperatively moves focus between the focusable elements of a container. ## Installation @@ -174,17 +175,95 @@ const Popover: Component<{ open: boolean }> = props => { ### Props -| Prop | Type | Default | Description | -| ------------------- | ----------------------------------- | -------------------------- | ------------------------------------------------------------------------ | +| Prop | Type | Default | Description | +| ------------------- | ---------------------------------- | -------------------------- | ---------------------------------------------------------------------- | | `enabled` | `MaybeAccessor` | `true` | Whether focus-restore is active. | | `element` | `MaybeAccessor` | `document.body` | Element to dispatch the `onFinalFocus` event on. | | `finalFocusElement` | `MaybeAccessor` | Previously focused element | Element to focus when deactivated. | | `onFinalFocus` | `(event: Event) => void` | — | Callback when focus restores. Call `event.preventDefault()` to cancel. | +## `createFocusGroup` + +`createFocusGroup` creates a [FocusGroup](#focusgroup) that moves focus between the focusable elements of a container — e.g. arrow-key navigation in a menu, listbox or toolbar. It walks the DOM with a `TreeWalker`, either restricting itself to tabbable elements or considering everything focusable. Keyboard navigation (arrow keys, Home/End, Tab) is enabled by default: the `keydown` listener is attached to the focus group ref automatically. + +### How to use it + +```tsx +import { createFocusGroup } from "@solid-primitives/focus"; + +const [ref, setRef] = createSignal(); + +// Keyboard navigation is attached to the ref automatically. +createFocusGroup(ref); + +return ( +
+ + + +
+); +``` + +The returned group also exposes imperative methods for moving focus, e.g. inside a click handler: + +```tsx +const group = createFocusGroup(ref); + +return ; +``` + +### `FocusGroup` + +The object returned by `createFocusGroup`. Each method focuses its target and returns it (or `undefined` when there is nothing to move to). Methods accept an options object: + +| Method | Description | +| ----------------- | ------------------------------------------------------- | +| `focusNext()` | Moves focus to the next focusable/tabbable element. | +| `focusPrevious()` | Moves focus to the previous focusable/tabbable element. | +| `focusFirst()` | Moves focus to the first focusable/tabbable element. | +| `focusLast()` | Moves focus to the last focusable/tabbable element. | + +### Keyboard navigation + +Keyboard navigation is enabled by default and can be disabled with the `keyboardNavigation` option. The `keydown` listener is attached to the focus group ref (removed when the ref changes or the group is disposed): + +- **Arrow keys** move focus between items, following `orientation` and `textDirection`. Home/End jump to the first/last item. +- **Tab/Shift+Tab** move within the group when `handleTab` is enabled and focus is already inside it; at a boundary the browser takes over. +- **`wrap: true`** loops around at the ends. + +```tsx +createFocusGroup(ref, () => ({ + orientation: "horizontal", + wrap: true, +})); +``` + +### Options + +Options can be passed per-method-call or as default options (second argument to `createFocusGroup`, applied to every method call): + +```tsx +const group = createFocusGroup(ref, () => ({ wrap: true, tabbable: true })); +``` + +| Option | Type | Default | Description | +| -------------------- | ------------------------------ | ----------------- | -------------------------------------------------------------------- | +| `from` | `Element` | Currently focused | Element to start searching from. | +| `tabbable` | `boolean` | `false` | Only include tabbable elements (`tabindex="-1"` excluded). | +| `wrap` | `boolean` | `false` | Wrap around when reaching the end of the container. | +| `accept` | `(node) => boolean` | — | Callback determining whether an element is eligible for focus. | +| `orientation` | `MaybeAccessor` | `"vertical"` | The orientation of the focus group (`"vertical"` or `"horizontal"`). | +| `textDirection` | `MaybeAccessor` | `"ltr"` | The text direction of the focus group (`"ltr"` or `"rtl"`). | +| `handleTab` | `MaybeAccessor` | `true` | Whether tab key presses should be handled. | +| `keyboardNavigation` | `MaybeAccessor` | `true` | Whether the `keydown` listener is attached to the ref. | + ## Credits `createFocusTrap` is ported from [solid-focus-trap](https://github.com/corvudev/corvu/tree/main/packages/solid-focus-trap), part of the [corvu](https://corvu.dev) UI toolkit by [Jasmin Noetzli (GiyoMoon)](https://github.com/GiyoMoon). Licensed under the MIT License. +`createFocusGroup` is ported from [kobalte](https://kobalte.dev)'s [`createFocusManager`](https://github.com/kobaltedev/kobalte/blob/main/packages/utils/src/focus-manager.ts), which in turn is based on [react-spectrum](https://react-spectrum.adobe.com)'s `FocusManager` (Apache License 2.0, Copyright 2020 Adobe). + ## Changelog See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/focus/package.json b/packages/focus/package.json index a5420bd81..516f395b2 100644 --- a/packages/focus/package.json +++ b/packages/focus/package.json @@ -29,6 +29,8 @@ "autofocus", "createAutofocus", "createFocusTrap", + "createFocusRestore", + "createFocusGroup", "makeFocusListener", "createFocusSignal" ], diff --git a/packages/focus/src/focusGroup.ts b/packages/focus/src/focusGroup.ts new file mode 100644 index 000000000..0288897bc --- /dev/null +++ b/packages/focus/src/focusGroup.ts @@ -0,0 +1,395 @@ +/* + * Ported from kobalte's focus-manager. + * Portions of this file are based on code from react-spectrum. + * Apache License Version 2.0, Copyright 2020 Adobe. + * + * Credits to the Kobalte team: + * https://github.com/kobaltedev/kobalte/blob/main/packages/utils/src/focus-manager.ts + * + * Credits to the React Spectrum team: + * https://github.com/adobe/react-spectrum/blob/7638f4d671e32b7aa2db110a875fe48f24b68fd0/packages/react-aria/src/focus/FocusScope.tsx + */ + +import { access, type MaybeAccessor } from "@solid-primitives/utils"; +import { createEffect, type Accessor } from "solid-js"; +import { + FOCUSABLE_ELEMENT_SELECTOR, + isElementVisible, + TABBABLE_ELEMENT_SELECTOR, +} from "./tabbable.ts"; + +export type Orientation = "vertical" | "horizontal"; +export type TextDirection = "ltr" | "rtl"; + +export interface FocusGroup { + /** Moves focus to the next focusable or tabbable element in the focus scope. */ + focusNext(opts?: FocusGroupOptions): HTMLElement | undefined; + + /** Moves focus to the previous focusable or tabbable element in the focus scope. */ + focusPrevious(opts?: FocusGroupOptions): HTMLElement | undefined; + + /** Moves focus to the first focusable or tabbable element in the focus scope. */ + focusFirst(opts?: FocusGroupOptions): HTMLElement | undefined; + + /** Moves focus to the last focusable or tabbable element in the focus scope. */ + focusLast(opts?: FocusGroupOptions): HTMLElement | undefined; +} + +export interface FocusGroupOptions { + /** The element to start searching from. The currently focused element by default. */ + from?: Element; + + /** Whether to only include tabbable elements, or all focusable elements. */ + tabbable?: boolean; + + /** Whether focus should wrap around when it reaches the end of the scope. */ + wrap?: boolean; + + /** A callback that determines whether the given element is focused. */ + accept?: (node: Element) => boolean; + + /** The orientation of the focus group. @default "vertical" */ + orientation?: MaybeAccessor; + + /** The text direction of the focus group. @default "ltr" */ + textDirection?: MaybeAccessor; + + /** Whether tab key presses should be handled. @default true */ + handleTab?: MaybeAccessor; + + /** + * Whether keyboard navigation (arrow keys, Home/End, Tab) should be enabled. + * The `keydown` listener is attached to the focus group ref automatically. @default true + */ + keyboardNavigation?: MaybeAccessor; +} + +/** + * Creates a FocusGroup object that can be used to move focus within an element. + * + * By default keyboard navigation is enabled: a `keydown` listener is attached to the + * focus group ref automatically (and removed when the ref changes or the group is disposed). + * + * @example + * ```tsx + * const [ref, setRef] = createSignal(); + * const group = createFocusGroup(ref); + * + * group.focusFirst(); + * group.focusNext(); + * group.focusPrevious(); + * group.focusLast(); + * + *
+ * + * + * + *
+ * ``` + */ +export const createFocusGroup = ( + ref: Accessor, + defaultOptions: Accessor = () => ({}), +): FocusGroup => { + const focusNext = (opts: FocusGroupOptions = {}): HTMLElement | undefined => { + const root = ref(); + + if (!root) { + return; + } + + const { + from = defaultOptions().from || document.activeElement, + tabbable = defaultOptions().tabbable, + wrap = defaultOptions().wrap, + accept = defaultOptions().accept, + } = opts; + + const walker = getFocusableTreeWalker(root, { tabbable, accept }); + + if (from && root.contains(from)) { + walker.currentNode = from; + } + + let nextNode = (walker.nextNode() as HTMLElement | null) ?? undefined; + + if (!nextNode && wrap) { + walker.currentNode = root; + nextNode = (walker.nextNode() as HTMLElement | null) ?? undefined; + } + + if (nextNode) { + focusElement(nextNode, true); + } + + return nextNode; + }; + + const focusPrevious = (opts: FocusGroupOptions = {}): HTMLElement | undefined => { + const root = ref(); + + if (!root) { + return; + } + + const { + from = defaultOptions().from || document.activeElement, + tabbable = defaultOptions().tabbable, + wrap = defaultOptions().wrap, + accept = defaultOptions().accept, + } = opts; + + const walker = getFocusableTreeWalker(root, { tabbable, accept }); + + if (from && root.contains(from)) { + walker.currentNode = from; + } else { + const next = last(walker); + if (next) { + focusElement(next, true); + } + return next; + } + + let previousNode = (walker.previousNode() as HTMLElement | null) ?? undefined; + + if (!previousNode && wrap) { + walker.currentNode = root; + previousNode = last(walker); + } + + if (previousNode) { + focusElement(previousNode, true); + } + + return previousNode; + }; + + const focusFirst = (opts: FocusGroupOptions = {}): HTMLElement | undefined => { + const root = ref(); + + if (!root) { + return; + } + + const { tabbable = defaultOptions().tabbable, accept = defaultOptions().accept } = opts; + + const walker = getFocusableTreeWalker(root, { tabbable, accept }); + const nextNode = walker.nextNode() as HTMLElement | undefined; + + if (nextNode) { + focusElement(nextNode, true); + } + + return nextNode; + }; + + const focusLast = (opts: FocusGroupOptions = {}): HTMLElement | undefined => { + const root = ref(); + + if (!root) { + return; + } + + const { tabbable = defaultOptions().tabbable, accept = defaultOptions().accept } = opts; + + const walker = getFocusableTreeWalker(root, { tabbable, accept }); + const next = last(walker); + + if (next) { + focusElement(next, true); + } + + return next; + }; + + const keyboardOptions = () => { + const opts = defaultOptions(); + return { + orientation: access(opts.orientation) ?? ("vertical" as const), + textDirection: access(opts.textDirection) ?? ("ltr" as const), + handleTab: access(opts.handleTab) ?? true, + }; + }; + + const getNextKey = (): string => { + const { orientation, textDirection } = keyboardOptions(); + return orientation === "vertical" + ? "arrowdown" + : textDirection === "ltr" + ? "arrowright" + : "arrowleft"; + }; + + const getPreviousKey = (): string => { + const { orientation, textDirection } = keyboardOptions(); + return orientation === "vertical" + ? "arrowup" + : textDirection === "ltr" + ? "arrowleft" + : "arrowright"; + }; + + const isFocusInsideGroup = (): boolean => { + const root = ref(); + return root != null && root.contains(document.activeElement); + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + const eventKey = event.key.toLowerCase(); + const from = event.target instanceof Element ? event.target : undefined; + + if (eventKey === getNextKey()) { + event.preventDefault(); + focusNext({ from }); + } else if (eventKey === getPreviousKey()) { + event.preventDefault(); + focusPrevious({ from }); + } else if (eventKey === "home") { + event.preventDefault(); + focusFirst(); + } else if (eventKey === "end") { + event.preventDefault(); + focusLast(); + } else if (eventKey === "tab" && keyboardOptions().handleTab && isFocusInsideGroup()) { + if (event.shiftKey) { + if (focusPrevious({ from, wrap: false })) event.preventDefault(); + } else if (focusNext({ from, wrap: false })) { + event.preventDefault(); + } + } + }; + + createEffect( + () => ({ + root: ref(), + keyboardNavigation: access(defaultOptions().keyboardNavigation) ?? true, + }), + ({ root, keyboardNavigation }) => { + if (!root || !keyboardNavigation) { + return; + } + root.addEventListener("keydown", handleKeyDown); + return () => root.removeEventListener("keydown", handleKeyDown); + }, + ); + + return { focusNext, focusPrevious, focusFirst, focusLast }; +}; + +function focusElement(element: HTMLElement | null, scroll = false): void { + if (element != null) { + try { + element.focus({ preventScroll: !scroll }); + } catch (_err) { + // ignore + } + } +} + +function last(walker: TreeWalker): HTMLElement | undefined { + let next: HTMLElement | undefined; + let last: HTMLElement | undefined; + + do { + last = walker.lastChild() as HTMLElement; + if (last) { + next = last; + } + } while (last); + + return next; +} + +function isElementInScope(element: Element | null, scope: HTMLElement[]): boolean { + return scope.some(node => node.contains(element)); +} + +/** + * Create a [TreeWalker]{@link https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker} + * that matches all focusable/tabbable elements. + */ +export function getFocusableTreeWalker( + root: HTMLElement, + opts?: FocusGroupOptions, + scope?: HTMLElement[], +): TreeWalker { + const selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, { + acceptNode(node) { + // Skip nodes inside the starting node. + if (opts?.from?.contains(node)) { + return NodeFilter.FILTER_REJECT; + } + + if ( + opts?.tabbable && + (node as Element).tagName === "INPUT" && + (node as HTMLInputElement).getAttribute("type") === "radio" + ) { + // If the radio is in a form, we can get all the other radios by name. + if (!isTabbableRadio(node as HTMLInputElement)) { + return NodeFilter.FILTER_REJECT; + } + + // If the radio is in the same group as the current node and none are selected, we can skip it. + if ( + (walker.currentNode as Element).tagName === "INPUT" && + (walker.currentNode as HTMLInputElement).type === "radio" && + (walker.currentNode as HTMLInputElement).name === (node as HTMLInputElement).name + ) { + return NodeFilter.FILTER_REJECT; + } + } + + if ( + (node as HTMLElement).matches(selector) && + isElementVisible(node as HTMLElement) && + (!scope || isElementInScope(node as HTMLElement, scope)) && + (!opts?.accept || opts.accept(node as Element)) + ) { + return NodeFilter.FILTER_ACCEPT; + } + + return NodeFilter.FILTER_SKIP; + }, + }); + + if (opts?.from) { + walker.currentNode = opts.from; + } + + return walker; +} + +function getRadiosInGroup(element: HTMLInputElement): HTMLInputElement[] { + if (!element.form) { + // Radio buttons outside a form - query the document. + return Array.from( + element.ownerDocument.querySelectorAll( + `input[type="radio"][name="${CSS.escape(element.name)}"]`, + ), + ).filter(radio => !radio.form); + } + + // namedItem returns RadioNodeList (iterable) for 2+ elements, but a single Element for exactly 1. + const radioList = element.form.elements.namedItem(element.name); + const ownerWindow = element.ownerDocument.defaultView || window; + if (radioList instanceof ownerWindow.RadioNodeList) { + return Array.from(radioList).filter( + (el): el is HTMLInputElement => el instanceof ownerWindow.HTMLInputElement, + ); + } + if (radioList instanceof ownerWindow.HTMLInputElement) { + return [radioList]; + } + return []; +} + +function isTabbableRadio(element: HTMLInputElement): boolean { + if (element.checked) { + return true; + } + const radios = getRadiosInGroup(element); + return radios.length > 0 && !radios.some(radio => radio.checked); +} diff --git a/packages/focus/src/index.ts b/packages/focus/src/index.ts index a9f0aa245..ce85b1b65 100644 --- a/packages/focus/src/index.ts +++ b/packages/focus/src/index.ts @@ -5,3 +5,5 @@ export type { CreateFocusTrapProps } from "./focusTrap.ts"; export { createFocusRestore } from "./focusRestore.ts"; export type { CreateFocusRestoreProps } from "./focusRestore.ts"; export { makeFocusListener, createFocusSignal } from "./focusSignal.ts"; +export { createFocusGroup, getFocusableTreeWalker } from "./focusGroup.ts"; +export type { FocusGroup, FocusGroupOptions, Orientation, TextDirection } from "./focusGroup.ts"; diff --git a/packages/focus/src/tabbable.ts b/packages/focus/src/tabbable.ts new file mode 100644 index 000000000..83cc50d57 --- /dev/null +++ b/packages/focus/src/tabbable.ts @@ -0,0 +1,91 @@ +/* + * Ported from kobalte's tabbable utilities. + * Portions of this file are based on code from ariakit. + * MIT Licensed, Copyright (c) Diego Haz. + * + * Credits to the Ariakit team: + * https://github.com/ariakit/ariakit/blob/main/packages/ariakit-utils/src/focus.ts + * + * Portions of this file are based on code from react-spectrum. + * Apache License Version 2.0, Copyright 2020 Adobe. + * + * Credits to the React Spectrum team: + * https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/focus/src/isElementVisible.ts + * https://github.com/adobe/react-spectrum/blob/8f2f2acb3d5850382ebe631f055f88c704aa7d17/packages/@react-aria/focus/src/FocusScope.tsx + */ + +const focusableElements = [ + "input:not([type='hidden']):not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "button:not([disabled])", + "a[href]", + "area[href]", + "[tabindex]", + "iframe", + "object", + "embed", + "audio[controls]", + "video[controls]", + "[contenteditable]:not([contenteditable='false'])", +]; + +const tabbableElements = [...focusableElements, '[tabindex]:not([tabindex="-1"]):not([disabled])']; + +export const FOCUSABLE_ELEMENT_SELECTOR: string = `${focusableElements.join( + ":not([hidden]),", +)},[tabindex]:not([disabled]):not([hidden])`; + +export const TABBABLE_ELEMENT_SELECTOR: string = tabbableElements.join( + ':not([hidden]):not([tabindex="-1"]),', +); + +/** + * Adapted from https://github.com/testing-library/jest-dom and + * https://github.com/vuejs/vue-test-utils-next/. + * Licensed under the MIT License. + * @param element - Element to evaluate for display or visibility. + */ +export function isElementVisible(element: Element, childElement?: Element): boolean { + return ( + element.nodeName !== "#comment" && + isStyleVisible(element) && + isAttributeVisible(element, childElement) && + (!element.parentElement || isElementVisible(element.parentElement, element)) + ); +} + +function isStyleVisible(element: Element) { + if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) { + return false; + } + + const { display, visibility } = element.style; + + let isVisible = display !== "none" && visibility !== "hidden" && visibility !== "collapse"; + + if (isVisible) { + if (!element.ownerDocument.defaultView) { + return isVisible; + } + + const { getComputedStyle } = element.ownerDocument.defaultView; + const { display: computedDisplay, visibility: computedVisibility } = getComputedStyle(element); + + isVisible = + computedDisplay !== "none" && + computedVisibility !== "hidden" && + computedVisibility !== "collapse"; + } + + return isVisible; +} + +function isAttributeVisible(element: Element, childElement?: Element) { + return ( + !element.hasAttribute("hidden") && + (element.nodeName === "DETAILS" && childElement && childElement.nodeName !== "SUMMARY" + ? element.hasAttribute("open") + : true) + ); +} diff --git a/packages/focus/stories/focus.stories.tsx b/packages/focus/stories/focus.stories.tsx index 0e4c4afe2..deb4c2811 100644 --- a/packages/focus/stories/focus.stories.tsx +++ b/packages/focus/stories/focus.stories.tsx @@ -6,6 +6,7 @@ import { createFocusSignal, makeFocusListener, createFocusTrap, + createFocusGroup, } from "@solid-primitives/focus"; import readme from "../README.md?raw"; import { @@ -308,3 +309,68 @@ export const CustomInitialFocus = meta.story({ ); }, }); + +export const ArrowKeyNavigation = meta.story({ + name: "Arrow-key focus navigation", + parameters: { + docs: { + description: { + story: + "`createFocusGroup(ref)` creates a focus group that moves focus between the focusable children of the container — the building block for arrow-key navigation in menus, listboxes and toolbars. Keyboard navigation is enabled by default: the `keydown` listener is attached to the ref automatically, so no manual wiring is needed. Here ArrowDown/ArrowUp cycle with wrap, Home/End jump to the ends.", + }, + }, + }, + render: () => { + const [ref, setRef] = createSignal(); + const [focusedLabel, setFocusedLabel] = createSignal("One"); + + createFocusGroup(ref, () => ({ wrap: true })); + + return ( + +
+ setFocusedLabel((event.target as HTMLElement).getAttribute("aria-label") ?? "Unknown") + } + role="listbox" + aria-label="Focus group" + style={{ + display: "flex", + "flex-direction": "column", + gap: "0.5rem", + background: "white", + border: `1px solid ${colors.border}`, + "border-radius": radii.lg, + padding: "1rem", + }} + > + + + +
+ +

+ Focus an item, then use ArrowDown / ArrowUp to cycle with wrap,{" "} + Home / End to jump. +

+
+ ); + }, +}); + +function MenuItem(props: { label: string }) { + return ( + + ); +} diff --git a/packages/focus/test/index.test.tsx b/packages/focus/test/index.test.tsx index 5a1f7b26c..a472dba66 100644 --- a/packages/focus/test/index.test.tsx +++ b/packages/focus/test/index.test.tsx @@ -1,6 +1,12 @@ import { describe, test, expect, vi, beforeEach, afterAll, beforeAll } from "vitest"; import { createRoot, createSignal, flush } from "solid-js"; -import { autofocus, createAutofocus, createFocusTrap, createFocusRestore } from "../src/index.js"; +import { + autofocus, + createAutofocus, + createFocusTrap, + createFocusRestore, + createFocusGroup, +} from "../src/index.js"; let focused: HTMLElement | null = null; @@ -623,3 +629,375 @@ describe("createFocusRestore", () => { expect(focused).toBe(null); }); }); + +describe("createFocusGroup", () => { + test("focusFirst focuses and returns the first focusable element", () => { + const { container, buttons } = makeContainer(3); + const group = createFocusGroup(() => container); + expect(group.focusFirst()).toBe(buttons[0]); + expect(focused).toBe(buttons[0]); + }); + + test("focusLast focuses and returns the last focusable element", () => { + const { container, buttons } = makeContainer(3); + const group = createFocusGroup(() => container); + expect(group.focusLast()).toBe(buttons[2]); + expect(focused).toBe(buttons[2]); + }); + + test("focusNext moves to the next element from `from`", () => { + const { container, buttons } = makeContainer(3); + const group = createFocusGroup(() => container); + expect(group.focusNext({ from: buttons[0] })).toBe(buttons[1]); + expect(focused).toBe(buttons[1]); + }); + + test("focusNext defaults to the currently focused element", () => { + const { container, buttons } = makeContainer(3); + const origActiveElement = Object.getOwnPropertyDescriptor(Document.prototype, "activeElement")!; + Object.defineProperty(document, "activeElement", { get: () => buttons[0], configurable: true }); + + const group = createFocusGroup(() => container); + expect(group.focusNext()).toBe(buttons[1]); + + Object.defineProperty(document, "activeElement", origActiveElement); + }); + + test("focusPrevious moves to the previous element from `from`", () => { + const { container, buttons } = makeContainer(3); + const group = createFocusGroup(() => container); + expect(group.focusPrevious({ from: buttons[2] })).toBe(buttons[1]); + expect(focused).toBe(buttons[1]); + }); + + test("focusNext wraps from the last element when wrap is true", () => { + const { container, buttons } = makeContainer(3); + const group = createFocusGroup(() => container); + expect(group.focusNext({ from: buttons[2], wrap: true })).toBe(buttons[0]); + expect(focused).toBe(buttons[0]); + }); + + test("focusPrevious wraps from the first element when wrap is true", () => { + const { container, buttons } = makeContainer(3); + const group = createFocusGroup(() => container); + expect(group.focusPrevious({ from: buttons[0], wrap: true })).toBe(buttons[2]); + expect(focused).toBe(buttons[2]); + }); + + test("does not wrap when wrap is false", () => { + const { container, buttons } = makeContainer(3); + const group = createFocusGroup(() => container); + expect(group.focusNext({ from: buttons[2], wrap: false })).toBe(undefined); + expect(focused).toBe(null); + }); + + test("respects defaultOptions", () => { + const { container, buttons } = makeContainer(3); + const group = createFocusGroup( + () => container, + () => ({ wrap: true }), + ); + expect(group.focusNext({ from: buttons[2] })).toBe(buttons[0]); + }); + + test("tabbable option only includes tabbable elements", () => { + const container = document.createElement("div"); + const a = document.createElement("button"); + const b = document.createElement("button"); + b.tabIndex = -1; // focusable but not tabbable + container.append(a, b); + + const group = createFocusGroup(() => container); + expect(group.focusNext({ from: a, tabbable: true })).toBe(undefined); // b excluded + expect(group.focusNext({ from: a, tabbable: false })).toBe(b); // all focusable + }); + + test("accept option filters elements", () => { + const container = document.createElement("div"); + const a = document.createElement("button"); + a.id = "keep"; + const b = document.createElement("button"); + b.id = "skip"; + container.append(a, b); + + const group = createFocusGroup(() => container); + expect(group.focusNext({ from: a, accept: el => el.id !== "skip" })).toBe(undefined); + expect(group.focusFirst({ accept: el => el.id !== "skip" })).toBe(a); + }); + + test("returns undefined when root is not set", () => { + const group = createFocusGroup(() => undefined); + expect(group.focusFirst()).toBe(undefined); + expect(group.focusNext()).toBe(undefined); + expect(group.focusPrevious()).toBe(undefined); + expect(group.focusLast()).toBe(undefined); + }); +}); + +describe("createFocusGroup keyboard navigation", () => { + const key = (key: string, opts: KeyboardEventInit = {}) => + new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...opts }); + + /** Flush pending effects so `createFocusGroup` has attached its keydown listener, then dispatch `event` on `target`. */ + const press = (container: HTMLElement, target: Element, event: KeyboardEvent) => { + flush(); + target.dispatchEvent(event); + return event; + }; + + test("ArrowDown moves focus to the next element (vertical by default)", () => { + createRoot(dispose => { + const { container, buttons } = makeContainer(3); + createFocusGroup(() => container); + press(container, buttons[0]!, key("ArrowDown")); + expect(focused).toBe(buttons[1]); + dispose(); + }); + }); + + test("ArrowUp moves focus to the previous element", () => { + createRoot(dispose => { + const { container, buttons } = makeContainer(3); + createFocusGroup(() => container); + press(container, buttons[2]!, key("ArrowUp")); + expect(focused).toBe(buttons[1]); + dispose(); + }); + }); + + test("ArrowDown does not wrap by default", () => { + createRoot(dispose => { + const { container, buttons } = makeContainer(3); + createFocusGroup(() => container); + press(container, buttons[2]!, key("ArrowDown")); + expect(focused).toBe(null); + dispose(); + }); + }); + + test("ArrowDown wraps when wrap is true", () => { + createRoot(dispose => { + const { container, buttons } = makeContainer(3); + createFocusGroup( + () => container, + () => ({ wrap: true }), + ); + press(container, buttons[2]!, key("ArrowDown")); + expect(focused).toBe(buttons[0]); + dispose(); + }); + }); + + test("ArrowUp wraps when wrap is true", () => { + createRoot(dispose => { + const { container, buttons } = makeContainer(3); + createFocusGroup( + () => container, + () => ({ wrap: true }), + ); + press(container, buttons[0]!, key("ArrowUp")); + expect(focused).toBe(buttons[2]); + dispose(); + }); + }); + + test("horizontal orientation uses left/right arrows", () => { + createRoot(dispose => { + const { container, buttons } = makeContainer(3); + createFocusGroup( + () => container, + () => ({ orientation: "horizontal" }), + ); + press(container, buttons[0]!, key("ArrowRight")); + expect(focused).toBe(buttons[1]); + press(container, buttons[1]!, key("ArrowLeft")); + expect(focused).toBe(buttons[0]); + dispose(); + }); + }); + + test("horizontal RTL flips the arrow keys", () => { + createRoot(dispose => { + const { container, buttons } = makeContainer(3); + createFocusGroup( + () => container, + () => ({ orientation: "horizontal", textDirection: "rtl" }), + ); + press(container, buttons[0]!, key("ArrowLeft")); + expect(focused).toBe(buttons[1]); + press(container, buttons[1]!, key("ArrowRight")); + expect(focused).toBe(buttons[0]); + dispose(); + }); + }); + + test("Home and End move to the first and last element", () => { + createRoot(dispose => { + const { container, buttons } = makeContainer(3); + createFocusGroup(() => container); + press(container, buttons[0]!, key("Home")); + expect(focused).toBe(buttons[0]); + press(container, buttons[1]!, key("End")); + expect(focused).toBe(buttons[2]); + dispose(); + }); + }); + + test("handles accessor options", () => { + createRoot(dispose => { + const { container, buttons } = makeContainer(3); + createFocusGroup( + () => container, + () => ({ orientation: () => "horizontal" }), + ); + press(container, buttons[0]!, key("ArrowRight")); + expect(focused).toBe(buttons[1]); + dispose(); + }); + }); + + test("Tab moves to the next element when focus is inside the group", () => { + createRoot(dispose => { + const { container, buttons } = makeContainer(3); + const origActiveElement = Object.getOwnPropertyDescriptor( + Document.prototype, + "activeElement", + )!; + Object.defineProperty(document, "activeElement", { + get: () => buttons[0], + configurable: true, + }); + + createFocusGroup(() => container); + const event = key("Tab"); + const prevent = vi.spyOn(event, "preventDefault"); + press(container, buttons[0]!, event); + + expect(focused).toBe(buttons[1]); + expect(prevent).toHaveBeenCalled(); + Object.defineProperty(document, "activeElement", origActiveElement); + dispose(); + }); + }); + + test("Shift+Tab moves to the previous element", () => { + createRoot(dispose => { + const { container, buttons } = makeContainer(3); + const origActiveElement = Object.getOwnPropertyDescriptor( + Document.prototype, + "activeElement", + )!; + Object.defineProperty(document, "activeElement", { + get: () => buttons[2], + configurable: true, + }); + + createFocusGroup(() => container); + press(container, buttons[2]!, key("Tab", { shiftKey: true })); + expect(focused).toBe(buttons[1]); + Object.defineProperty(document, "activeElement", origActiveElement); + dispose(); + }); + }); + + test("Tab does not move focus when focus is outside the group", () => { + createRoot(dispose => { + const { container, buttons } = makeContainer(3); + createFocusGroup(() => container); + press(container, buttons[0]!, key("Tab")); + expect(focused).toBe(null); + dispose(); + }); + }); + + test("arrow keys call preventDefault", () => { + createRoot(dispose => { + const { container, buttons } = makeContainer(3); + createFocusGroup(() => container); + const event = key("ArrowDown"); + const prevent = vi.spyOn(event, "preventDefault"); + press(container, buttons[0]!, event); + expect(prevent).toHaveBeenCalled(); + dispose(); + }); + }); + + test("does not attach a listener when root is not set", () => { + createRoot(dispose => { + createFocusGroup(() => undefined); + const event = key("ArrowDown"); + const prevent = vi.spyOn(event, "preventDefault"); + flush(); + document.body.dispatchEvent(event); + expect(prevent).not.toHaveBeenCalled(); + expect(focused).toBe(null); + dispose(); + }); + }); + + test("keyboardNavigation: false disables key handling", () => { + createRoot(dispose => { + const { container, buttons } = makeContainer(3); + createFocusGroup( + () => container, + () => ({ keyboardNavigation: false }), + ); + const event = key("ArrowDown"); + const prevent = vi.spyOn(event, "preventDefault"); + press(container, buttons[0]!, event); + expect(focused).toBe(null); + expect(prevent).not.toHaveBeenCalled(); + dispose(); + }); + }); + + test("keyboardNavigation can be toggled reactively", () => { + const { container, buttons } = makeContainer(3); + const [enabled, setEnabled] = createSignal(true); + let dispose!: () => void; + createRoot(d => { + dispose = d; + createFocusGroup( + () => container, + () => ({ keyboardNavigation: enabled() }), + ); + press(container, buttons[0]!, key("ArrowDown")); + expect(focused).toBe(buttons[1]); + }); + + setEnabled(false); + focused = null; + press(container, buttons[0]!, key("ArrowDown")); + expect(focused).toBe(null); + + setEnabled(true); + press(container, buttons[0]!, key("ArrowDown")); + expect(focused).toBe(buttons[1]); + dispose(); + }); + + test("keydown listener follows the ref and is removed from the previous ref", () => { + const { container, buttons } = makeContainer(3); + const otherContainer = document.createElement("div"); + const otherButtons = [document.createElement("button"), document.createElement("button")]; + otherButtons.forEach(btn => otherContainer.appendChild(btn)); + + const [ref, setRef] = createSignal(container); + let dispose!: () => void; + createRoot(d => { + dispose = d; + createFocusGroup(ref); + press(container, buttons[0]!, key("ArrowDown")); + expect(focused).toBe(buttons[1]); + }); + + setRef(otherContainer); + focused = null; + press(container, buttons[0]!, key("ArrowDown")); + expect(focused).toBe(null); // listener removed from the old container + + press(otherContainer, otherButtons[0]!, key("ArrowDown")); + expect(focused).toBe(otherButtons[1]); // listener attached to the new ref + dispose(); + }); +}); diff --git a/packages/focus/test/server.test.ts b/packages/focus/test/server.test.ts index 9b4ae4800..3a7cf61f3 100644 --- a/packages/focus/test/server.test.ts +++ b/packages/focus/test/server.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { createRoot } from "solid-js"; -import { createAutofocus, createFocusTrap } from "../src/index.js"; +import { createAutofocus, createFocusTrap, createFocusGroup } from "../src/index.js"; describe("API doesn't break in SSR", () => { it("createAutofocus() - SSR", () => { @@ -16,4 +16,11 @@ describe("API doesn't break in SSR", () => { dispose(); }); }); + + it("createFocusGroup() - SSR", () => { + createRoot(dispose => { + expect(() => createFocusGroup(() => undefined)).not.toThrow(); + dispose(); + }); + }); });