import { ReactiveController, ReactiveElement } from 'lit'; /** * Spatial mode for arrow-key movement. Aligns with logical axes (inline/block) and a * 2D layout mode derived from element geometry. * * - **horizontal**: Arrow keys on the inline axis move focus (respects `dir`). * - **vertical**: Arrow keys on the block axis move focus. * - **both**: **ArrowLeft** / **ArrowRight** move along `getItems()` order like **horizontal** * (respects `dir`); **ArrowUp** / **ArrowDown** move backward / forward in the same order. * - **grid**: Arrow keys move in rows and columns using bounding-rect layout; Ctrl+Home / Ctrl+End * jump to the first cell of the first row or the last cell of the last row. */ export type FocusgroupDirection = 'horizontal' | 'vertical' | 'both' | 'grid'; /** * Options for {@link FocusgroupNavigationController}. */ export type FocusgroupNavigationOptions = { /** * Returns the current set of items that participate in roving tabindex and * directional navigation. Callers typically close over the host (for example * querying slotted or shadow DOM children). */ getItems: () => HTMLElement[]; /** * Determines which arrow keys move focus and how grid navigation is computed. * Use **`both`** when the same linear order should respond to horizontal and vertical arrow keys. */ direction: FocusgroupDirection; /** * When true, arrow keys wrap from the last item to the first (and reverse). * Defaults to false. */ wrap?: boolean; /** * When true, restoring focus into the composite (for example with Tab) targets * the item that was last focused, if it is still a member of the group. * Similar to the default memory behavior described for `focusgroup` in Open UI. * Defaults to true. */ memory?: boolean; /** * When true, both natively `disabled` and `aria-disabled="true"` items are * skipped for arrow navigation and are not chosen as the roving tab stop. * When false (default), disabled items remain in sequence — useful for * patterns such as menus where disabled items may still be focusable per * APG guidance. * * **Note:** Regardless of this flag, natively `disabled` elements are never * chosen as the roving tab stop (`tabindex="0"`) because they cannot receive * browser focus; see {@link applyRovingTabindex}. A future revision may * decouple native `disabled` and `aria-disabled` into separate options if * component migrations surface the need. * * Defaults to false. */ skipDisabled?: boolean; /** * Invoked after the active item changes and `tabindex` values are synchronized. * The argument is the new active element, or null when the group has no eligible items. */ onActiveItemChange?: (active: HTMLElement | null) => void; /** * When set to a **non-zero** integer, **Page Up** / **Page Down** move focus by that many * positions in `getItems()` order for **`horizontal`**, **`vertical`**, and **`both`** modes * (respects **`wrap`** the same way as single-step arrows). * For **`grid`**, page keys move by that many **rows** (column index is clamped to each row’s * length). Omitted, `0`, `NaN`, and non-finite values disable page keys. The sign of the * number is ignored; only the magnitude is used. */ pageStep?: number; }; /** * Name of the `CustomEvent` dispatched on the host when the roving tabindex active item changes. * * The event `bubbles` and is `composed`. Handlers read * {@link FocusgroupNavigationActiveChangeDetail} from `event.detail`. */ export declare const focusgroupNavigationActiveChange = "swc-focusgroup-navigation-active-change"; /** * `detail` object for the {@link focusgroupNavigationActiveChange} event. */ export type FocusgroupNavigationActiveChangeDetail = { /** * Element that now has `tabindex="0"` among managed items, or null when the group is empty. */ activeElement: HTMLElement | null; }; /** * **FocusgroupNavigation** — implements the roving `tabindex` pattern from the APG * keyboard guide and directional navigation similar to the proposed `focusgroup` * attribute (Open UI). The exported class name is `FocusgroupNavigationController`. * * The controller: * - Keeps exactly one item in the tab order (`tabindex="0"`) per composite; sets * `tabindex="-1"` on other items it manages. * - Handles Arrow keys, Home, and End for focus movement (and optionally wrap). **`both`** * direction accepts horizontal and vertical arrows on the same `getItems()` sequence. * In **`grid`** mode only, **Ctrl+Home** / **Ctrl+End** move to the first cell of the first * row or the last cell of the last row (by layout-derived rows). * - Optional **`pageStep`**: **Page Up** / **Page Down** move by that many items (linear modes) * or rows (**`grid`**). * - Optional **`skipDisabled`**: omit **`disabled`** and **`aria-disabled="true"`** items from * roving tabindex and arrow navigation. * - Supports optional last-focused memory when re-entering via Tab. * - Exposes {@link FocusgroupNavigationController.setActiveItem} to choose the roving tab stop * without calling `focus()`, and {@link FocusgroupNavigationController.focusFirstItemByTextPrefix} * for typeahead-style roving `tabindex` (call {@link FocusgroupNavigationController.getActiveItem} * and `focus()` yourself when you want keyboard focus to move). Arrow-key handling calls * `setActiveItem` and `focus()` together. * * Dispatches a bubbling, composed `CustomEvent` named * {@link focusgroupNavigationActiveChange} when the active item changes. * * This is not a browser `focusgroup` implementation; it is a Lit reactive controller * for custom elements until native `focusgroup` is available. * * @example * ```typescript * class MyToolbar extends LitElement { * private readonly navigation = new FocusgroupNavigationController(this, { * direction: 'horizontal', * wrap: true, * getItems: () => * Array.from(this.renderRoot.querySelectorAll('button')), * }); * * protected override firstUpdated(): void { * super.firstUpdated(); * this.navigation.refresh(); * } * } * ``` * * @see https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#keyboardnavigationinsidecomponents * @see https://open-ui.org/components/scoped-focusgroup.explainer/ * * **Native `focusgroup` (future):** The comment block immediately below this class lists which * parts of this file are the most likely candidates for deprecation or deletion once browsers * ship built-in focus-group behavior that covers the same cases (especially roving tabindex and * arrow-key focus moves). Some options (for example rect-based **grid**, **pageStep**, or * **skipDisabled**) may remain useful longer if the platform surface stays narrower. */ export declare class FocusgroupNavigationController implements ReactiveController { /** * Lit reactive host this controller is attached to. */ private host; /** * Effective options (defaults merged with the latest `setOptions` / constructor values). */ private options; /** * Capture-phase `keydown` listener reference for removal on disconnect. */ private readonly boundKeydown; /** * Capture-phase `focusin` listener reference for removal on disconnect. */ private readonly boundFocusin; /** * Capture-phase `focusout` listener reference for removal on disconnect. */ private readonly boundFocusout; /** * Cached item for {@link FocusgroupNavigationOptions.memory} when the user moves focus * inside or out of the composite. Cleared when that node is no longer returned by * `getItems` or when the group becomes empty. */ private lastFocused; /** * Tracks the previously dispatched active item so that * {@link applyRovingTabindex} only fires the active-change event and * {@link FocusgroupNavigationOptions.onActiveItemChange} callback when the * active item actually changes. */ private previousActive; /** * Guard flag set during keyboard navigation so that the `focusin` triggered * by `item.focus()` does not redundantly call {@link applyRovingTabindex}. */ private isNavigating; /** * Cached result of {@link getEligibleItems}, populated on first access within * a refresh cycle and cleared at the start of each entry point * ({@link refresh}, {@link handleFocusin}, {@link handleKeydown}). */ private cachedEligibleItems; /** * Cached result of {@link buildRows}, populated on first access within a * keydown cycle and cleared alongside {@link cachedEligibleItems}. */ private cachedRows; /** * Registers this instance on `host` via `addController` and merges `options` with defaults. * * @param host - Reactive element that owns the composite (arrow keys and tab order apply within its subtree). * @param options - `getItems`, `direction`, and optional behavior flags. */ constructor(host: ReactiveElement, options: FocusgroupNavigationOptions); /** * Merges `partial` into the current options and reapplies roving `tabindex` to the item set. * * @param partial - Fields to override; omitted keys keep their previous values. */ setOptions(partial: Partial): void; /** * Returns the eligible managed item that currently participates in the sequential focus order * (`tabindex="0"`), or null if no eligible item has tab index zero. * * @returns The active roving item, or null. */ getActiveItem(): HTMLElement | null; /** * Re-queries `getItems()`, recomputes eligibility, and syncs roving `tabindex`. * * Call after the item list or item eligibility changes (for example after Lit * `updated()` or slot changes). When {@link FocusgroupNavigationOptions.memory} is true, * prefers the stored last-focused item if it is still eligible; otherwise keeps the * current active item or falls back to the first eligible item. */ refresh(): void; /** * Sets roving `tabindex` so `item` is the active tab stop (`tabindex="0"`) and others in the * group are `-1`. Does **not** call `focus()`. When {@link FocusgroupNavigationOptions.memory} * is true, updates the stored last-focused item so Tab re-entry can target this item. * * @param item - Item to mark active; must be returned by `getItems` and pass eligibility checks. * @returns False if `item` is not in the current eligible item list. */ setActiveItem(item: HTMLElement): boolean; /** * Updates roving `tabindex` so the first **eligible** item (same set as arrow navigation) * whose typeahead label starts with `prefix` becomes the active tab stop (`tabindex="0"`). * Matching is **case-insensitive**. The label is the first non-empty of: trimmed * **`aria-label`**, trimmed text from **`aria-labelledby`** references (in order, space-joined), * or trimmed **`textContent`**. Search order matches arrow-key traversal. * * Does **not** call `focus()`. After this returns `true`, call `focus()` on * {@link FocusgroupNavigationController.getActiveItem} (for example `getActiveItem()?.focus()`), * often from a **microtask** when the caller runs from a pointer handler so focus is not * overwritten by the clicked control. * * Typical use: menu typeahead; wire `keydown` or `input` at the host and debounce as needed. * * @param prefix - String to match as a leading substring after `trim`; whitespace-only yields * no match and returns `false`. * @returns True if a matching item was found and roving tabindex was applied. */ focusFirstItemByTextPrefix(prefix: string): boolean; /** * Lit `ReactiveController` hook: registers capture-phase listeners on `host` and runs * an initial {@link refresh}. */ hostConnected(): void; /** * Lit `ReactiveController` hook: removes listeners registered in {@link hostConnected}. */ hostDisconnected(): void; /** * Resolves writing direction from the computed style of the host element. * * Uses `getComputedStyle` rather than walking `dir` attributes so that * CSS-inherited direction (the 2nd-gen default) is correctly detected. * * @returns True when horizontal arrow directions should follow RTL semantics. */ private isRtl; /** * Whether `node` is the host or reachable from it by walking `parentNode` and * `ShadowRoot.host` (so shadow descendants count, including nested shadow roots). * * `Element.contains()` is not used because it returns false for nodes inside the * host's shadow tree, which would drop every item for typical Lit components. * * @param node - Node to test (may be null). * @returns True if `node` is in the host's shadow-inclusive subtree. */ private isNodeWithinHostScope; /** * Items returned by `getItems` that lie within `host` (shadow-inclusive tree). * * @returns Candidates before eligibility filtering. */ private getRawItems; /** * {@link getRawItems} filtered by {@link isNavigableItem}. * * @returns Items that participate in roving tabindex and arrow navigation. */ private getEligibleItems; /** * {@link buildRows} with per-cycle caching, cleared alongside * {@link cachedEligibleItems}. * * @param items - Eligible items to lay out as a grid. * @returns Cached row-major array of rows. */ private getRows; /** * Whether `el` may participate in the focus group (connected, visible, not inert, * and not skipped when {@link FocusgroupNavigationOptions.skipDisabled} is true). * * @param el - Candidate from `getItems`. * @returns True if the element counts as navigable for this controller. */ private isNavigableItem; /** * Whether `el` should be treated as disabled for {@link FocusgroupNavigationOptions.skipDisabled}. * * @param el - Element to test. * @returns True if the native `disabled` property is true or `aria-disabled` is `"true"`. */ private isDisabledForSkip; /** * String used for {@link focusFirstItemByTextPrefix}: prefers **`aria-label`**, then text from * **`aria-labelledby`** (IDs resolved in the shadow root or document), else **`textContent`**. * All branches are trimmed; empty strings fall through to the next source. */ private getItemTypeaheadLabel; /** * Whether `el` is natively disabled and therefore unable to receive focus * regardless of its `tabindex` value. */ private isNativelyDisabled; /** * Sets `tabindex="-1"` on ineligible raw items, then assigns `tabindex="0"` to * `active` (or the first eligible item if `active` is not eligible) and `-1` to the rest. * * When `skipDisabled` is false, natively disabled items remain in the eligible list * for arrow navigation but are never chosen as the roving tab stop because they * cannot receive focus. The tab stop falls through to the nearest non-disabled item. * * Dispatches the active-change event and {@link FocusgroupNavigationOptions.onActiveItemChange}. * * @param active - Preferred item to mark as the single tab stop when eligible. */ private applyRovingTabindex; /** * Dispatches {@link focusgroupNavigationActiveChange} on the reactive host with the given detail. * * @param activeElement - New active item, or null when clearing selection. */ private dispatchActiveChange; /** * Resolves the managed item that actually received focus inside the shadow tree. * * Same retargeting problem as {@link resolveManagedKeydownTarget}: listeners on * the shadow host see `event.target` retargeted to the host when focus lands on a * descendant inside the shadow root. Walk `composedPath()` and fall back to * `shadowRoot.activeElement` to find the real focused managed item. * * @param event - Focus event dispatched while focus moves into the composite. * @param items - Current eligible items from {@link getEligibleItems}. * @returns The managed element that received focus, or null. */ private resolveManagedFocusTarget; /** * Capture-phase `focusin` handler: syncs roving `tabindex` when focus moves to a managed item * (for example via pointer), and updates memory when enabled. * * @param event - Focus event whose target may be a group item. */ private handleFocusin; /** * Capture-phase `focusout` handler: when focus leaves the host subtree, stores the * previous target for {@link FocusgroupNavigationOptions.memory}. * * @param event - Focus event; `relatedTarget` stays inside the host when moving between items. */ private handleFocusout; /** * Resolves which managed item should receive arrow, Home, End, or grid Ctrl+Home / Ctrl+End * handling for this key event. * * Listeners on the shadow **host** often see a **retargeted** {@link KeyboardEvent.target} * (the host) while focus is on a descendant inside the shadow tree, so matching * `event.target` against `getItems()` fails. {@link Event.composedPath} still includes the * focused node; we also fall back to {@link ShadowRoot.activeElement} when needed. * * @param event - Keyboard event dispatched while focus is in this composite. * @param items - Current eligible items from {@link getEligibleItems}. * @returns The managed element to treat as keydown target, or null. */ private resolveManagedKeydownTarget; /** * Capture-phase `keydown` handler: arrow keys and Home/End move focus among eligible items * when the event target is managed; calls `preventDefault` when handling navigation. * * When {@link FocusgroupDirection | `direction`} is **`both`**, **ArrowLeft** / **ArrowRight** * and **ArrowUp** / **ArrowDown** all participate (see {@link navigateBothAxes}). * * When {@link FocusgroupDirection | `direction`} is **`grid`**, **Ctrl+Home** focuses the * first cell in the first row and **Ctrl+End** focuses the last cell in the last row (from * {@link buildRows}); other modifier combinations are ignored except plain Home/End. * * When {@link FocusgroupNavigationOptions.pageStep} is a non-zero finite number, **Page Up** * and **Page Down** are handled before arrow keys (see {@link navigatePage}). * * @param event - Keyboard event from the focused element inside the host. */ private handleKeydown; /** * Applies roving tabindex to `item` and moves DOM focus; used for keyboard navigation only. */ private moveKeyNavigationFocusTo; /** * Positive step count for {@link FocusgroupNavigationOptions.pageStep}, or null when page keys * are disabled. */ private getEffectivePageMagnitude; /** * Target for **Page Up** / **Page Down** when {@link getEffectivePageMagnitude} is set. * * @param items - Eligible items. * @param current - Focused item. * @param signedDelta - `+magnitude` for Page Down or `-magnitude` for Page Up (items for * linear modes, rows for `grid`). */ private navigatePage; /** * Page Up/Down along `getItems()` order (used for `horizontal`, `vertical`, and `both`). */ private navigatePageLinearItems; /** * Page Up/Down by whole rows in `grid` mode (column clamped per {@link navigateGrid}). */ private navigatePageGridRows; /** * Computes the next focus target for linear {@link FocusgroupDirection} modes. * * @param items - Eligible items in traversal order. * @param current - Currently focused item. * @param key - `KeyboardEvent.key` value. * @param mode - `horizontal` (inline axis) or `vertical` (block axis). * @param rtl - When true, horizontal Left/Right swap forward/backward. * @returns Next item, or null if the key is not a navigation key or movement is blocked. */ private navigateLinear; /** * Computes the next focus target when {@link FocusgroupDirection | `direction`} is **`both`**: * inline arrows use the same deltas as {@link navigateLinear} `horizontal` mode; **ArrowUp** / * **ArrowDown** step backward / forward in `getItems()` order (not flipped by `dir`). * * @param items - Eligible items in traversal order. * @param current - Currently focused item. * @param key - `KeyboardEvent.key` value. * @param rtl - When true, horizontal Left/Right swap forward/backward. * @returns Next item, or null if the key is not handled or movement is blocked. */ private navigateBothAxes; /** * Computes the next focus target for `grid` {@link FocusgroupDirection} mode using * row clustering and column indices. * * @param current - Currently focused item. * @param key - `KeyboardEvent.key` value. * @param rtl - When true, horizontal Left/Right swap column direction within a row. * @param grid - Pre-built row grid from {@link buildRows}. * @returns Next cell item, or null if the key is not handled or movement is blocked. */ private navigateGrid; /** * Groups `items` into rows by similar `getBoundingClientRect().top`, then sorts each row by `left`. * * @param items - Eligible elements to lay out as a grid. * @returns Row-major array of rows; each row is left-to-right. */ private buildRows; /** * Locates `el` in a row-major grid built by {@link buildRows}. * * @param grid - Rows of elements. * @param el - Element to find. * @returns Row and column indices, or null if absent. */ private findGridIndex; }