hono-preact

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).

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

OptionTypeDefaultNotes
enabledbooleannoneHandle keys only while active (typically the open state).
containerRefRefObject<HTMLElement>noneThe element holding the items.
itemSelectorstringnoneCSS selector matching the navigable items (exclude disabled items here).
activeIdstring | nullnoneThe id of the active item; drives aria-activedescendant.
setActiveId(id: string | null) => voidnoneCalled to change the active item.
mode'roving' | 'activedescendant'noneroving moves DOM focus; activedescendant keeps focus and scrolls into view.
loopbooleantrueWrap arrow navigation at the first / last item.
typeaheadbooleantrueActivate items by typing their leading characters.
homeEndbooleantrueHandle Home/End (default true); pass false to leave them as native caret movement (Combobox uses this).
scopeSelectorstringnoneExclude items nested in a closer same-role container (a submenu).

Returns

FieldTypeDescription
onKeyDown(event: KeyboardEvent) => voidHandle navigation keys; calls preventDefault on keys it consumes.
getItems() => HTMLElement[]The current enabled items, queried live from the DOM in order.
setActiveItem(index: number) => voidActivate 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.

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>
  );
}

Companion exports

@hono-preact/ui also exports the pieces useListNavigation is built from, for composing your own navigation:

ExportTypeDescription
useTypeahead(opts?: UseTypeaheadOptions) => (char: string) => stringA hook returning a callback that accumulates printable chars into a query, resetting after an idle gap.
wrapNext(current: number, length: number, loop: boolean) => numberNext index, wrapping or clamping at the end.
wrapPrev(current: number, length: number, loop: boolean) => numberPrevious index, wrapping or clamping at the start.
matchTypeahead(labels: string[], query: string, fromIndex: number) => numberThe next index (circularly) whose label starts with the query, or -1.
getItems(container: HTMLElement, selector: string, scopeSelector?: string) => HTMLElement[]The enabled items in a container in DOM order.