Manages keyboard navigation within a linear list following WAI-ARIA menu/listbox/toolbar patterns. Supports arrow key navigation (vertical, horizontal, or both), Home/End for boundaries, optional wrap-around, RTL, and Escape to close. Opt into hasRovingTabIndex for composite widgets (toolbars, segmented controls, tab strips) that own a single tab stop. Suitable for dropdown menus, toolbars, and any 1D focusable list.
tsimport {useListFocus} from '@astryxdesign/core/hooks'
| Guidance | Practices |
|---|---|
| Do | Set orientation to 'horizontal' for toolbars and tab bars, 'vertical' for dropdown menus. |
| Do | Provide an onEscape callback for menus/dropdowns to return focus to the trigger. |
| Do | Enable hasRovingTabIndex (and hasCaretGuard when the widget can contain text inputs) for toolbar-style composites that should be a single tab stop. |
| Don't | Use for 2D grid navigation; prefer useGridFocus for grids and calendars. |
| Param | Type | Description |
|---|---|---|
options | UseListFocusOptions | Configuration object for list focus behavior. All fields are optional. |
options.itemSelector | string (default: '[role="menuitem"]') | Selector for focusable items within the list. |
options.wrap | boolean (default: true) | Whether arrow navigation wraps around at the ends. |
options.onEscape | () => void | Callback when Escape key is pressed (e.g., close menu). |
options.orientation | 'horizontal' | 'vertical' | 'both' (default: 'vertical') | Navigation orientation. 'horizontal' uses ArrowLeft/ArrowRight, 'vertical' uses ArrowUp/ArrowDown, 'both' accepts all four arrows. |
options.hasHomeEnd | boolean (default: true) | Whether Home/End jump to the first/last enabled item. |
options.isRtl | boolean (default: false) | Whether the list is in a right-to-left context. When true, ArrowLeft/ArrowRight are swapped for horizontal navigation so it follows visual direction. |
options.hasRovingTabIndex | boolean (default: false) | Opt into roving-tabindex ownership: the hook stamps a single tab stop (one item tabindex="0", the rest -1), repairs it as items mount/unmount or toggle disabled, and moves it with arrow navigation. When false, the hook only moves focus and never touches tabindex. |
options.hasCaretGuard | boolean (default: false) | When true, arrow keys are not stolen from a nested text input/textarea whose caret is not at the boundary in the direction of travel (or that has a selection), and are never stolen from a nested contenteditable (rich-text editor / chat composer). Preserves inline text editing within the list. |
| Field | Type | Description |
|---|---|---|
| listRef | React.RefObject<HTMLElement | null> | Ref to attach to the list container element. |
| handleKeyDown | (e: React.KeyboardEvent) => void | Key down handler to attach to the list container. |
| handleFocus | (e: React.FocusEvent) => void | Focus handler for the container. Keeps the roving tab stop in sync when hasRovingTabIndex is enabled; a no-op otherwise, so it is always safe to attach. |
| focusItem | (index: number) => void | Focus a specific item by index (clamped to valid range). |
| focusFirst | () => boolean | Focus the first enabled item. Returns true when an item was focused. |
| focusLast | () => boolean | Focus the last enabled item. Returns true when an item was focused. |