import { Canvas, Meta } from '@storybook/addon-docs/blocks'; import { DocsFooter, DocsHeader } from '../../../swc/.storybook/blocks';
import * as FocusgroupNavigationControllerStories from './stories/focusgroup-navigation-controller.stories';
- Collapses the tab sequence to one tab stop by setting
tabindex="0"on the active item andtabindex="-1"on all others. - Arrow keys move focus according to
direction: horizontal (inline axis), vertical (block axis), both (all four arrows on one linear order), or grid (rows and columns from layout). - Home / End jump to the first or last item (row-major order for
grid). - Ctrl+Home / Ctrl+End (
gridonly) jump to the first cell of the first row or the last cell of the last row. - Page Up / Page Down move
pageStepitems (linear modes) or rows (grid) whenpageStepis set.
wrap: end wraps to start (and vice versa), similar towrapconcepts in thefocusgroupproposal.memory: Tab returns to the last focused item instead of resetting, similar to thenomemoryconcepts in thefocusgroupproposal.skipDisabled: whentrue, elements withdisabledoraria-disabled="true"are excluded from the roving tab stop and arrow navigation.pageStep: non-zero positive integer enables Page Up / Page Down movement.
setActiveItem(element): sets rovingtabindexto a chosen eligible item (does not callfocus(); callgetActiveItem()?.focus()afterward).focusFirstItemByTextPrefix(prefix): sets rovingtabindexto the first eligible item whose label starts withprefix(case-insensitive). Does not callfocus().
- Construct the controller in your element's
constructor, passinggetItemsanddirection. - Ensure
getItemsreturns liveHTMLElementreferences (for example fromthis.renderRootor slotted content). - After the first render, if items live in shadow DOM, call
refresh()fromfirstUpdated(or after slotting) so roving tabindex can run once nodes exist. - Provide appropriate roles and labels on the host and items (the controller does not set ARIA roles).
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
import { FocusgroupNavigationController } from '@spectrum-web-components/core/controllers/focusgroup-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<HTMLElement>('button')),
});
protected override firstUpdated(): void {
super.firstUpdated();
this.navigation.refresh();
}
protected override render() {
return html`
<button type="button">Bold</button>
<button type="button">Italic</button>
<button type="button">Underline</button>
`;
}
}Use direction: 'horizontal' for inline-axis arrow navigation. ArrowLeft and ArrowRight move between controls (respecting dir for RTL); Tab yields one stop for the entire group.
this.navigation = new FocusgroupNavigationController(this, {
direction: 'horizontal',
wrap: true,
getItems: () =>
Array.from(this.renderRoot.querySelectorAll<HTMLElement>('button')),
});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.
this.navigation = new FocusgroupNavigationController(this, {
direction: 'both',
wrap: true,
getItems: () =>
Array.from(this.renderRoot.querySelectorAll<HTMLElement>('button')),
});Use direction: 'vertical' for block-axis arrow navigation in menus and lists. ArrowDown / ArrowUp traverse items; Page Up / Page Down skip multiple items when pageStep is set (in this demo, pageStep: 2).
One control uses aria-disabled="true" instead of native disabled so it stays focusable while arrow keys move through the list: native disabled removes focusability and would block reaching items after it.
this.navigation = new FocusgroupNavigationController(this, {
direction: 'vertical',
wrap: true,
pageStep: 2,
skipDisabled: false,
getItems: () =>
Array.from(this.renderRoot.querySelectorAll<HTMLElement>('button')),
});With skipDisabled: true, items stay in the DOM (for layout or screen-reader context), but both native disabled and aria-disabled="true" items are removed from the roving tab stop and from arrow movement. In this demo, Save (disabled) and Close (aria-disabled="true") are skipped; the arrow sequence is New → Open → Print → Help.
this.navigation = new FocusgroupNavigationController(this, {
direction: 'vertical',
wrap: true,
skipDisabled: true,
getItems: () =>
Array.from(this.renderRoot.querySelectorAll<HTMLElement>('button')),
});<button type="button">New</button>
<button type="button">Open</button>
<button type="button" disabled>Save</button>
<button type="button">Print</button>
<button type="button" aria-disabled="true">Close</button>
<button type="button">Help</button>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.
- Page Up / Page Down move
pageSteprows at a time (in this demo,pageStep: 2); the focused column index is clamped when a row has fewer cells (same rule as ArrowUp / ArrowDown).
this.navigation = new FocusgroupNavigationController(this, {
direction: 'grid',
wrap: false,
pageStep: 2,
getItems: () =>
Array.from(this.renderRoot.querySelectorAll<HTMLElement>('.grid button')),
});setActiveItem(element) updates roving tabindex to a chosen eligible item only; it does not call focus(). Returns false if the element is not eligible (not in getItems() or skipped by skipDisabled). Call getActiveItem()?.focus() afterward to move focus.
When invoked from a trigger click, defer focus() with queueMicrotask so the browser does not move focus back to the clicked element after your handler returns:
const el = this.renderRoot.querySelector<HTMLElement>('[data-item="c"]');
if (el && this.navigation.setActiveItem(el)) {
queueMicrotask(() => {
el.focus();
});
}focusFirstItemByTextPrefix(prefix) updates roving tabindex to the first eligible item whose typeahead label starts with prefix (case-insensitive), in getItems() order. Matching uses each item's typeahead label: trimmed aria-label if set, otherwise text from aria-labelledby references (in order), otherwise trimmed textContent. Only eligible items are considered (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.
// Example: after the user types into your menu search buffer `buffer`
if (this.navigation.focusFirstItemByTextPrefix(buffer)) {
queueMicrotask(() => {
this.navigation.getActiveItem()?.focus();
});
}The FocusgroupNavigationController implements several accessibility features:
Collapses a composite widget to a single Tab stop by managing tabindex="0" on the active item and tabindex="-1" on all others. This follows the APG roving tabindex pattern.
- ArrowLeft / ArrowRight: move focus in horizontal and both modes
- ArrowUp / ArrowDown: move focus in vertical, both, and grid modes
- Home: jump to first item (row-major order for grid)
- End: jump to last item (row-major order for grid)
- Ctrl+Home: first cell of first row (grid only)
- Ctrl+End: last cell of last row (grid only)
- Page Up / Page Down: move
pageStepitems or rows
- With
skipDisabled: false(default): disabled items remain focusable so keyboard users can discover them - With
skipDisabled: true: disabled items are excluded from navigation entirely
- For
horizontal, ArrowLeft / ArrowRight follow the host's resolveddir(rtlswaps forward/back). - For
both, ArrowLeft / ArrowRight followdirthe same way, while ArrowUp / ArrowDown always step backward / forward ingetItems()order. - In
gridmode, vertical movement uses row geometry; column movement respectsdirfor left/right.
- Always provide appropriate ARIA
roleon the host (toolbar,menu,grid,listbox, etc.): the controller does not set roles - Always provide
aria-labeloraria-labelledbyon the host element - Use
aria-disabled="true"instead of nativedisabledwhen items should remain focusable for discoverability - Use
skipDisabled: trueonly when disabled items should be completely unreachable via keyboard - Call
refresh()after any DOM change that adds or removes items from the group
| 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 (does not call focus()). Returns false if ineligible. |
focusFirstItemByTextPrefix(prefix) |
Set roving tabindex to the first eligible item matching prefix (case-insensitive). Does not call focus(). Returns false if no match. |
getActiveItem() |
Returns the eligible item with tabindex="0", if any. |
The controller dispatches swc-focusgroup-navigation-active-change (focusgroupNavigationActiveChange) on the host with detail: { activeElement } when the active item changes. The event bubbles and is composed.
import { focusgroupNavigationActiveChange } from '@spectrum-web-components/core/controllers/focusgroup-navigation-controller.js';
host.addEventListener(focusgroupNavigationActiveChange, (event) => {
console.log('Active item:', event.detail.activeElement);
});| 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). 0 / omitted / non-finite: disabled. |
onActiveItemChange |
(el) => void |
— | Callback when active item changes. |
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.