useListNavigation#
useListNavigation is the keyboard navigation binding the Menu
and Select components use. Given a container of items,
it handles ArrowUp / ArrowDown, Home / End, and typeahead, tracking which item is
active and moving the active state through the list (wrapping at the ends, skipping
disabled items).
It supports two modes. In roving mode it moves DOM focus to the active item (a
roving tabindex list, as in a menu). In activedescendant mode focus stays on a
container element and you render aria-activedescendant pointing at the active
item, scrolling it into view rather than focusing it (as in a listbox where the
trigger keeps focus).
Demo#
import { useListNavigation } from 'hono-preact-ui';
import { useId, useRef, useState } from 'preact/hooks';
const OPTIONS = ['Red', 'Orange', 'Yellow', 'Green', 'Blue', 'Violet'];
// An activedescendant listbox: the trigger keeps DOM focus while ArrowUp/Down,
// Home/End, and typeahead move aria-activedescendant over the options (wrapping
// at the ends, scrolling into view). Styling: .docs-listnav* in root.css.
export function UseListNavigationDemo() {
const listRef = useRef<HTMLDivElement>(null);
const [activeId, setActiveId] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const baseId = useId();
const listId = `${baseId}-list`;
const nav = useListNavigation({
enabled: open,
containerRef: listRef,
itemSelector: '[role="option"]',
activeId,
setActiveId,
mode: 'activedescendant',
});
return (
<div class="docs-listnav">
<button
type="button"
role="combobox"
aria-expanded={open}
aria-controls={listId}
aria-activedescendant={open ? (activeId ?? undefined) : undefined}
class="docs-listnav-trigger"
onClick={() => setOpen((o) => !o)}
onKeyDown={(e) => {
if (open) nav.onKeyDown(e);
}}
>
{open ? 'Arrow / Home / End / type a letter' : 'Open list'}
</button>
<div
ref={listRef}
id={listId}
role="listbox"
class="docs-listnav-list"
hidden={!open}
>
{OPTIONS.map((opt) => {
const id = `${baseId}-${opt}`;
return (
<div
key={opt}
id={id}
role="option"
aria-selected={activeId === id}
data-active={activeId === id ? '' : undefined}
class="docs-listnav-option"
>
{opt}
</div>
);
})}
</div>
</div>
);
}
Example#
A listbox where the trigger keeps focus and the active option is tracked with
aria-activedescendant:
import { useListNavigation } from 'hono-preact-ui';
import { useRef, useState } from 'preact/hooks';
function Listbox({ open }: { open: boolean }) {
const listRef = useRef<HTMLDivElement>(null);
const [activeId, setActiveId] = useState<string | null>(null);
const nav = useListNavigation({
enabled: open,
containerRef: listRef,
itemSelector: '[role="option"]:not([aria-disabled="true"])',
activeId,
setActiveId,
mode: 'activedescendant',
});
return (
<button
role="combobox"
aria-expanded={open}
aria-activedescendant={open ? (activeId ?? undefined) : undefined}
onKeyDown={nav.onKeyDown}
>
<div ref={listRef} role="listbox">
{/* role="option" rows */}
</div>
</button>
);
}
Signature#
import { useListNavigation } from 'hono-preact-ui';
function useListNavigation(opts: UseListNavigationOptions): ListNavigation;
interface UseListNavigationOptions {
enabled: boolean;
containerRef: RefObject<HTMLElement>;
itemSelector: string;
activeId: string | null;
setActiveId: (id: string | null) => void;
mode: 'roving' | 'activedescendant';
loop?: boolean; // default true
typeahead?: boolean; // default true
homeEnd?: boolean; // default true
scopeSelector?: string;
}
interface ListNavigation {
onKeyDown: (event: KeyboardEvent) => void;
getItems: () => HTMLElement[];
setActiveItem: (index: number) => void;
}
Options#
| Option | Type | Default | Notes |
|---|---|---|---|
enabled | boolean | none | Handle keys only while active (typically the open state). |
containerRef | RefObject<HTMLElement> | none | The element holding the items. |
itemSelector | string | none | CSS selector matching the navigable items (exclude disabled items here). |
activeId | string | null | none | The id of the active item; drives aria-activedescendant. |
setActiveId | (id: string | null) => void | none | Called to change the active item. |
mode | 'roving' | 'activedescendant' | none | roving moves DOM focus; activedescendant keeps focus and scrolls into view. |
loop | boolean | true | Wrap arrow navigation at the first / last item. |
typeahead | boolean | true | Activate items by typing their leading characters. |
homeEnd | boolean | true | Handle Home/End (default true); pass false to leave them as native caret movement (Combobox uses this). |
scopeSelector | string | none | Exclude items nested in a closer same-role container (a submenu). |
Returns#
| Field | Type | Description |
|---|---|---|
onKeyDown | (event: KeyboardEvent) => void | Handle navigation keys; calls preventDefault on keys it consumes. |
getItems | () => HTMLElement[] | The current enabled items, queried live from the DOM in order. |
setActiveItem | (index: number) => void | Activate the item at index: sets the active id and focuses or scrolls it per mode. |
onKeyDown calls event.preventDefault() on the keys it handles, so a caller
that adds its own keys (Enter to select, Escape to close) can early-return on
event.defaultPrevented after delegating.
Companion exports#
hono-preact-ui also exports useTypeahead, the type-to-select hook
useListNavigation uses internally, for composing your own navigation:
| Export | Type | Description |
|---|---|---|
useTypeahead | (opts?: UseTypeaheadOptions) => (char: string) => string | A hook returning a callback that accumulates printable chars into a query, resetting after an idle gap. |