diff --git a/.vscode/settings.json b/.vscode/settings.json index a50353789e3..b89efb845eb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -70,6 +70,8 @@ "seafoam", "sidenav", "tabindex", + "tabindexes", + "unmanage", "unsuffixed", "valuenow", "valuetext" diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts new file mode 100644 index 00000000000..e026187bada --- /dev/null +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller.ts @@ -0,0 +1,23 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Package entry for `@spectrum-web-components/core/controllers/focus-group-navigation-controller.js`. + * Implementation lives under `focus-group-navigation-controller/src/` next to demos and tests. + */ +export { + focusgroupNavigationActiveChange, + FocusgroupNavigationController, + type FocusgroupDirection, + type FocusgroupNavigationActiveChangeDetail, + type FocusgroupNavigationOptions, +} from './focus-group-navigation-controller/src/focus-group-navigation-controller.js'; diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller/focus-group-navigation-controller.md b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/focus-group-navigation-controller.md new file mode 100644 index 00000000000..a1eae03b55c --- /dev/null +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/focus-group-navigation-controller.md @@ -0,0 +1,181 @@ +`FocusgroupNavigationController` implements the [roving `tabindex` pattern](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#managingfocuswithincomponentsusingarovingtabindex) from the ARIA Authoring Practices Guide (APG) and directional keyboard behavior aligned with the Open UI [`focusgroup` explainer](https://open-ui.org/components/scoped-focusgroup.explainer/). Use it inside Lit-based custom elements (or any `ReactiveElement`) until native `focusgroup` is widely available. + +## What it does + +- Collapses the tab sequence to **one** tab stop for the composite by setting `tabindex="0"` on the active item and `tabindex="-1"` on the others it manages. +- Moves focus with **Arrow** keys according to `direction`: horizontal (inline axis), vertical (block axis), **both** (horizontal and vertical arrows on the same linear order), or **grid** (rows and columns from layout). +- Supports **Home** / **End** to jump to the first or last item (for `grid`, order is visual row-major). +- In **`grid`** mode only, **Ctrl+Home** moves focus to the **first cell in the first row** and **Ctrl+End** to the **last cell in the last row** (rows are derived from layout; ragged last rows use the final cell in that row). +- Optional **`skipDisabled`**: when `true`, elements with native **`disabled`** or **`aria-disabled="true"`** are excluded from roving `tabindex` and from arrow-key navigation (see story **Skip disabled menu**). +- **`setActiveItem(element)`** updates roving `tabindex` to a chosen eligible item only; it does **not** call `focus()` — call **`getActiveItem()?.focus()`** afterward (story **Programmatic focus** defers `focus()` with **`queueMicrotask`** when invoked from a trigger `click`). +- **`focusFirstItemByTextPrefix(prefix)`** updates roving `tabindex` to the first eligible item whose typeahead label starts with `prefix` (case-insensitive), in `getItems()` order — label uses **`aria-label`**, then **`aria-labelledby`** text, then **`textContent`**. It does **not** call `focus()`; call **`getActiveItem()?.focus()`** yourself (story **Text prefix focus**). +- Optional **`pageStep`**: when set to a non-zero integer, **Page Up** / **Page Down** move that many items in `getItems()` order (linear modes) or that many **rows** in **`grid`** mode. +- Optional **wrap** (end wraps to start) and **memory** (Tab returns to the last focused item), similar to `wrap` and `nomemory` concepts in the `focusgroup` proposal. + +## Import + +```typescript +import { + FocusgroupNavigationController, + focusgroupNavigationActiveChange, +} from '@spectrum-web-components/core/controllers/focus-group-navigation-controller.js'; +``` + +## Basic usage + +1. Construct the controller in your element’s `constructor`, passing `getItems` and `direction`. +2. Ensure `getItems` returns live `HTMLElement` references (for example from `this.renderRoot` or slotted content). +3. After the first render, if items live in shadow DOM, call **`refresh()`** from `firstUpdated` (or after slotting) so roving tabindex can run once nodes exist. +4. Provide appropriate **roles** and **labels** on the host and items (the controller does not set ARIA roles). + +### Example (horizontal toolbar) + +```typescript +import { LitElement, html, css } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { FocusgroupNavigationController } from '@spectrum-web-components/core/controllers/focus-group-navigation-controller.js'; + +@customElement('my-format-toolbar') +export class MyFormatToolbar extends LitElement { + static styles = css` + :host { + display: flex; + gap: 4px; + } + `; + + 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(); + } + + protected override render() { + return html` + + + + `; + } +} +``` + +### Example (horizontal and vertical arrows, same order) + +Use `direction: 'both'` when controls are laid out in a line (or any single sequence) but you want **ArrowUp** / **ArrowDown** to move focus as well as **ArrowLeft** / **ArrowRight**. Inline arrows follow `dir` like `horizontal`; **ArrowUp** / **ArrowDown** step backward / forward in `getItems()` order. + +```typescript +this.navigation = new FocusgroupNavigationController(this, { + direction: 'both', + wrap: true, + getItems: () => + Array.from(this.renderRoot.querySelectorAll('button')), +}); +``` + +The Storybook story **Both axes linear** demonstrates this on a small toolbar. + +### Example (vertical list, skip disabled) + +Items stay in the DOM (for example for layout or screen-reader context), but **`skipDisabled: true`** removes them from the roving tab stop and from arrow movement. Treat both native **`disabled`** and **`aria-disabled="true"`** as skipped. + +```typescript +this.navigation = new FocusgroupNavigationController(this, { + direction: 'vertical', + wrap: true, + skipDisabled: true, + getItems: () => + Array.from(this.renderRoot.querySelectorAll('button')), +}); +``` + +```html + + + + + + + +``` + +The Storybook story **Skip disabled menu** walks **New → Open → Print → Help** with arrow keys only (Save and Close are never focused). + +### Example (Page Up / Page Down) + +```typescript +this.navigation = new FocusgroupNavigationController(this, { + direction: 'vertical', + pageStep: 3, + getItems: () => + Array.from(this.renderRoot.querySelectorAll('button')), +}); +``` + +With `pageStep: 3`, each **Page Down** advances three items in `getItems()` order; **Page Up** goes back three. For **`grid`**, use the same option to move three rows at a time. + +### Example (focus by text prefix / typeahead) + +Call **`focusFirstItemByTextPrefix`** when the user types into a composite (often from a capturing `keydown` or debounced `input`). Matching uses each item’s typeahead label — trimmed **`aria-label`** if set, otherwise text from **`aria-labelledby`** references (in order), otherwise trimmed **`textContent`** — with a **case-insensitive** prefix test, and only **eligible** items (respects **`skipDisabled`**). The first match in `getItems()` order becomes the roving tab stop; **`focus()` is not called** by the controller. + +Move focus yourself on **`getActiveItem()`**. From a **`click`** handler on another control, defer `focus()` with **`queueMicrotask`** (or similar) so the browser does not move focus back to the clicked element after your handler returns. + +```typescript +// Example: after the user types into your menu search buffer `buffer` +if (this.navigation.focusFirstItemByTextPrefix(buffer)) { + queueMicrotask(() => { + this.navigation.getActiveItem()?.focus(); + }); +} +``` + +### Example (grid) + +Use `direction: 'grid'` when items are laid out in rows (for example CSS Grid). The controller groups items into rows using bounding rectangles, then maps Arrow keys to cell movement. **Home** / **End** use visual row-major order (first and last item in that flattened sequence). **Ctrl+Home** / **Ctrl+End** jump to the first cell of the top row or the last cell of the bottom row, which matches rectangular grids and differs from plain **End** only when the last row has fewer cells than earlier rows. + +Set **`pageStep`** to a positive integer (for example `2`) so **Page Up** / **Page Down** move that many rows; the focused column index is clamped when a row has fewer cells (same rule as **ArrowUp** / **ArrowDown**). + +## API + +| Member | Description | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `setOptions(partial)` | Merge new options and reapply roving tabindex. | +| `refresh()` | Re-query items and sync tabindex (call after DOM changes). | +| `setActiveItem(element)` | Set roving `tabindex` to the given eligible item only (does **not** call `focus()`). Returns `false` if the element is not eligible. | +| `focusFirstItemByTextPrefix(prefix)` | Set roving `tabindex` to the first eligible item whose typeahead label (`aria-label`, then `aria-labelledby`, then `textContent`) starts with `prefix` (case-insensitive). Does **not** call `focus()`. Returns `false` if `prefix` is whitespace-only or there is no match. | +| `getActiveItem()` | Returns the eligible item with `tabindex="0"`, if any. | + +### Events + +On the host, the controller dispatches **`swc-focusgroup-navigation-active-change`** (`focusgroupNavigationActiveChange`) with `detail: { activeElement }` when the active item changes. + +### Options + +| Option | Type | Default | Description | +| -------------------- | ------------------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `getItems` | `() => HTMLElement[]` | (required) | Current navigable items. | +| `direction` | `'horizontal' \| 'vertical' \| 'both' \| 'grid'` | (required) | Arrow-key mode. **`both`**: Left/Right and Up/Down on the same `getItems()` sequence. | +| `wrap` | `boolean` | `false` | Wrap at ends. | +| `memory` | `boolean` | `true` | Remember last focused for re-entry via Tab. | +| `skipDisabled` | `boolean` | `false` | Skip `disabled` / `aria-disabled="true"` items. | +| `pageStep` | `number` | — | Non-zero: **Page Up** / **Page Down** move this many items (linear) or rows (**grid**); sign ignored. `0` / omitted / non-finite: disabled. | +| `onActiveItemChange` | `(el) => void` | — | Callback when active item changes. | + +## RTL and writing modes + +For `horizontal`, **ArrowLeft** / **ArrowRight** follow the host’s resolved `dir` (`rtl` swaps forward/back). For **`both`**, **ArrowLeft** / **ArrowRight** follow `dir` the same way, while **ArrowUp** / **ArrowDown** always step backward / forward in `getItems()` order. In **`grid`** mode, vertical movement uses row geometry; column movement respects `dir` for left/right. + +## Relationship to native `focusgroup` + +Native `focusgroup` would supply guaranteed tab stops, memory, and arrow behavior in the browser. This controller provides a **JavaScript** implementation for custom elements: you keep explicit ARIA roles and selection logic, and use the controller for tabindex and arrow-key focus movement. + +## See also + +- [Keyboard navigation inside components (APG)](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#keyboardnavigationinsidecomponents) +- [Focusgroup explainer (Open UI)](https://open-ui.org/components/scoped-focusgroup.explainer/) diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller/src/focus-group-navigation-controller.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/src/focus-group-navigation-controller.ts new file mode 100644 index 00000000000..d0d8debb6c1 --- /dev/null +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/src/focus-group-navigation-controller.ts @@ -0,0 +1,1109 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { ReactiveController, ReactiveElement } from 'lit'; + +// ───────────────────────── +// TYPES +// ───────────────────────── + +/** + * 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, items that are disabled for interaction are skipped for arrow + * navigation and are not chosen as the roving tab stop. When false, disabled + * items remain in sequence (useful for patterns such as menus where disabled + * items may still be focusable per APG guidance). + * 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; +}; + +// ───────────────────────── +// CONSTANTS +// ───────────────────────── + +/** + * Default boolean flags merged with the constructor `options` object. + * + * @internal + */ +const DEFAULT_OPTIONS = { + wrap: false, + memory: true, + skipDisabled: false, +} as const; + +/** + * Tolerance in CSS pixels for grouping items into the same grid row when using + * {@link FocusgroupDirection | `grid`} mode. + * + * @internal + */ +const GRID_ROW_TOLERANCE_PX = 6; + +/** + * 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 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. + */ +// ───────────────────────────────────────────────────────────────────────────── +// Native `focusgroup` (future) — likely deprecation candidates +// +// If/when browsers implement `focusgroup` (or equivalent) with behavior comparable to this +// controller for your targets, consider removing or shrinking the following areas first: +// +// 1. Roving tabindex — `applyRovingTabindex()`, the tabindex portions of `refresh()` and +// `setActiveItem()`, and assigning `tabIndex` to ineligible raw items. +// +// 2. Host keyboard interception — `handleKeydown()`, `hostConnected` / `hostDisconnected` +// `keydown` listeners, `resolveManagedKeydownTarget()` (shadow retargeting workaround), and +// navigation helpers: `navigateLinear`, `navigateBothAxes`, `navigateGrid`, `navigatePage`, +// `navigatePageLinearItems`, `navigatePageGridRows`, `getEffectivePageMagnitude`, plus +// Home/End and Ctrl+Home/Ctrl+End branches inside `handleKeydown`. +// +// 3. JS “memory” for Tab re-entry — `lastFocused`, `handleFocusin` / `handleFocusout` memory +// paths, and `refresh()`’s preference for `lastFocused` when native group memory replaces +// this pattern. +// +// Often slower to retire (verify against shipped HTML/Open UI behavior): `buildRows` and +// geometry-based **grid** navigation; **pageStep** (Page Up/Down magnitude); **skipDisabled** and +// `isDisabledForSkip`; `isNodeWithinHostScope` / `getRawItems` if declarative scoping differs in +// shadow DOM; `dispatchActiveChange`, `onActiveItemChange`, and the exported event name if +// products still want a single composed integration hook; `focusFirstItemByTextPrefix` for +// typeahead roving tabindex (callers focus `getActiveItem()` unless the platform adds an equivalent). +// `isRtl()` may +// duplicate or diverge from native axis mapping — revisit when testing RTL with native focusgroup. +// ───────────────────────────────────────────────────────────────────────────── + +export class FocusgroupNavigationController implements ReactiveController { + /** + * Lit reactive host this controller is attached to. + */ + private host: ReactiveElement; + + /** + * Effective options (defaults merged with the latest `setOptions` / constructor values). + */ + private options: FocusgroupNavigationOptions; + + /** + * Capture-phase `keydown` listener reference for removal on disconnect. + */ + private readonly boundKeydown = this.handleKeydown.bind(this); + + /** + * Capture-phase `focusin` listener reference for removal on disconnect. + */ + private readonly boundFocusin = this.handleFocusin.bind(this); + + /** + * Capture-phase `focusout` listener reference for removal on disconnect. + */ + private readonly boundFocusout = this.handleFocusout.bind(this); + + /** + * 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: HTMLElement | null = null; + + // ───────────────────────── + // PUBLIC API + // ───────────────────────── + + /** + * 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) { + this.host = host; + this.options = { ...DEFAULT_OPTIONS, ...options }; + host.addController(this); + } + + /** + * 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. + */ + public setOptions(partial: Partial): void { + this.options = { ...this.options, ...partial }; + this.refresh(); + } + + /** + * 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. + */ + public getActiveItem(): HTMLElement | null { + for (const el of this.getEligibleItems()) { + if (el.tabIndex === 0) { + return el; + } + } + return 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. + */ + public refresh(): void { + const items = this.getEligibleItems(); + if (items.length === 0) { + for (const el of this.getRawItems()) { + el.tabIndex = -1; + } + this.lastFocused = null; + this.dispatchActiveChange(null); + this.options.onActiveItemChange?.(null); + return; + } + + const preferred = + (this.options.memory && + this.lastFocused && + items.includes(this.lastFocused) + ? this.lastFocused + : null) ?? + this.getActiveItem() ?? + items[0]; + + this.applyRovingTabindex(preferred); + } + + /** + * 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. + */ + public setActiveItem(item: HTMLElement): boolean { + const items = this.getEligibleItems(); + if (!items.includes(item)) { + return false; + } + this.applyRovingTabindex(item); + if (this.options.memory) { + this.lastFocused = item; + } + return true; + } + + /** + * 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. + */ + public focusFirstItemByTextPrefix(prefix: string): boolean { + const trimmed = prefix.trim(); + if (trimmed === '') { + return false; + } + const needle = trimmed.toLowerCase(); + const items = this.getEligibleItems(); + const match = items.find((el) => { + const label = this.getItemTypeaheadLabel(el).toLowerCase(); + return label.startsWith(needle); + }); + if (!match) { + return false; + } + this.applyRovingTabindex(match); + return true; + } + + /** + * Lit `ReactiveController` hook: registers capture-phase listeners on `host` and runs + * an initial {@link refresh}. + */ + public hostConnected(): void { + this.host.addEventListener('keydown', this.boundKeydown, true); + this.host.addEventListener('focusin', this.boundFocusin, true); + this.host.addEventListener('focusout', this.boundFocusout, true); + this.refresh(); + } + + /** + * Lit `ReactiveController` hook: removes listeners registered in {@link hostConnected}. + */ + public hostDisconnected(): void { + this.host.removeEventListener('keydown', this.boundKeydown, true); + this.host.removeEventListener('focusin', this.boundFocusin, true); + this.host.removeEventListener('focusout', this.boundFocusout, true); + } + + // ───────────────────────── + // IMPLEMENTATION + // ───────────────────────── + // + // Which parts may become redundant under native `focusgroup` is summarized in the + // “Native `focusgroup` (future)” comment block directly above the class declaration. + + /** + * Resolves `dir` from the shadow host, nearest `dir` ancestor, or `document.documentElement`. + * + * @returns True when horizontal arrow directions should follow RTL semantics. + */ + private isRtl(): boolean { + const root = this.host.getRootNode(); + if (root instanceof ShadowRoot) { + const dir = root.host.getAttribute('dir'); + if (dir === 'rtl' || dir === 'ltr') { + return dir === 'rtl'; + } + } + const scoped = this.host.closest('[dir]'); + const d = scoped?.getAttribute('dir'); + if (d === 'rtl') { + return true; + } + if (d === 'ltr') { + return false; + } + return document.documentElement.dir === 'rtl'; + } + + /** + * 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(node: Node | null): boolean { + if (!node) { + return false; + } + const host = this.host; + let current: Node | null = node; + while (current) { + if (current === host) { + return true; + } + const parent: Node | null = current.parentNode; + if (parent) { + current = parent; + } else if (current instanceof ShadowRoot) { + current = current.host; + } else { + return false; + } + } + return false; + } + + /** + * Items returned by `getItems` that lie within `host` (shadow-inclusive tree). + * + * @returns Candidates before eligibility filtering. + */ + private getRawItems(): HTMLElement[] { + return this.options + .getItems() + .filter((el) => this.isNodeWithinHostScope(el)); + } + + /** + * {@link getRawItems} filtered by {@link isNavigableItem}. + * + * @returns Items that participate in roving tabindex and arrow navigation. + */ + private getEligibleItems(): HTMLElement[] { + return this.getRawItems().filter((el) => this.isNavigableItem(el)); + } + + /** + * 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(el: HTMLElement): boolean { + if (!el.isConnected) { + return false; + } + if (el.hasAttribute('inert') || el.closest('[inert]')) { + return false; + } + const style = getComputedStyle(el); + if (style.visibility === 'hidden' || style.display === 'none') { + return false; + } + if (this.options.skipDisabled && this.isDisabledForSkip(el)) { + return false; + } + return true; + } + + /** + * 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(el: HTMLElement): boolean { + if ('disabled' in el && (el as HTMLButtonElement).disabled) { + return true; + } + return el.getAttribute('aria-disabled') === 'true'; + } + + /** + * 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(el: HTMLElement): string { + const fromAria = el.getAttribute('aria-label')?.trim(); + if (fromAria) { + return fromAria; + } + const labelledBy = el.getAttribute('aria-labelledby')?.trim(); + if (labelledBy) { + const root = el.getRootNode(); + const chunks: string[] = []; + for (const id of labelledBy.split(/\s+/)) { + if (!id) { + continue; + } + const ref = + root instanceof ShadowRoot + ? (root.getElementById(id) ?? el.ownerDocument.getElementById(id)) + : el.ownerDocument.getElementById(id); + const t = ref?.textContent?.trim(); + if (t) { + chunks.push(t); + } + } + const joined = chunks.join(' ').trim(); + if (joined) { + return joined; + } + } + return el.textContent?.trim() ?? ''; + } + + /** + * 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. + * Dispatches the active-change event and {@link FocusgroupNavigationOptions.onActiveItemChange}. + * + * @param active - Preferred item to mark as the single tab stop when eligible. + */ + private applyRovingTabindex(active: HTMLElement): void { + const items = this.getEligibleItems(); + const raw = this.getRawItems(); + for (const el of raw) { + if (!items.includes(el)) { + el.tabIndex = -1; + } + } + if (items.length === 0) { + return; + } + const safeActive = items.includes(active) ? active : items[0]; + for (const el of items) { + if (el === safeActive) { + el.tabIndex = 0; + } else { + el.tabIndex = -1; + } + } + this.dispatchActiveChange(safeActive); + this.options.onActiveItemChange?.(safeActive); + } + + /** + * Dispatches {@link focusgroupNavigationActiveChange} on the reactive host with the given detail. + * + * @param activeElement - New active item, or null when clearing selection. + */ + private dispatchActiveChange(activeElement: HTMLElement | null): void { + this.host.dispatchEvent( + new CustomEvent( + focusgroupNavigationActiveChange, + { + bubbles: true, + composed: true, + detail: { activeElement }, + } + ) + ); + } + + /** + * 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(event: FocusEvent): void { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + const items = this.getEligibleItems(); + if (!items.includes(target)) { + return; + } + this.applyRovingTabindex(target); + if (this.options.memory) { + this.lastFocused = target; + } + } + + /** + * 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(event: FocusEvent): void { + const next = event.relatedTarget; + if (next instanceof Node && this.isNodeWithinHostScope(next)) { + return; + } + const target = event.target; + if ( + this.options.memory && + target instanceof HTMLElement && + this.getRawItems().includes(target) + ) { + this.lastFocused = target; + } + } + + /** + * 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( + event: KeyboardEvent, + items: HTMLElement[] + ): HTMLElement | null { + if (items.length === 0) { + return null; + } + const set = new Set(items); + for (const node of event.composedPath()) { + if (!(node instanceof HTMLElement)) { + continue; + } + if (set.has(node)) { + return node; + } + if (node === this.host) { + break; + } + } + const root = this.host.shadowRoot; + const active = root?.activeElement; + if (active instanceof HTMLElement && set.has(active)) { + return active; + } + return null; + } + + /** + * 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(event: KeyboardEvent): void { + if (event.defaultPrevented || event.altKey) { + return; + } + + const items = this.getEligibleItems(); + const target = this.resolveManagedKeydownTarget(event, items); + if (!target) { + return; + } + + if ( + this.options.direction === 'grid' && + event.ctrlKey && + !event.metaKey && + (event.key === 'Home' || event.key === 'End') + ) { + const grid = this.buildRows(items); + if (grid.length > 0) { + const firstRow = grid[0]; + const lastRow = grid[grid.length - 1]; + const boundary = + event.key === 'Home' + ? (firstRow?.[0] ?? null) + : (lastRow?.[lastRow.length - 1] ?? null); + if (boundary && boundary !== target) { + event.preventDefault(); + this.moveKeyNavigationFocusTo(boundary); + } + } + return; + } + + if (event.ctrlKey || event.metaKey) { + return; + } + + const pageMagnitude = this.getEffectivePageMagnitude(); + if ( + pageMagnitude !== null && + (event.key === 'PageUp' || event.key === 'PageDown') + ) { + const pageNext = this.navigatePage( + items, + target, + event.key === 'PageDown' ? pageMagnitude : -pageMagnitude + ); + if (pageNext && pageNext !== target) { + event.preventDefault(); + this.moveKeyNavigationFocusTo(pageNext); + } + return; + } + + const rtl = this.isRtl(); + let next: HTMLElement | null = null; + + switch (this.options.direction) { + case 'horizontal': + next = this.navigateLinear(items, target, event.key, 'horizontal', rtl); + break; + case 'vertical': + next = this.navigateLinear(items, target, event.key, 'vertical', rtl); + break; + case 'both': + next = this.navigateBothAxes(items, target, event.key, rtl); + break; + case 'grid': + next = this.navigateGrid(items, target, event.key, rtl); + break; + default: + break; + } + + if (next && next !== target) { + event.preventDefault(); + this.moveKeyNavigationFocusTo(next); + return; + } + + if (event.key === 'Home' || event.key === 'End') { + const ordered = + this.options.direction === 'grid' + ? this.buildRows(items).flat() + : items; + if (ordered.length === 0) { + return; + } + const boundary = + event.key === 'Home' ? ordered[0] : ordered[ordered.length - 1]; + if (boundary && boundary !== target) { + event.preventDefault(); + this.moveKeyNavigationFocusTo(boundary); + } + } + } + + /** + * Applies roving tabindex to `item` and moves DOM focus; used for keyboard navigation only. + */ + private moveKeyNavigationFocusTo(item: HTMLElement): void { + if (this.setActiveItem(item)) { + item.focus(); + } + } + + /** + * Positive step count for {@link FocusgroupNavigationOptions.pageStep}, or null when page keys + * are disabled. + */ + private getEffectivePageMagnitude(): number | null { + const raw = this.options.pageStep; + if (raw === undefined || raw === null) { + return null; + } + const n = Math.trunc(Number(raw)); + if (!Number.isFinite(n) || n === 0) { + return null; + } + return Math.abs(n); + } + + /** + * 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( + items: HTMLElement[], + current: HTMLElement, + signedDelta: number + ): HTMLElement | null { + if (this.options.direction === 'grid') { + return this.navigatePageGridRows(items, current, signedDelta); + } + return this.navigatePageLinearItems(items, current, signedDelta); + } + + /** + * Page Up/Down along `getItems()` order (used for `horizontal`, `vertical`, and `both`). + */ + private navigatePageLinearItems( + items: HTMLElement[], + current: HTMLElement, + deltaIdx: number + ): HTMLElement | null { + const idx = items.indexOf(current); + if (idx < 0 || items.length === 0) { + return null; + } + let nextIdx = idx + deltaIdx; + if (this.options.wrap) { + const len = items.length; + nextIdx = ((nextIdx % len) + len) % len; + } else { + nextIdx = Math.max(0, Math.min(items.length - 1, nextIdx)); + } + return items[nextIdx] ?? null; + } + + /** + * Page Up/Down by whole rows in `grid` mode (column clamped per {@link navigateGrid}). + */ + private navigatePageGridRows( + items: HTMLElement[], + current: HTMLElement, + rowDelta: number + ): HTMLElement | null { + const grid = this.buildRows(items); + if (grid.length === 0) { + return null; + } + const pos = this.findGridIndex(grid, current); + if (!pos) { + return null; + } + const { row, col } = pos; + let nextRow = row + rowDelta; + if (this.options.wrap) { + const n = grid.length; + nextRow = ((nextRow % n) + n) % n; + } else { + nextRow = Math.max(0, Math.min(grid.length - 1, nextRow)); + } + const targetRow = grid[nextRow]; + if (!targetRow?.length) { + return null; + } + const clampedCol = Math.min(col, targetRow.length - 1); + return targetRow[clampedCol] ?? null; + } + + /** + * 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( + items: HTMLElement[], + current: HTMLElement, + key: string, + mode: 'horizontal' | 'vertical', + rtl: boolean + ): HTMLElement | null { + const idx = items.indexOf(current); + if (idx < 0) { + return null; + } + + let delta = 0; + if (mode === 'horizontal') { + if (key === 'ArrowLeft') { + delta = rtl ? 1 : -1; + } else if (key === 'ArrowRight') { + delta = rtl ? -1 : 1; + } + } else { + if (key === 'ArrowUp') { + delta = -1; + } else if (key === 'ArrowDown') { + delta = 1; + } + } + + if (delta === 0) { + return null; + } + + let nextIdx = idx + delta; + if (this.options.wrap) { + nextIdx = (nextIdx + items.length) % items.length; + } else if (nextIdx < 0 || nextIdx >= items.length) { + return null; + } + return items[nextIdx] ?? null; + } + + /** + * 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( + items: HTMLElement[], + current: HTMLElement, + key: string, + rtl: boolean + ): HTMLElement | null { + const idx = items.indexOf(current); + if (idx < 0) { + return null; + } + + let delta = 0; + if (key === 'ArrowLeft') { + delta = rtl ? 1 : -1; + } else if (key === 'ArrowRight') { + delta = rtl ? -1 : 1; + } else if (key === 'ArrowUp') { + delta = -1; + } else if (key === 'ArrowDown') { + delta = 1; + } + + if (delta === 0) { + return null; + } + + let nextIdx = idx + delta; + if (this.options.wrap) { + nextIdx = (nextIdx + items.length) % items.length; + } else if (nextIdx < 0 || nextIdx >= items.length) { + return null; + } + return items[nextIdx] ?? null; + } + + /** + * Computes the next focus target for `grid` {@link FocusgroupDirection} mode using + * row clustering and column indices. + * + * @param items - Eligible items (layout-derived rows may differ from DOM order). + * @param current - Currently focused item. + * @param key - `KeyboardEvent.key` value. + * @param rtl - When true, horizontal Left/Right swap column direction within a row. + * @returns Next cell item, or null if the key is not handled or movement is blocked. + */ + private navigateGrid( + items: HTMLElement[], + current: HTMLElement, + key: string, + rtl: boolean + ): HTMLElement | null { + const grid = this.buildRows(items); + const pos = this.findGridIndex(grid, current); + if (!pos) { + return null; + } + const { row, col } = pos; + const rowItems = grid[row] ?? []; + let nextRow = row; + let nextCol = col; + + switch (key) { + case 'ArrowLeft': + nextCol = rtl ? col + 1 : col - 1; + break; + case 'ArrowRight': + nextCol = rtl ? col - 1 : col + 1; + break; + case 'ArrowUp': + nextRow = row - 1; + break; + case 'ArrowDown': + nextRow = row + 1; + break; + default: + return null; + } + + if (key === 'ArrowLeft' || key === 'ArrowRight') { + if (nextCol >= 0 && nextCol < rowItems.length) { + return rowItems[nextCol] ?? null; + } + if (this.options.wrap && rowItems.length > 0) { + const wrappedCol = (nextCol + rowItems.length) % rowItems.length; + return rowItems[wrappedCol] ?? null; + } + return null; + } + + if (nextRow < 0 || nextRow >= grid.length) { + if (this.options.wrap && grid.length > 0) { + nextRow = (nextRow + grid.length) % grid.length; + } else { + return null; + } + } + + const targetRow = grid[nextRow]; + if (!targetRow?.length) { + return null; + } + const clampedCol = Math.min(col, targetRow.length - 1); + return targetRow[clampedCol] ?? null; + } + + /** + * 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(items: HTMLElement[]): HTMLElement[][] { + type RowAcc = { top: number; elements: HTMLElement[] }; + const rows: RowAcc[] = []; + + for (const el of items) { + const top = el.getBoundingClientRect().top; + let row = rows.find( + (r) => Math.abs(r.top - top) <= GRID_ROW_TOLERANCE_PX + ); + if (!row) { + row = { top, elements: [] }; + rows.push(row); + } + row.elements.push(el); + } + + rows.sort((a, b) => a.top - b.top); + return rows.map((r) => + r.elements.sort( + (a, b) => + a.getBoundingClientRect().left - b.getBoundingClientRect().left + ) + ); + } + + /** + * 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( + grid: HTMLElement[][], + el: HTMLElement + ): { row: number; col: number } | null { + for (let r = 0; r < grid.length; r++) { + const c = grid[r].indexOf(el); + if (c !== -1) { + return { row: r, col: c }; + } + } + return null; + } +} diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller/stories/focus-group-navigation-controller.stories.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/stories/focus-group-navigation-controller.stories.ts new file mode 100644 index 00000000000..114d9ab2e30 --- /dev/null +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/stories/focus-group-navigation-controller.stories.ts @@ -0,0 +1,830 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { css, html, LitElement, type TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import type { Meta, StoryObj } from '@storybook/web-components'; + +import { FocusgroupNavigationController } from '../../focus-group-navigation-controller.js'; +import readme from '../focus-group-navigation-controller.md?raw'; + +// ───────────────────────── +// DEMO HOSTS +// ───────────────────────── + +/** + * @internal + * + * Storybook-only host demonstrating horizontal {@link FocusgroupNavigationController} usage. + */ +@customElement('demo-focusgroup-horizontal') +export class DemoFocusgroupHorizontal extends LitElement { + /** + * Shadow DOM styles for the inline toolbar demo. + */ + static override styles = css` + :host { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + button { + font: inherit; + padding: 8px 12px; + border-radius: 4px; + border: 1px solid var(--spectrum-gray-400, #ccc); + background: var(--spectrum-gray-75, #f5f5f5); + cursor: pointer; + } + button:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } + `; + + /** + * Controller instance: horizontal direction with wrapping. + */ + private readonly navigation = new FocusgroupNavigationController(this, { + direction: 'horizontal', + wrap: true, + getItems: () => + Array.from(this.renderRoot.querySelectorAll('button')), + }); + + /** + * Runs after first render so `renderRoot` contains buttons before {@link FocusgroupNavigationController.refresh}. + */ + protected override firstUpdated(): void { + super.firstUpdated(); + this.navigation.refresh(); + } + + /** + * Renders formatting action buttons managed by the focus navigation controller. + */ + protected override render(): TemplateResult { + return html` + + + + + `; + } +} + +/** + * @internal + * + * Storybook-only host demonstrating `direction: 'both'` — horizontal and vertical arrows + * move along the same `getItems()` order. + */ +@customElement('demo-focusgroup-both-axes') +export class DemoFocusgroupBothAxes extends LitElement { + /** + * Shadow DOM styles for the inline toolbar demo (layout matches horizontal; keys differ). + */ + static override styles = css` + :host { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + button { + font: inherit; + padding: 8px 12px; + border-radius: 4px; + border: 1px solid var(--spectrum-gray-400, #ccc); + background: var(--spectrum-gray-75, #f5f5f5); + cursor: pointer; + } + button:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } + `; + + /** + * Controller instance: both axes on one linear sequence with wrapping. + */ + private readonly navigation = new FocusgroupNavigationController(this, { + direction: 'both', + wrap: true, + getItems: () => + Array.from(this.renderRoot.querySelectorAll('button')), + }); + + /** + * Runs after first render so `renderRoot` contains buttons before {@link FocusgroupNavigationController.refresh}. + */ + protected override firstUpdated(): void { + super.firstUpdated(); + this.navigation.refresh(); + } + + /** + * Renders segment controls; **ArrowLeft** / **ArrowRight** and **ArrowUp** / **ArrowDown** + * all traverse this row in order. + */ + protected override render(): TemplateResult { + return html` + + + + + `; + } +} + +/** + * @internal + * + * Storybook-only host demonstrating vertical {@link FocusgroupNavigationController} usage. + */ +@customElement('demo-focusgroup-vertical') +export class DemoFocusgroupVertical extends LitElement { + /** + * Shadow DOM styles for the vertical menu demo. + */ + static override styles = css` + :host { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + max-width: 14rem; + padding: 8px; + border: 1px solid var(--spectrum-gray-300, #ddd); + border-radius: 4px; + background: var(--spectrum-gray-50, #fff); + } + button { + font: inherit; + text-align: start; + padding: 8px 12px; + border: none; + border-radius: 4px; + background: transparent; + cursor: pointer; + } + button:hover { + background: var(--spectrum-gray-200, #e8e8e8); + } + button:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 0; + } + button[aria-disabled='true'] { + opacity: 0.5; + cursor: not-allowed; + } + `; + + /** + * Blocks pointer activation for the menu item that uses `aria-disabled` instead of native + * `disabled` so it can stay in the roving focus order (native `disabled` is not focusable). + * + * @param event - Click event from the inert item. + */ + private handleInertMenuItemClick(event: Event): void { + event.preventDefault(); + } + + /** + * Prevents Enter/Space from activating the `aria-disabled` item like a normal button. + * + * @param event - Key event while the inert item is focused. + */ + private handleInertMenuItemKeydown(event: KeyboardEvent): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + } + } + + /** + * Controller instance: vertical direction with wrapping; disabled items stay focusable; + * **Page Up** / **Page Down** move two items at a time (`pageStep: 2`). + */ + private readonly navigation = new FocusgroupNavigationController(this, { + direction: 'vertical', + wrap: true, + pageStep: 2, + skipDisabled: false, + getItems: () => + Array.from(this.renderRoot.querySelectorAll('button')), + }); + + /** + * Runs after first render so `renderRoot` contains buttons before {@link FocusgroupNavigationController.refresh}. + */ + protected override firstUpdated(): void { + super.firstUpdated(); + this.navigation.refresh(); + } + + /** + * Renders menu-like actions including one inactive control. + * + * Uses `aria-disabled="true"` instead of the `disabled` attribute so the item stays + * focusable while arrow keys move through the list; native `disabled` removes focusability + * and would block reaching items after it. + */ + protected override render(): TemplateResult { + return html` + + + + + `; + } +} + +/** + * @internal + * + * Storybook-only host demonstrating {@link FocusgroupNavigationController} with + * `skipDisabled: true` so native `disabled` and `aria-disabled="true"` items are omitted + * from roving tabindex and arrow navigation. + */ +@customElement('demo-focusgroup-skip-disabled') +export class DemoFocusgroupSkipDisabled extends LitElement { + /** + * Shadow DOM styles for the menu demo (disabled styling for skipped items). + */ + static override styles = css` + :host { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + max-width: 16rem; + padding: 8px; + border: 1px solid var(--spectrum-gray-300, #ddd); + border-radius: 4px; + background: var(--spectrum-gray-50, #fff); + } + button { + font: inherit; + text-align: start; + padding: 8px 12px; + border: none; + border-radius: 4px; + background: transparent; + cursor: pointer; + } + button:hover:not([disabled]):not([aria-disabled='true']) { + background: var(--spectrum-gray-200, #e8e8e8); + } + button:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 0; + } + button[disabled], + button[aria-disabled='true'] { + opacity: 0.5; + cursor: not-allowed; + } + `; + + /** + * Prevents activating the `aria-disabled` item if it is clicked (not in arrow sequence). + * + * @param event - Click from the inactive item. + */ + private handleSkippedAriaDisabledClick(event: Event): void { + event.preventDefault(); + } + + /** + * Controller instance: vertical list; disabled and `aria-disabled` items are skipped. + */ + private readonly navigation = new FocusgroupNavigationController(this, { + direction: 'vertical', + wrap: true, + skipDisabled: true, + getItems: () => + Array.from(this.renderRoot.querySelectorAll('button')), + }); + + /** + * Runs after first render so `renderRoot` contains buttons before {@link FocusgroupNavigationController.refresh}. + */ + protected override firstUpdated(): void { + super.firstUpdated(); + this.navigation.refresh(); + } + + /** + * Renders a file menu with two skipped entries (native **disabled** and **aria-disabled**). + */ + protected override render(): TemplateResult { + return html` + + + + + + + `; + } +} + +/** + * @internal + * + * Storybook-only host demonstrating `grid` {@link FocusgroupNavigationController} usage. + */ +@customElement('demo-focusgroup-grid') +export class DemoFocusgroupGrid extends LitElement { + /** + * Shadow DOM styles for the 3×3 grid demo. + */ + static override styles = css` + :host { + display: block; + } + .grid { + display: grid; + grid-template-columns: repeat(3, 5rem); + gap: 8px; + } + button { + font: inherit; + height: 3rem; + border-radius: 4px; + border: 1px solid var(--spectrum-gray-400, #ccc); + background: var(--spectrum-gray-75, #f5f5f5); + cursor: pointer; + } + button:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } + `; + + /** + * Controller instance: grid direction without row/column wrap; **Page Up** / **Page Down** + * move two rows (`pageStep: 2`). + */ + private readonly navigation = new FocusgroupNavigationController(this, { + direction: 'grid', + wrap: false, + pageStep: 2, + getItems: () => + Array.from(this.renderRoot.querySelectorAll('.grid button')), + }); + + /** + * Runs after first render so grid buttons exist before {@link FocusgroupNavigationController.refresh}. + */ + protected override firstUpdated(): void { + super.firstUpdated(); + this.navigation.refresh(); + } + + /** + * Renders a `role="grid"` region with nine `role="gridcell"` buttons. + */ + protected override render(): TemplateResult { + const cells = Array.from({ length: 9 }, (_, i) => i + 1); + return html` +
+ ${cells.map( + (n) => html` + + ` + )} +
+ `; + } +} + +/** + * @internal + * + * Storybook-only host demonstrating {@link FocusgroupNavigationController.setActiveItem} plus + * explicit `focus()` from the demo. + */ +@customElement('demo-focusgroup-programmatic') +export class DemoFocusgroupProgrammatic extends LitElement { + /** + * Shadow DOM styles for the toolbar row and programmatic trigger control. + */ + static override styles = css` + :host { + display: flex; + flex-direction: column; + gap: 12px; + align-items: flex-start; + } + .demo-trigger { + margin-top: 4px; + font: inherit; + padding: 8px 12px; + border-radius: 4px; + border: 1px solid var(--spectrum-gray-400, #ccc); + background: var(--spectrum-gray-75, #f5f5f5); + cursor: pointer; + } + .demo-trigger:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } + .row { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + button { + font: inherit; + padding: 8px 12px; + border-radius: 4px; + border: 1px solid var(--spectrum-gray-400, #ccc); + background: var(--spectrum-gray-75, #f5f5f5); + cursor: pointer; + } + button:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } + `; + + /** + * Controller instance: horizontal without wrap; items are toolbar buttons with `data-item`. + */ + private readonly navigation = new FocusgroupNavigationController(this, { + direction: 'horizontal', + wrap: false, + getItems: () => + Array.from( + this.renderRoot.querySelectorAll('.row button[data-item]') + ), + }); + + /** + * Which `data-item` value {@link focusProgrammaticTarget} should focus. + * + * Reflected as attribute `focus-target` for the Storybook story. + */ + @property({ type: String, attribute: 'focus-target' }) + public focusTarget: 'a' | 'b' | 'c' = 'c'; + + /** + * Runs after first render so toolbar buttons exist before {@link FocusgroupNavigationController.refresh}. + */ + protected override firstUpdated(): void { + super.firstUpdated(); + this.navigation.refresh(); + } + + /** + * Sets roving tabindex to the toolbar button whose `data-item` matches {@link focusTarget}, + * then moves focus (deferred so a trigger `click` does not overwrite focus). + */ + public focusProgrammaticTarget(): void { + const sel = `[data-item="${this.focusTarget}"]`; + const el = this.renderRoot.querySelector(sel); + if (el && this.navigation.setActiveItem(el)) { + queueMicrotask(() => { + el.focus(); + }); + } + } + + /** + * Click handler for the demo trigger button; delegates to {@link focusProgrammaticTarget}. + */ + private handleProgrammaticDemoActivate(): void { + this.focusProgrammaticTarget(); + } + + /** + * Renders the toolbar row and external trigger used to test programmatic focus. + */ + protected override render(): TemplateResult { + return html` + + + `; + } +} + +/** + * @internal + * + * Storybook-only host demonstrating {@link FocusgroupNavigationController.focusFirstItemByTextPrefix}. + */ +@customElement('demo-focusgroup-text-prefix') +export class DemoFocusgroupTextPrefix extends LitElement { + /** + * Shadow DOM styles for the vertical menu and demo triggers outside the roving group. + */ + static override styles = css` + :host { + display: flex; + flex-direction: column; + gap: 12px; + align-items: flex-start; + max-width: 18rem; + } + .menu { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + width: 100%; + padding: 8px; + border: 1px solid var(--spectrum-gray-300, #ddd); + border-radius: 4px; + background: var(--spectrum-gray-50, #fff); + } + .menu button { + font: inherit; + text-align: start; + padding: 8px 12px; + border: none; + border-radius: 4px; + background: transparent; + cursor: pointer; + } + .menu button:hover { + background: var(--spectrum-gray-200, #e8e8e8); + } + .menu button:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 0; + } + .menu button.typeahead-icon { + font-size: 1.125rem; + line-height: 1.2; + } + .triggers { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .demo-trigger { + font: inherit; + padding: 8px 12px; + border-radius: 4px; + border: 1px solid var(--spectrum-gray-400, #ccc); + background: var(--spectrum-gray-75, #f5f5f5); + cursor: pointer; + } + .demo-trigger:focus-visible { + outline: 2px solid var(--spectrum-blue-800, #0265dc); + outline-offset: 2px; + } + `; + + /** + * Controller instance: only `.menu button` elements participate (triggers are outside). + */ + private readonly navigation = new FocusgroupNavigationController(this, { + direction: 'vertical', + wrap: true, + getItems: () => + Array.from(this.renderRoot.querySelectorAll('.menu button')), + }); + + /** + * Runs after first render so menu buttons exist before {@link FocusgroupNavigationController.refresh}. + */ + protected override firstUpdated(): void { + super.firstUpdated(); + this.navigation.refresh(); + } + + /** + * Applies roving tabindex via {@link FocusgroupNavigationController.focusFirstItemByTextPrefix}, + * then focuses the active item (for Storybook tests; production code can call those separately). + * + * @param prefix - Prefix to match against each item’s typeahead label. + * @returns Whether a matching item was found and focused. + */ + public focusByTextPrefix(prefix: string): boolean { + if (!this.navigation.focusFirstItemByTextPrefix(prefix)) { + return false; + } + this.navigation.getActiveItem()?.focus(); + return true; + } + + /** + * Demo: roving tabindex for **Paste** via prefix `Pas`, then move focus after the click target + * has finished activation. + */ + private handleDemoPrefixPas(): void { + if (!this.navigation.focusFirstItemByTextPrefix('Pas')) { + return; + } + queueMicrotask(() => { + this.navigation.getActiveItem()?.focus(); + }); + } + + /** + * Demo: roving tabindex for **Cut** via prefix `cu`, then move focus after activation. + */ + private handleDemoPrefixCu(): void { + if (!this.navigation.focusFirstItemByTextPrefix('cu')) { + return; + } + queueMicrotask(() => { + this.navigation.getActiveItem()?.focus(); + }); + } + + /** + * Renders a small menu plus trigger buttons that call prefix-based focus on the controller. + */ + protected override render(): TemplateResult { + return html` + +
+ + +
+ `; + } +} + +// ───────────────────────── +// STORYBOOK +// ───────────────────────── + +/** + * Storybook metadata: documentation body comes from `focus-group-navigation-controller.md`. + */ +const meta: Meta = { + title: 'Focus group navigation controller', + tags: ['autodocs'], + parameters: { + docs: { + subtitle: `Roving tabindex and directional keys for composite widgets (APG-aligned, focusgroup-like).`, + description: { + component: readme, + }, + }, + }, +}; + +/** Lit demo hosts are exported for unit tests; exclude from CSF so Vitest does not run them as stories. */ +export default { + ...meta, + excludeStories: [ + 'DemoFocusgroupHorizontal', + 'DemoFocusgroupBothAxes', + 'DemoFocusgroupVertical', + 'DemoFocusgroupSkipDisabled', + 'DemoFocusgroupGrid', + 'DemoFocusgroupProgrammatic', + 'DemoFocusgroupTextPrefix', + ], +} as Meta; + +type Story = StoryObj; + +/** + * Inline-axis arrows move between formatting controls; Tab yields one stop for the group. + */ +export const HorizontalToolbar: Story = { + render: () => html` + + `, +}; + +/** + * **ArrowLeft** / **ArrowRight** and **ArrowUp** / **ArrowDown** all move along the same + * control order (LTR: Right and Down advance, Left and Up go back). Useful when layout is + * horizontal but users expect vertical arrow keys to work as well. + */ +export const BothAxesLinear: Story = { + render: () => html` + + `, +}; + +/** + * Block-axis arrows traverse menu-like items; **Page Up** / **Page Down** skip two items. + * One control uses `aria-disabled` (not native `disabled`) so it stays focusable and items + * after it remain reachable. + */ +export const VerticalMenu: Story = { + render: () => html` + + `, +}; + +/** + * With `skipDisabled: true`, **Save** (`disabled`) and **Close** (`aria-disabled="true"`) are + * left out of the tab order and arrow sequence; **New**, **Open**, **Print**, and **Help** are + * all reachable with **ArrowDown** / **ArrowUp** (wrap on). + */ +export const SkipDisabledMenu: Story = { + render: () => html` + + `, +}; + +/** + * Arrow keys move across a 3×3 grid; **Page Up** / **Page Down** move two rows at a time. + * **Home** / **End** jump to the first and last cell in row-major order; **Ctrl+Home** / + * **Ctrl+End** jump to the first cell of the first row and the last cell of the last row + * (equivalent here to cells **1** and **9**). + */ +export const Grid: Story = { + render: () => html` + + `, +}; + +/** + * Demo calls \`setActiveItem\` then \`focus()\` so the roving tab stop matches keyboard navigation. + */ +export const ProgrammaticFocus: Story = { + render: () => html` + + `, +}; + +/** + * The controller’s **focusFirstItemByTextPrefix** only syncs roving `tabindex` to the first label + * match; the demo triggers then call `focus()` on {@link FocusgroupNavigationController.getActiveItem} + * in a microtask. Call the same pattern from application code (for example on `keydown` for typeahead). + */ +export const TextPrefixFocus: Story = { + render: () => html` + + `, +}; diff --git a/2nd-gen/packages/core/controllers/focus-group-navigation-controller/test/focus-group-navigation-controller.test.ts b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/test/focus-group-navigation-controller.test.ts new file mode 100644 index 00000000000..13fe0522c7f --- /dev/null +++ b/2nd-gen/packages/core/controllers/focus-group-navigation-controller/test/focus-group-navigation-controller.test.ts @@ -0,0 +1,505 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { expect } from '@storybook/test'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; + +import { getComponent } from '../../../swc/utils/test-utils.js'; +import focusMeta, { + BothAxesLinear, + DemoFocusgroupProgrammatic, + DemoFocusgroupTextPrefix, + Grid, + HorizontalToolbar, + ProgrammaticFocus, + SkipDisabledMenu, + TextPrefixFocus, + VerticalMenu, +} from './focus-group-navigation-controller.stories.js'; + +type KeydownOptions = { + ctrlKey?: boolean; +}; + +/** + * Dispatches a composed `keydown` so listeners on the shadow host receive it like a real keystroke. + * + * @param target - Element to dispatch from (typically the focused control inside the host). + * @param key - `KeyboardEvent.key` value. + * @param options - Optional modifier keys. + */ +function keydown( + target: HTMLElement, + key: string, + options?: KeydownOptions +): void { + target.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + composed: true, + cancelable: true, + ctrlKey: options?.ctrlKey ?? false, + }) + ); +} + +/** Returns the focused element inside the host shadow root, if any. */ +function shadowActiveButton(host: HTMLElement): HTMLButtonElement | null { + const root = host.shadowRoot; + const active = root?.activeElement; + return active instanceof HTMLButtonElement ? active : null; +} + +export default { + ...focusMeta, + title: 'Focus group navigation controller/Tests', + parameters: { + ...focusMeta.parameters, + docs: { disable: true, page: null }, + }, + tags: ['!autodocs', 'dev'], +} as Meta; + +// ────────────────────────────────────────────────────────────── +// Horizontal toolbar (ArrowLeft / ArrowRight, wrap) +// ────────────────────────────────────────────────────────────── + +export const HorizontalToolbarArrowNavigation: Story = { + ...HorizontalToolbar, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-focusgroup-horizontal' + ); + + await step('ArrowRight moves forward along the toolbar', async () => { + const root = host.shadowRoot; + expect(root).toBeTruthy(); + const first = root!.querySelector('button'); + expect(first?.textContent?.trim()).toBe('Bold'); + first!.focus(); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Bold'); + + keydown(first!, 'ArrowRight'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Italic'); + + keydown(shadowActiveButton(host)!, 'ArrowRight'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Underline'); + + keydown(shadowActiveButton(host)!, 'ArrowRight'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe( + 'Strikethrough' + ); + }); + + await step('ArrowLeft moves backward', async () => { + keydown(shadowActiveButton(host)!, 'ArrowLeft'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Underline'); + }); + + await step('wrap: ArrowRight from last item returns to first', async () => { + while ( + shadowActiveButton(host)?.textContent?.trim() !== 'Strikethrough' + ) { + keydown(shadowActiveButton(host)!, 'ArrowRight'); + } + keydown(shadowActiveButton(host)!, 'ArrowRight'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Bold'); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// Both axes (horizontal + vertical arrows on one linear order) +// ────────────────────────────────────────────────────────────── + +export const BothAxesLinearArrowNavigation: Story = { + ...BothAxesLinear, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-focusgroup-both-axes' + ); + + await step( + 'ArrowRight and ArrowDown both advance in getItems() order', + async () => { + const root = host.shadowRoot; + expect(root).toBeTruthy(); + const first = root!.querySelector('button'); + expect(first?.textContent?.trim()).toBe('Start'); + first!.focus(); + + keydown(first!, 'ArrowRight'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Section A'); + + keydown(shadowActiveButton(host)!, 'ArrowDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Section B'); + } + ); + + await step('ArrowLeft and ArrowUp both move backward', async () => { + keydown(shadowActiveButton(host)!, 'ArrowLeft'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Section A'); + + keydown(shadowActiveButton(host)!, 'ArrowUp'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Start'); + }); + + await step('wrap: ArrowDown from last item returns to first', async () => { + while (shadowActiveButton(host)?.textContent?.trim() !== 'End') { + keydown(shadowActiveButton(host)!, 'ArrowDown'); + } + keydown(shadowActiveButton(host)!, 'ArrowDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Start'); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// Vertical menu (ArrowDown through aria-disabled item) +// ────────────────────────────────────────────────────────────── + +export const VerticalMenuArrowNavigation: Story = { + ...VerticalMenu, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-focusgroup-vertical' + ); + + await step( + 'ArrowDown reaches each item including aria-disabled and last item', + async () => { + const root = host.shadowRoot; + expect(root).toBeTruthy(); + const first = root!.querySelector('button'); + expect(first?.textContent?.trim()).toBe('Copy'); + first!.focus(); + + keydown(first!, 'ArrowDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Paste'); + + keydown(shadowActiveButton(host)!, 'ArrowDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe( + 'Cut (unavailable)' + ); + expect(shadowActiveButton(host)?.getAttribute('aria-disabled')).toBe( + 'true' + ); + + keydown(shadowActiveButton(host)!, 'ArrowDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe( + 'Select all' + ); + } + ); + + await step('ArrowUp moves back through the list', async () => { + keydown(shadowActiveButton(host)!, 'ArrowUp'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe( + 'Cut (unavailable)' + ); + }); + + await step( + 'Page Down skips two items; Page Up moves back two', + async () => { + const root = host.shadowRoot!; + root.querySelector('button')!.focus(); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Copy'); + + keydown(shadowActiveButton(host)!, 'PageDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe( + 'Cut (unavailable)' + ); + + root.querySelectorAll('button')[3]!.focus(); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe( + 'Select all' + ); + + keydown(shadowActiveButton(host)!, 'PageUp'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Paste'); + } + ); + }, +}; + +// ────────────────────────────────────────────────────────────── +// Skip disabled (native disabled + aria-disabled omitted from arrows) +// ────────────────────────────────────────────────────────────── + +export const SkipDisabledMenuArrowNavigation: Story = { + ...SkipDisabledMenu, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-focusgroup-skip-disabled' + ); + const root = host.shadowRoot!; + const buttonByLabel = (label: string): HTMLButtonElement => { + const b = Array.from( + root.querySelectorAll('button') + ).find((btn) => btn.textContent?.trim() === label); + expect(b).toBeTruthy(); + return b!; + }; + + await step( + 'skipped items use tabindex -1 and are disabled or aria-disabled', + async () => { + const save = buttonByLabel('Save'); + const close = buttonByLabel('Close'); + expect(save.disabled).toBe(true); + expect(close.getAttribute('aria-disabled')).toBe('true'); + expect(save.tabIndex).toBe(-1); + expect(close.tabIndex).toBe(-1); + } + ); + + await step( + 'ArrowDown visits every enabled item in order then wraps to first', + async () => { + buttonByLabel('New').focus(); + const visited: string[] = []; + for (let i = 0; i < 5; i++) { + visited.push(shadowActiveButton(host)!.textContent!.trim()); + keydown(shadowActiveButton(host)!, 'ArrowDown'); + } + expect(visited).toEqual(['New', 'Open', 'Print', 'Help', 'New']); + } + ); + + await step('ArrowUp from first enabled wraps to last enabled', async () => { + buttonByLabel('New').focus(); + keydown(shadowActiveButton(host)!, 'ArrowUp'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Help'); + }); + + await step('many arrow steps never focus Save or Close', async () => { + buttonByLabel('New').focus(); + for (let i = 0; i < 16; i++) { + const label = shadowActiveButton(host)?.textContent?.trim(); + expect(label).not.toBe('Save'); + expect(label).not.toBe('Close'); + keydown(shadowActiveButton(host)!, 'ArrowDown'); + } + }); + + await step('Home and End stay within eligible items only', async () => { + buttonByLabel('Print').focus(); + keydown(shadowActiveButton(host)!, 'Home'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('New'); + keydown(shadowActiveButton(host)!, 'End'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Help'); + }); + }, +}; + +// ────────────────────────────────────────────────────────────── +// Grid (spatial arrows, Home / End) +// ────────────────────────────────────────────────────────────── + +export const GridArrowNavigation: Story = { + ...Grid, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-focusgroup-grid' + ); + + const cell = (label: string): HTMLButtonElement => { + const root = host.shadowRoot!; + const buttons = Array.from( + root.querySelectorAll('.grid button') + ); + const b = buttons.find((btn) => btn.textContent?.trim() === label); + expect(b).toBeTruthy(); + return b!; + }; + + await step( + 'from center cell 5, arrows move to geometric neighbors', + async () => { + const c5 = cell('5'); + c5.focus(); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('5'); + + keydown(c5, 'ArrowLeft'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('4'); + + keydown(shadowActiveButton(host)!, 'ArrowRight'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('5'); + + keydown(shadowActiveButton(host)!, 'ArrowUp'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('2'); + + keydown(shadowActiveButton(host)!, 'ArrowDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('5'); + + keydown(shadowActiveButton(host)!, 'ArrowDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('8'); + } + ); + + await step( + 'Home and End jump to first and last cell in row-major order', + async () => { + cell('5').focus(); + keydown(shadowActiveButton(host)!, 'Home'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('1'); + + keydown(shadowActiveButton(host)!, 'End'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('9'); + } + ); + + await step( + 'Ctrl+Home and Ctrl+End jump to first cell of first row and last cell of last row', + async () => { + cell('8').focus(); + keydown(shadowActiveButton(host)!, 'Home', { ctrlKey: true }); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('1'); + + cell('2').focus(); + keydown(shadowActiveButton(host)!, 'End', { ctrlKey: true }); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('9'); + } + ); + + await step( + 'Page Down moves two rows; Page Up moves back two rows', + async () => { + cell('1').focus(); + keydown(shadowActiveButton(host)!, 'PageDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('7'); + + keydown(shadowActiveButton(host)!, 'PageUp'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('1'); + } + ); + + await step( + 'Page Down past last row clamps to last row (same column clamped)', + async () => { + cell('5').focus(); + keydown(shadowActiveButton(host)!, 'PageDown'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('8'); + } + ); + }, +}; + +// ────────────────────────────────────────────────────────────── +// Programmatic focus + horizontal arrows without wrap +// ────────────────────────────────────────────────────────────── + +export const ProgrammaticFocusAndArrows: Story = { + ...ProgrammaticFocus, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-focusgroup-programmatic' + ); + + await step('ArrowRight moves only among toolbar items A–C', async () => { + const root = host.shadowRoot; + expect(root).toBeTruthy(); + const itemA = root!.querySelector('[data-item="a"]'); + expect(itemA).toBeTruthy(); + itemA!.focus(); + expect(shadowActiveButton(host)?.getAttribute('data-item')).toBe('a'); + + keydown(itemA!, 'ArrowRight'); + expect(shadowActiveButton(host)?.getAttribute('data-item')).toBe('b'); + + keydown(shadowActiveButton(host)!, 'ArrowRight'); + expect(shadowActiveButton(host)?.getAttribute('data-item')).toBe('c'); + }); + + await step( + 'no wrap: ArrowRight from last toolbar item stays on C', + async () => { + keydown(shadowActiveButton(host)!, 'ArrowRight'); + expect(shadowActiveButton(host)?.getAttribute('data-item')).toBe('c'); + } + ); + + await step( + 'setActiveItem plus focus updates roving tabindex and arrow navigation', + async () => { + host.focusProgrammaticTarget(); + await Promise.resolve(); + expect(host.focusTarget).toBe('c'); + expect(shadowActiveButton(host)?.getAttribute('data-item')).toBe('c'); + + keydown(shadowActiveButton(host)!, 'ArrowLeft'); + expect(shadowActiveButton(host)?.getAttribute('data-item')).toBe('b'); + } + ); + }, +}; + +// ────────────────────────────────────────────────────────────── +// Text prefix / typeahead (focusFirstItemByTextPrefix) +// ────────────────────────────────────────────────────────────── + +export const TextPrefixFocusNavigation: Story = { + ...TextPrefixFocus, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-focusgroup-text-prefix' + ); + + await step( + 'prefix "c" focuses Copy (first eligible match in order)', + async () => { + expect(host.focusByTextPrefix('c')).toBe(true); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Copy'); + } + ); + + await step('longer prefix "cu" focuses Cut', async () => { + expect(host.focusByTextPrefix('cu')).toBe(true); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Cut'); + }); + + await step('prefix is case-insensitive and trim-aware', async () => { + expect(host.focusByTextPrefix(' PAS ')).toBe(true); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Paste'); + + expect(host.focusByTextPrefix('SEL')).toBe(true); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Select all'); + }); + + await step('aria-label is used when present (icon-only item)', async () => { + expect(host.focusByTextPrefix('un')).toBe(true); + expect(shadowActiveButton(host)?.getAttribute('aria-label')).toBe('Undo'); + }); + + await step( + 'whitespace-only prefix returns false without changing focus', + async () => { + host.focusByTextPrefix('Paste'); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Paste'); + expect(host.focusByTextPrefix(' ')).toBe(false); + expect(shadowActiveButton(host)?.textContent?.trim()).toBe('Paste'); + } + ); + + await step('no match returns false', async () => { + expect(host.focusByTextPrefix('zzz')).toBe(false); + }); + }, +}; diff --git a/2nd-gen/packages/core/controllers/index.ts b/2nd-gen/packages/core/controllers/index.ts index a022b4457d6..5a880b5839a 100644 --- a/2nd-gen/packages/core/controllers/index.ts +++ b/2nd-gen/packages/core/controllers/index.ts @@ -10,6 +10,17 @@ * governing permissions and limitations under the License. */ +/** + * Public exports for Lit reactive controllers shared across 2nd-gen packages. + */ + +export { + focusgroupNavigationActiveChange, + FocusgroupNavigationController, + type FocusgroupDirection, + type FocusgroupNavigationActiveChangeDetail, + type FocusgroupNavigationOptions, +} from './focus-group-navigation-controller.js'; export { LanguageResolutionController, languageResolverUpdatedSymbol, diff --git a/2nd-gen/packages/core/element/spectrum-element.ts b/2nd-gen/packages/core/element/spectrum-element.ts index 5403b644c51..a0d87b27137 100644 --- a/2nd-gen/packages/core/element/spectrum-element.ts +++ b/2nd-gen/packages/core/element/spectrum-element.ts @@ -12,6 +12,7 @@ import { LitElement, ReactiveElement } from 'lit'; +import { getActiveElement } from '../utils/get-active-element.js'; import { coreVersion, version } from './version.js'; type Constructor> = { @@ -34,39 +35,10 @@ export function SpectrumMixin>( */ public override shadowRoot!: ShadowRoot; public hasVisibleFocusInTree(): boolean { - const getAncestors = (root: Document = document): HTMLElement[] => { - let currentNode = root.activeElement as HTMLElement; - while ( - currentNode?.shadowRoot && - currentNode.shadowRoot.activeElement - ) { - currentNode = currentNode.shadowRoot.activeElement as HTMLElement; - } - const ancestors: HTMLElement[] = currentNode ? [currentNode] : []; - while (currentNode) { - const ancestor = - currentNode.assignedSlot || - currentNode.parentElement || - (currentNode.getRootNode() as ShadowRoot)?.host; - if (ancestor) { - ancestors.push(ancestor as HTMLElement); - } - currentNode = ancestor as HTMLElement; - } - return ancestors; - }; - const activeElement = getAncestors(this.getRootNode() as Document)[0]; - if (!activeElement) { - return false; - } - // Browsers without support for the `:focus-visible` - // selector will throw on the following test (Safari, older things). - // Some won't throw, but will be focusing item rather than the menu and - // will rely on the polyfill to know whether focus is "visible" or not. - return ( - activeElement.matches(':focus-visible') || - activeElement.matches('.focus-visible') + const active = getActiveElement( + this.getRootNode() as Document | ShadowRoot ); + return active?.matches(':focus-visible') ?? false; } } return SpectrumMixinElement; diff --git a/2nd-gen/packages/core/mixins/disabled-mixin.ts b/2nd-gen/packages/core/mixins/disabled-mixin.ts new file mode 100644 index 00000000000..82710a61c50 --- /dev/null +++ b/2nd-gen/packages/core/mixins/disabled-mixin.ts @@ -0,0 +1,85 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { PropertyValues, ReactiveElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +type Constructor> = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new (...args: any[]): T; + prototype: T; +}; + +export interface DisabledInterface { + disabled: boolean; +} + +/** + * A mixin that adds a reactive `disabled` property with associated + * accessibility behavior. + * + * Sets `aria-disabled` on the host (not the native `disabled` attribute) + * so the element remains discoverable by assistive technology. Components + * wrapping native form controls (e.g., ``, ``; + * } + * } + * ``` + */ +export function DisabledMixin>( + constructor: T +): T & Constructor { + class DisabledElement extends constructor { + @property({ type: Boolean, reflect: true }) + disabled = false; + + /** + * Uses `update()` (not `updated()`) so side effects apply BEFORE render. + * Using `updated()` would leave a single frame where the component + * renders as enabled but is behaviorally disabled, allowing the element + * to be briefly focusable/clickable. + */ + protected override update(changedProperties: PropertyValues): void { + if (changedProperties.has('disabled')) { + if (this.disabled) { + // Host gets aria-disabled for screen reader discoverability. + // Components wrapping native form controls should ALSO set + // disabled on the inner element in render(). + this.setAttribute('aria-disabled', 'true'); + if (this.hasAttribute('tabindex')) { + this.dataset.prevTabindex = this.getAttribute('tabindex')!; + this.setAttribute('tabindex', '-1'); + } + if (this.matches(':focus-within')) { + (this.shadowRoot?.activeElement as HTMLElement)?.blur(); + } + } else { + this.removeAttribute('aria-disabled'); + if (this.dataset.prevTabindex !== undefined) { + this.setAttribute('tabindex', this.dataset.prevTabindex); + delete this.dataset.prevTabindex; + } + } + } + super.update(changedProperties); + } + } + return DisabledElement as unknown as T & Constructor; +} diff --git a/2nd-gen/packages/core/mixins/index.ts b/2nd-gen/packages/core/mixins/index.ts index f8343051236..321f1138dfb 100644 --- a/2nd-gen/packages/core/mixins/index.ts +++ b/2nd-gen/packages/core/mixins/index.ts @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +export { DisabledMixin, type DisabledInterface } from './disabled-mixin.js'; export { ObserveSlotPresence, type SlotPresenceObservingInterface, diff --git a/2nd-gen/packages/core/overview.mdx b/2nd-gen/packages/core/overview.mdx new file mode 100644 index 00000000000..f181fbc7678 --- /dev/null +++ b/2nd-gen/packages/core/overview.mdx @@ -0,0 +1,19 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Core + +These pages document **`@spectrum-web-components/core`**: shared element primitives, mixins, utilities, controllers, and base classes used by 2nd-gen components. + +## Available documentation + +- **[Focus group navigation controller](../?path=/docs/core-focus-group-navigation-controller--readme)** — roving `tabindex`, arrow-key movement (horizontal, vertical, **both**, or grid), optional wrap/memory, prefix / typeahead focus, aligned with APG and the Open UI `focusgroup` explainer. + +Add `.mdx` pages or `stories/*.stories.ts` anywhere under `packages/core` to surface them under **Core** in Storybook. + +Import paths use the package exports, for example: + +```typescript +import { FocusgroupNavigationController } from '@spectrum-web-components/core/controllers/focus-group-navigation-controller.js'; +``` diff --git a/2nd-gen/packages/core/package.json b/2nd-gen/packages/core/package.json index 5e95c0d0282..1e83da80686 100644 --- a/2nd-gen/packages/core/package.json +++ b/2nd-gen/packages/core/package.json @@ -51,6 +51,10 @@ "types": "./dist/controllers/index.d.ts", "import": "./dist/controllers/index.js" }, + "./controllers/focus-group-navigation-controller.js": { + "types": "./dist/controllers/focus-group-navigation-controller/ocus-group-navigation-controller.d.ts", + "import": "./dist/controllers/ocus-group-navigation-controller/focus-group-navigation-controller.js" + }, "./controllers/language-resolution.js": { "types": "./dist/controllers/language-resolution.d.ts", "import": "./dist/controllers/language-resolution.js" @@ -152,6 +156,9 @@ "controllers/index.js": [ "dist/controllers/index.d.ts" ], + "controllers/ocus-group-navigation-controller/focus-group-navigation-controller.js": [ + "dist/controllers/ocus-group-navigation-controller/focus-group-navigation-controller.d.ts" + ], "controllers/language-resolution.js": [ "dist/controllers/language-resolution.d.ts" ], diff --git a/2nd-gen/packages/core/utils/focusable-selectors.ts b/2nd-gen/packages/core/utils/focusable-selectors.ts new file mode 100644 index 00000000000..08e6f43d3f7 --- /dev/null +++ b/2nd-gen/packages/core/utils/focusable-selectors.ts @@ -0,0 +1,61 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * CSS selector strings for matching focusable and tabbable DOM elements + * per the HTML specification. + * + * These selectors use standard HTML focusability rules only — the 1st-gen + * custom `[focusable]` attribute selector is intentionally not included, + * as native `delegatesFocus` replaces that workaround. + */ + +/** + * Matches elements that can receive focus programmatically (via `.focus()`). + * + * Includes elements with `tabindex="-1"` which are focusable via script + * but not reachable via the Tab key. + * + * @example + * ```typescript + * const firstFocusable = shadowRoot.querySelector(focusableSelector); + * ``` + */ +export const focusableSelector = [ + 'input:not([inert]):not([disabled])', + 'select:not([inert]):not([disabled])', + 'textarea:not([inert]):not([disabled])', + 'a[href]:not([inert])', + 'button:not([inert]):not([disabled])', + '[tabindex]:not([inert])', + 'audio[controls]:not([inert])', + 'video[controls]:not([inert])', + '[contenteditable]:not([contenteditable="false"]):not([inert])', + 'details>summary:first-of-type:not([inert])', + 'details:not([inert])', +].join(','); + +/** + * Matches elements reachable via the Tab key. + * + * This is a subset of {@link focusableSelector} that excludes elements + * with `tabindex="-1"` (which are focusable via script but not tabbable). + * + * @example + * ```typescript + * const tabbableElements = [...container.querySelectorAll(tabbableSelector)]; + * ``` + */ +export const tabbableSelector = focusableSelector + .split(',') + .map((s) => s + ':not([tabindex="-1"])') + .join(','); diff --git a/2nd-gen/packages/core/utils/get-active-element.ts b/2nd-gen/packages/core/utils/get-active-element.ts new file mode 100644 index 00000000000..088f67b9f1f --- /dev/null +++ b/2nd-gen/packages/core/utils/get-active-element.ts @@ -0,0 +1,40 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Returns the deepest focused element by traversing shadow DOM boundaries. + * + * `document.activeElement` stops at shadow hosts — this utility follows + * `shadowRoot.activeElement` chains to find the leaf-level focused element. + * + * @param root - The document or shadow root to start traversal from. + * Defaults to `document`. + * @returns The deepest focused element, or `null` if nothing is focused. + * + * @example + * ```typescript + * // Get the truly focused element across all shadow boundaries + * const active = getActiveElement(); + * + * // Start traversal from a specific root + * const active = getActiveElement(el.getRootNode() as Document); + * ``` + */ +export function getActiveElement( + root: Document | ShadowRoot = document +): HTMLElement | null { + let current = root.activeElement as HTMLElement | null; + while (current?.shadowRoot?.activeElement) { + current = current.shadowRoot.activeElement as HTMLElement; + } + return current; +} diff --git a/2nd-gen/packages/core/utils/index.ts b/2nd-gen/packages/core/utils/index.ts index 090776620c3..26867bea526 100644 --- a/2nd-gen/packages/core/utils/index.ts +++ b/2nd-gen/packages/core/utils/index.ts @@ -11,4 +11,6 @@ */ export { capitalize } from './capitalize.js'; +export { getActiveElement } from './get-active-element.js'; +export { focusableSelector, tabbableSelector } from './focusable-selectors.js'; export { getLabelFromSlot } from './get-label-from-slot.js'; diff --git a/2nd-gen/packages/swc/.storybook/main.ts b/2nd-gen/packages/swc/.storybook/main.ts index 451add679a0..594530be0db 100644 --- a/2nd-gen/packages/swc/.storybook/main.ts +++ b/2nd-gen/packages/swc/.storybook/main.ts @@ -58,6 +58,16 @@ const stories: StorybookConfig['stories'] = [ */ if (storybookMode !== 'ci-a11y') { stories.push( + { + directory: '../../core', + files: '**/*.mdx', + titlePrefix: 'Core', + }, + { + directory: '../../core', + files: '**/stories/**/*.stories.ts', + titlePrefix: 'Core', + }, { directory: 'learn-about-swc', files: '*.mdx', @@ -83,6 +93,11 @@ if (storybookMode === 'dev') { files: '**/*.test.ts', titlePrefix: 'Components', }); + stories.push({ + directory: '../../core', + files: '**/stories/**/*.test.ts', + titlePrefix: 'Core', + }); } /** diff --git a/2nd-gen/packages/swc/.storybook/preview.ts b/2nd-gen/packages/swc/.storybook/preview.ts index ea4fa60de96..13decc838d1 100644 --- a/2nd-gen/packages/swc/.storybook/preview.ts +++ b/2nd-gen/packages/swc/.storybook/preview.ts @@ -255,6 +255,7 @@ const preview = { 'Using stackblitz', '2nd-gen testing', 'Tools vs packages', + 'Focus management', ], 'Style guide', [ diff --git a/2nd-gen/packages/swc/.storybook/storybook-env.d.ts b/2nd-gen/packages/swc/.storybook/storybook-env.d.ts index 5131d3a8b69..18e826ccd58 100644 --- a/2nd-gen/packages/swc/.storybook/storybook-env.d.ts +++ b/2nd-gen/packages/swc/.storybook/storybook-env.d.ts @@ -30,6 +30,11 @@ declare module '*.css' { export default content; } +declare module '*.md?raw' { + const content: string; + export default content; +} + // exports storybook-env.d.ts as a module so declare global can augment the Window export {}; diff --git a/CONTRIBUTOR-DOCS/01_contributor-guides/13_focus-management.md b/CONTRIBUTOR-DOCS/01_contributor-guides/13_focus-management.md new file mode 100644 index 00000000000..a9130484d72 --- /dev/null +++ b/CONTRIBUTOR-DOCS/01_contributor-guides/13_focus-management.md @@ -0,0 +1,635 @@ + + +[CONTRIBUTOR-DOCS](../README.md) / [Contributor guides](README.md) / Focus management + + + +# Focus management + + + +
+In this doc + +- [Overview](#overview) +- [The three primitives](#the-three-primitives) +- [Choosing a focus strategy](#choosing-a-focus-strategy) +- [delegatesFocus: true](#delegatesfocus-true) + - [When to use](#when-to-use) + - [How to use](#how-to-use) + - [Gotchas](#gotchas) + - [CSS and focus styling](#css-and-focus-styling) + - [Common mistakes](#common-mistakes-delegatesfocus) +- [DisabledMixin](#disabledmixin) + - [When to use](#when-to-use-1) + - [How to use](#how-to-use-1) + - [Why aria-disabled](#why-aria-disabled) + - [Common mistakes](#common-mistakes-disabledmixin) +- [RovingTabindexController](#rovingtabindexcontroller) + - [When to use](#when-to-use-2) + - [How to use](#how-to-use-2) + - [Configuration options](#configuration-options) + - [Common mistakes](#common-mistakes-rovingtabindexcontroller) +- [Focus utilities](#focus-utilities) + - [getActiveElement()](#getactiveelement) + - [focusableSelector and tabbableSelector](#focusableselector-and-tabbableselector) + - [hasVisibleFocusInTree()](#hasvisiblefocusintree) +- [Migration from 1st-gen](#migration-from-1st-gen) + - [Replacing Focusable base class](#replacing-focusable-base-class) + - [Replacing focusElement getter](#replacing-focuselement-getter) + - [Replacing FocusGroupController](#replacing-focusgroupcontroller) +- [Testing focus behavior](#testing-focus-behavior) +- [Resources](#resources) + +
+ + + +## Overview + +2nd-gen Spectrum Web Components use three composable, opt-in primitives for focus management instead of the 1st-gen `Focusable` base class inheritance chain. Each component picks only what it needs: + +``` +SpectrumElement (base, no focus logic) + ├── + DisabledMixin (opt-in disabled state) + ├── + delegatesFocus: true (native browser focus delegation) + └── + RovingTabindexController (opt-in roving tabindex + arrow keys) +``` + +This guide explains when and how to use each primitive, with correct examples and common mistakes to avoid. + +> **Scope:** This guide covers core focus management for standard components. Overlay, dialog, and dropdown focus concerns (focus trapping, focus restoration, overlay stacking) are **out of scope** and will be documented when those components are migrated. + +For the full technical rationale, see the [Focus Management Proposal](../../2nd-gen/packages/core/FOCUS-MANAGEMENT-PROPOSAL.md). + +--- + +## The three primitives + +| Primitive | What it does | Import | +|-----------|-------------|--------| +| `delegatesFocus: true` | Browser-native focus delegation from host to first focusable child | Built-in (shadow root option) | +| `DisabledMixin` | Reactive `disabled` property with `aria-disabled`, tabindex, blur | `@spectrum-web-components/core/mixins` | +| `RovingTabindexController` | Arrow key navigation + tabindex management for composite widgets | `@spectrum-web-components/core/controllers` | + +--- + +## Choosing a focus strategy + +Use this decision tree for every component: + +1. **Does the component manage focus across child elements?** (e.g., tabs, radio group, menu) + - **Yes** → Use `RovingTabindexController` + +2. **Does the host element itself receive focus?** (e.g., button, menu item) + - **Yes** → Use `DisabledMixin` only. No delegation needed. + +3. **Should focus go to a single inner element?** (e.g., textfield → ``, link → ``) + - **Yes** → Use `delegatesFocus: true`. Ensure the focus target is the **first focusable element** in the shadow DOM template. + +Most components also need `DisabledMixin` regardless of which focus strategy they use. + +--- + +## delegatesFocus: true + +### When to use + +Use `delegatesFocus` when there is **exactly one place focus should ever go** inside the shadow root. This covers components like textfields, checkboxes, links, color inputs, and accordion items. + +**Do not use** when: +- The component has multiple internal focus targets (e.g., combobox, multi-handle slider) +- The component manages its own focus routing (e.g., roving tabindex groups) +- The host element itself should be the focus target + +### How to use + +```typescript +import { DisabledMixin } from '@spectrum-web-components/core/mixins'; +import { SpectrumElement } from '@spectrum-web-components/core/element'; +import { html, css } from 'lit'; + +class SpCheckbox extends DisabledMixin(SpectrumElement) { + static override shadowRootOptions = { + ...SpectrumElement.shadowRootOptions, + delegatesFocus: true, + }; + + static override styles = css` + /* Suppress host focus outline — the inner element owns the focus ring */ + :host { + outline: none; + } + /* Container-level styling when focused */ + :host(:focus-within) { + border-color: var(--spectrum-focus-indicator-color); + } + /* Keyboard focus ring on the actual interactive element */ + input:focus-visible { + outline: 2px solid var(--spectrum-focus-indicator-color); + } + `; + + override render() { + // The MUST be the first focusable element in the template + return html` + + + `; + } + + // Re-dispatch focus/blur as composed events so they cross the shadow boundary + private _handleFocus(event: FocusEvent): void { + this.dispatchEvent(new FocusEvent('focus', { + bubbles: true, + composed: true, + relatedTarget: event.relatedTarget, + })); + } + + private _handleBlur(event: FocusEvent): void { + this.dispatchEvent(new FocusEvent('blur', { + bubbles: true, + composed: true, + relatedTarget: event.relatedTarget, + })); + } +} +``` + +### Gotchas + +1. **Do not set `tabindex` on the host.** Adding `tabindex="0"` creates **two tab stops** — the host gets focus first, then the inner element. This breaks keyboard navigation entirely. + +2. **Focus/blur events do not bubble out of the shadow root.** `delegatesFocus` handles focus *routing*, not event *bubbling*. If consumers need `focus`/`blur` events, you must re-dispatch them as composed events (see example above). + +3. **The focus target must be first.** The browser always delegates to the **first focusable element** in the shadow DOM. If your template puts decorative elements or other controls before the primary interactive element, focus will land in the wrong place. Restructure the template if needed. + +4. **`:focus` on the host is a pseudo-class match, not actual focus.** The host matches `:focus` for CSS styling when an inner element is focused, but `document.activeElement` still points to the host. Use `shadowRoot.activeElement` or `getActiveElement()` to find the real focused element. + +### CSS and focus styling + +| Selector | Where to use | Purpose | +|----------|-------------|---------| +| `:host(:focus-within)` | Host styles | Container-level styling when any descendant is focused | +| `:host(:focus)` | Host styles | Matches when inner element is focused (via delegation) | +| `input:focus-visible` | Shadow styles | Keyboard focus ring on the actual interactive element | +| `:host { outline: none; }` | Host styles | Suppress the default host focus outline to avoid double rings | + +### Common mistakes (delegatesFocus) + +```typescript +// BAD: tabindex on the host creates a double tab stop +class SpTextfield extends DisabledMixin(SpectrumElement) { + static override shadowRootOptions = { + ...SpectrumElement.shadowRootOptions, + delegatesFocus: true, + }; + connectedCallback() { + super.connectedCallback(); + this.tabIndex = 0; // WRONG — creates two tab stops + } +} +``` + +```typescript +// BAD: focus target is NOT the first focusable element +class SpTextfield extends DisabledMixin(SpectrumElement) { + static override shadowRootOptions = { + ...SpectrumElement.shadowRootOptions, + delegatesFocus: true, + }; + override render() { + return html` + + + `; + } +} +``` + +```typescript +// BAD: using delegatesFocus when the host should receive focus directly +class SpButton extends DisabledMixin(SpectrumElement) { + static override shadowRootOptions = { + ...SpectrumElement.shadowRootOptions, + delegatesFocus: true, // WRONG — the host IS the interactive element + }; +} + +// GOOD: host receives focus directly, no delegation needed +class SpButton extends DisabledMixin(SpectrumElement) { + // No delegatesFocus — the host is the focus target +} +``` + +--- + +## DisabledMixin + +### When to use + +Any interactive component that can be disabled — buttons, inputs, links, menu items, sliders, etc. Most components that use `delegatesFocus` or `RovingTabindexController` will also use `DisabledMixin`. + +### How to use + +```typescript +import { DisabledMixin } from '@spectrum-web-components/core/mixins'; +import { SpectrumElement } from '@spectrum-web-components/core/element'; +import { html } from 'lit'; + +class SpButton extends DisabledMixin(SpectrumElement) { + override render() { + return html` + + + `; + } +} +``` + +**What it does automatically:** +- Adds `disabled` as a reflected boolean property +- Sets `aria-disabled="true"` on the host when disabled +- Sets `tabindex="-1"` when disabled (preserves and restores the previous value) +- Blurs the element if it has focus when disabled +- Applies side effects in `update()` (before render) to prevent a 1-frame focusable gap + +### Why aria-disabled + +The mixin uses `aria-disabled` on the host instead of the native `disabled` attribute. This is intentional: + +| | `disabled` | `aria-disabled` | +|--|-----------|-----------------| +| Focusable? | No — removed from tab order | Yes — remains keyboard-accessible | +| Click events? | Blocked by browser | Still fire (guard in handler) | +| Screen readers | Disabled + undiscoverable | Disabled but still discoverable | + +Custom elements are not native form controls — the browser's `disabled` attribute has no built-in effect on a custom element host. Using `aria-disabled` keeps the element discoverable by assistive technology, which is generally better UX. + +**Components wrapping native form controls** (textfield, checkbox, etc.) should **also** set `disabled` on the inner element in `render()` to get correct platform behavior. + +For the full rationale, see [On disabled and aria-disabled attributes](https://kittygiraudel.com/2024/03/29/on-disabled-and-aria-disabled-attributes/). + +### Common mistakes (DisabledMixin) + +```typescript +// BAD: not setting disabled on the inner native element +class SpTextfield extends DisabledMixin(SpectrumElement) { + static override shadowRootOptions = { + ...SpectrumElement.shadowRootOptions, + delegatesFocus: true, + }; + override render() { + // The inner is still interactive even when host is "disabled" + return html``; // WRONG — missing ?disabled=${this.disabled} + } +} + +// GOOD: propagate disabled to the inner element +class SpTextfield extends DisabledMixin(SpectrumElement) { + static override shadowRootOptions = { + ...SpectrumElement.shadowRootOptions, + delegatesFocus: true, + }; + override render() { + return html``; + } +} +``` + +```typescript +// BAD: using updated() instead of update() for disabled side effects +class SpButton extends DisabledMixin(SpectrumElement) { + override updated(changed: PropertyValues) { + super.updated(changed); + // WRONG — this runs AFTER render, leaving a 1-frame gap where + // the element is visually enabled but behaviorally disabled + if (changed.has('disabled') && this.disabled) { + this.style.pointerEvents = 'none'; + } + } +} +``` + +```typescript +// BAD: not guarding click handlers when disabled +class SpButton extends DisabledMixin(SpectrumElement) { + private handleClick() { + // WRONG — aria-disabled doesn't block click events! + this.dispatchEvent(new Event('action')); + } + + // GOOD — guard against clicks when disabled + private handleClick() { + if (this.disabled) return; + this.dispatchEvent(new Event('action')); + } +} +``` + +--- + +## RovingTabindexController + +### When to use + +Composite widgets that should appear as a **single tab stop** with arrow key navigation between children. This follows WAI-ARIA patterns for: +- Tablists (``) +- Toolbars / action groups (``) +- Radio groups (``) +- Menus (``) +- Listboxes, grids, tree views + +### How to use + +```typescript +import { RovingTabindexController } from '@spectrum-web-components/core/controllers'; +import { SpectrumElement } from '@spectrum-web-components/core/element'; + +class SpTabs extends SpectrumElement { + private rovingTabindex = new RovingTabindexController(this, { + elements: () => [...this.querySelectorAll('sp-tab')] as Tab[], + direction: 'horizontal', + isFocusableElement: (tab) => !tab.disabled, + // Auto-select tab on arrow key navigation + elementEnterAction: (tab) => { + if (this.auto) { + this.selectTab(tab); + } + }, + // Return to selected tab (or first enabled tab) when re-entering + focusInIndex: (tabs) => { + const selectedIndex = tabs.findIndex((t) => t.selected); + return selectedIndex >= 0 ? selectedIndex : 0; + }, + }); + + // Expose the focus-in element for external focus management + get focusElement(): Tab { + return this.rovingTabindex.focusInElement; + } +} +``` + +**How it works at runtime:** + +``` +Tab into group → focus lands on element with tabindex="0": + [ A: 0 ] [ B: -1 ] [ C: -1 ] [ D: -1 ] + +Arrow Right → tabindex swaps, focus moves to B: + [ A: -1 ] [ B: 0 ] [ C: -1 ] [ D: -1 ] + +Tab out, then Tab back in → returns to B (remembered): + [ A: -1 ] [ B: 0 ] [ C: -1 ] [ D: -1 ] +``` + +### Configuration options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `elements` | `() => T[]` | **(required)** | Returns the current list of focusable elements | +| `direction` | `DirectionTypes \| () => DirectionTypes` | `'both'` | `'horizontal'`, `'vertical'`, `'both'`, or `'grid'` | +| `focusInIndex` | `number \| (elements: T[]) => number` | `0` | Which element to focus when entering the group | +| `isFocusableElement` | `(el: T) => boolean` | `() => true` | Filter non-focusable/disabled elements | +| `elementEnterAction` | `(el: T) => void` | no-op | Callback before focusing an element (e.g., auto-select) | +| `stopKeyEventPropagation` | `boolean` | `false` | Stop arrow key events from propagating | +| `listenerScope` | `HTMLElement \| () => HTMLElement` | `host.renderRoot` | Scope element for event listeners | +| `hostDelegatesFocus` | `boolean` | `false` | Set `true` if host also uses `delegatesFocus` | +| `directionLength` | `number` | `1` | Items per row in grid mode (required for `'grid'` direction) | + +### Common mistakes (RovingTabindexController) + +```typescript +// BAD: elements() returning a static array that goes stale +class SpTabs extends SpectrumElement { + private tabs = [...this.querySelectorAll('sp-tab')]; // Captured once at construction + + private rovingTabindex = new RovingTabindexController(this, { + elements: () => this.tabs, // WRONG — won't reflect DOM changes + }); +} + +// GOOD: elements() queries live DOM every time +class SpTabs extends SpectrumElement { + private rovingTabindex = new RovingTabindexController(this, { + elements: () => [...this.querySelectorAll('sp-tab')] as Tab[], + }); +} +``` + +```typescript +// BAD: forgetting isFocusableElement — arrow keys land on disabled items +class SpMenu extends SpectrumElement { + private rovingTabindex = new RovingTabindexController(this, { + elements: () => [...this.querySelectorAll('sp-menu-item')] as MenuItem[], + direction: 'vertical', + // WRONG — disabled items will receive focus via arrow keys + }); +} + +// GOOD: skip disabled elements +class SpMenu extends SpectrumElement { + private rovingTabindex = new RovingTabindexController(this, { + elements: () => [...this.querySelectorAll('sp-menu-item')] as MenuItem[], + direction: 'vertical', + isFocusableElement: (item) => !item.disabled, + }); +} +``` + +```typescript +// BAD: using delegatesFocus with RovingTabindexController without telling it +class SpActionGroup extends SpectrumElement { + static override shadowRootOptions = { + ...SpectrumElement.shadowRootOptions, + delegatesFocus: true, + }; + private rovingTabindex = new RovingTabindexController(this, { + elements: () => [...this.querySelectorAll('sp-action-button')] as ActionButton[], + // WRONG — controller doesn't know about delegatesFocus, tabindex conflicts + }); +} + +// GOOD: tell the controller about delegatesFocus +class SpActionGroup extends SpectrumElement { + static override shadowRootOptions = { + ...SpectrumElement.shadowRootOptions, + delegatesFocus: true, + }; + private rovingTabindex = new RovingTabindexController(this, { + elements: () => [...this.querySelectorAll('sp-action-button')] as ActionButton[], + hostDelegatesFocus: true, // Coordinates tabindex management with delegation + }); +} +``` + +```typescript +// BAD: not calling clearElementCache() when items change dynamically +class SpMenu extends SpectrumElement { + addItem(item: MenuItem) { + this.appendChild(item); + // WRONG — controller still has the old cached element list + } +} + +// GOOD: invalidate the cache after DOM mutations +class SpMenu extends SpectrumElement { + addItem(item: MenuItem) { + this.appendChild(item); + this.rovingTabindex.clearElementCache(); + } +} +``` + +--- + +## Focus utilities + +### getActiveElement() + +Returns the deepest focused element by traversing shadow DOM boundaries. `document.activeElement` stops at shadow hosts — this follows the chain. + +```typescript +import { getActiveElement } from '@spectrum-web-components/core/utils'; + +// Get the truly focused element across all shadow boundaries +const active = getActiveElement(); + +// Start from a specific root +const active = getActiveElement(this.getRootNode() as Document); +``` + +### focusableSelector and tabbableSelector + +CSS selector strings matching focusable and tabbable elements per the HTML spec. + +```typescript +import { focusableSelector, tabbableSelector } from '@spectrum-web-components/core/utils'; + +// Find the first focusable element in a container +const first = container.querySelector(focusableSelector); + +// Find all tabbable elements (excludes tabindex="-1") +const tabbable = [...container.querySelectorAll(tabbableSelector)]; +``` + +These use standard HTML focusability rules only. The 1st-gen `[focusable]` attribute selector is not included — native `delegatesFocus` replaces that workaround. + +### hasVisibleFocusInTree() + +Available on all `SpectrumElement` subclasses. Returns `true` if the deepest focused element in the current tree matches `:focus-visible` — i.e., the browser would show a focus ring. + +```typescript +class SpButton extends SpectrumElement { + private handleFocus() { + if (this.hasVisibleFocusInTree()) { + // Keyboard focus — show custom focus indicator + } + } +} +``` + +--- + +## Migration from 1st-gen + +### Replacing Focusable base class + +```typescript +// 1st-gen +import { Focusable } from '@spectrum-web-components/shared'; + +class SpTextfield extends Focusable { + get focusElement() { + return this.shadowRoot.querySelector('input'); + } +} + +// 2nd-gen +import { DisabledMixin } from '@spectrum-web-components/core/mixins'; +import { SpectrumElement } from '@spectrum-web-components/core/element'; + +class SpTextfield extends DisabledMixin(SpectrumElement) { + static override shadowRootOptions = { + ...SpectrumElement.shadowRootOptions, + delegatesFocus: true, + }; + // No focusElement getter needed — browser handles it +} +``` + +### Replacing focusElement getter + +The `focusElement` getter is no longer needed. `delegatesFocus: true` automatically delegates to the first focusable child. Make sure the focus target is first in the template: + +```typescript +// 1st-gen: explicit focusElement +get focusElement() { + return this.shadowRoot.querySelector('#inner-input'); +} + +// 2nd-gen: template order handles it +override render() { + return html` + + + `; +} +``` + +### Replacing FocusGroupController + +`FocusGroupController` no longer exists as a separate class. Its logic is consolidated into `RovingTabindexController`. If you were using `FocusGroupController` directly (only Accordion did this in 1st-gen), switch to `RovingTabindexController`: + +```typescript +// 1st-gen +import { FocusGroupController } from '@spectrum-web-components/reactive-controllers'; + +// 2nd-gen +import { RovingTabindexController } from '@spectrum-web-components/core/controllers'; +// Same API — just a different import and class name +``` + +--- + +## Testing focus behavior + +When submitting a PR that affects focus management, you must verify: + +**Keyboard testing:** +- [ ] Tab key moves focus into and out of the component correctly (single tab stop for roving groups) +- [ ] Arrow keys navigate within composite widgets in the expected direction +- [ ] Home/End jump to first/last item +- [ ] Disabled items are skipped during arrow key navigation +- [ ] Focus returns to the last-focused item when re-entering a roving group +- [ ] Focus ring is visible on keyboard focus (`:focus-visible`) +- [ ] No focus ring on mouse click + +**Screen reader testing:** +- [ ] Component role and name are announced correctly +- [ ] Disabled state is announced (`aria-disabled`) +- [ ] Focus delegation announces the inner element, not the host + +**Automated testing:** +- Write interaction tests using Storybook play functions to verify Tab, Arrow, Home/End behavior +- Include accessibility tests that validate ARIA snapshots + +--- + +## Resources + +- [Focus Management Proposal](../../2nd-gen/packages/core/FOCUS-MANAGEMENT-PROPOSAL.md) — Full technical rationale +- [WAI-ARIA Roving Tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex) — The pattern `RovingTabindexController` implements +- [Shadow DOM delegatesFocus](https://frontendmasters.com/blog/shadow-dom-focus-delegation-getting-delegatesfocus-right/) — Implementation deep-dive +- [On disabled and aria-disabled](https://kittygiraudel.com/2024/03/29/on-disabled-and-aria-disabled-attributes/) — Why `DisabledMixin` uses `aria-disabled` +- [Accessibility Testing Guide](09_accessibility-testing.md) — Automated and manual a11y testing diff --git a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md index 8eb267dc00b..9a33804bc21 100644 --- a/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md +++ b/CONTRIBUTOR-DOCS/02_style-guide/02_typescript/14_controller-composition.md @@ -15,6 +15,7 @@ - [How controllers work](#how-controllers-work) - [Available controllers](#available-controllers) - [Planned controllers](#planned-controllers) +- [FocusgroupNavigationController](#focusgroupnavigationcontroller) - [LanguageResolutionController](#languageresolutioncontroller) - [Using a controller in a component](#using-a-controller-in-a-component) - [Controller vs mixin](#controller-vs-mixin) @@ -60,6 +61,7 @@ To attach a controller, call `host.addController(this)` in the constructor. This | Controller | Location | Purpose | |-----------|----------|---------| +| `FocusgroupNavigationController` | `core/controllers/focus-group-navigation-controller.ts` | Roving tabindex and directional keyboard navigation for composites | | `LanguageResolutionController` | `core/controllers/language-resolution.ts` | Resolve locale for formatting | ## Planned controllers @@ -68,7 +70,7 @@ The following controllers exist in 1st-gen and may be recreated in 2nd-gen core | Controller | 1st-gen location | Purpose | |-----------|-----------------|---------| -| `RovingTabindexController` | `1st-gen/packages/shared/` | Keyboard navigation | +| `RovingTabindexController` | `1st-gen/packages/shared/` | Keyboard navigation (see `FocusgroupNavigationController` in 2nd-gen for a related pattern) | | `PlacementController` | `1st-gen/packages/overlay/` | Overlay positioning | | `MatchMediaController` | `1st-gen/packages/picker/` | Device-adaptive behavior | | `PendingStateController` | `1st-gen/packages/button/` | Loading states | @@ -79,9 +81,26 @@ The following controllers exist in 1st-gen and may be recreated in 2nd-gen core | `ColorController` | `1st-gen/tools/reactive-controllers/` | Color validation/conversion | | `GridController` | `1st-gen/tools/grid/` | Grid layout with virtual scrolling | +## FocusgroupNavigationController + +**File:** `core/controllers/focus-group-navigation-controller.ts` + +**What it does:** + +1. Collapses roving `tabindex` to one tab stop in a composite (`tabindex="0"` on the active item, `-1` on others it manages). +2. Handles Arrow keys, Home, and End for horizontal, vertical, **`both`** (horizontal and vertical arrows on the same linear order), or **grid** layouts; optional **`pageStep`** enables Page Up / Page Down by that many items (linear) or rows (**grid**); in **grid** mode, Ctrl+Home / Ctrl+End jump to the first cell of the first row or the last cell of the last row. +3. Optional **`skipDisabled`**: omit native **`disabled`** and **`aria-disabled="true"`** items from roving tabindex and arrow navigation (story **Skip disabled menu**). +4. Optionally wraps at ends and remembers the last focused item for Tab re-entry (similar to Open UI `focusgroup` semantics). + +**Public API:** `setOptions`, `getActiveItem`, `refresh`, `setActiveItem` (roving `tabindex` only — call `getActiveItem()?.focus()` to move focus), `focusFirstItemByTextPrefix` (typeahead label match for roving `tabindex` only — same follow-up), plus `hostConnected` / `hostDisconnected` via `ReactiveController`. + +**Events:** Dispatches `swc-focusgroup-navigation-active-change` when the active item changes. + +**Docs:** See `core/controllers/focus-group-navigation-demos/focus-group-navigation-controller.md` and Storybook **Core / Focus group navigation controller**. + ## LanguageResolutionController -The main controller currently in 2nd-gen is `LanguageResolutionController`. It resolves the component's language/locale for formatting numbers, dates, and accessibility text. +The main controller for locale in 2nd-gen is `LanguageResolutionController`. It resolves the component's language/locale for formatting numbers, dates, and accessibility text. **File:** `core/controllers/language-resolution.ts`