Listbox

Build a single-select listbox with keyboard navigation, typeahead, roving tabindex and explicit selection, while your widget supplies the ARIA roles.

This single-select listbox combines navigation, roving tabindex and typeahead. The widget supplies ARIA roles, selection and click handling.

Tab into it and arrow around, type a first letter to jump, then press Enter to pick. Space also selects when typeahead is inactive; wait 500 ms after typing to use it. Clicking picks too. Tab away and back, and focus returns to where you left it.

Live · assignee
  • Ada Lovelace
  • Alan Turing
  • Grace Hopper
  • Katherine Johnson
  • Margaret Hamilton
  • Barbara Liskov
<ul id="assignee" role="listbox" aria-label="Assignee">
  <li
    role="option"
    aria-selected="true"
    data-keyrove-item
    data-keyrove-roving-tabindex
    tabindex="0"
  >
    Ada Lovelace
  </li>
  <li
    role="option"
    aria-selected="false"
    data-keyrove-item
    data-keyrove-roving-tabindex
    tabindex="-1"
  >
    Alan Turing
  </li>
  <!-- … -->
  <li
    role="option"
    aria-selected="false"
    data-keyrove-item
    data-keyrove-roving-tabindex
    tabindex="-1"
  >
    Barbara Liskov
  </li>
</ul>

The readout tells selection from focus movement by its verb: ↓ from Ada Lovelace reads next → Alan Turing, and Enter then reads selected → Alan Turing.

The pieces

  • The markup. role="listbox" on the list, role="option" and aria-selected on every item, aria-label on the widget. This is what makes it a listbox to assistive technology, and keyrove neither reads nor writes any of it: roles and states are left to you because what they should say depends on the widget.
  • Navigation. data-keyrove-item on every option and keyRove first in the chain. The arrows, Home, End and the page keys come with the basic list, and they are the keys the APG listbox pattern asks for. A listbox does not wrap, so there is no data-keyrove-loop.
  • One tab stop. data-keyrove-roving-tabindex on every option, tabindex="0" on the selected one and -1 on the rest, so Tab treats the whole list as one control. Roving tabindex is the arrangement the APG describes for a composite widget, and keyrove carries the stop from here on. Options rendered from data can leave the tabindex out and have initRovingTabindex set the stop from the selection instead: initRovingTabindex(listbox, { initial: listbox.querySelector('[aria-selected="true"]') }).
  • Typeahead. createTypeahead() second in the chain, after navigation and before the widget's own keys, so a letter jumps and a bound key never becomes typing. See typeahead.
  • Picking. pick runs after navigation and typeahead. It uses matchesCombo for exact Space/Enter matching, so Ctrl+Space remains unhandled. It returns null for other keys, allowing another handler to follow it. Typeahead consumes Space when it extends a matching prefix; otherwise, pick handles it.
  • The mouse. followFocus updates the roving stop on focusin, including focus from clicks or code. The click handler then selects the option.

Selection that follows focus

To select each option as keyboard navigation or typeahead focuses it, pass onMove to both handlers. Replace the keydown chain above with:

const onMove = ({ to }) => select(to);

const typeahead = createTypeahead({ onMove });

listbox.addEventListener('keydown', (e) => {
  keyRove(e, { onMove }) || typeahead(e);
});

Use selection on focus for cheap, reversible changes such as filtering or sorting. Keep explicit selection when it submits a form or loads a page. onMove covers these handlers' moves; the separate click and focusin listeners still handle pointer selection and the tab stop.

Variations

  • Multi-select. Add aria-multiselectable="true" to the list and make pick toggle the focused option's aria-selected instead of moving a single selection. Nothing about navigation changes.
  • A grid of options. data-keyrove-cols adds grid navigation. For an ARIA grid, use role="grid" with rows and cells (role="row" and role="gridcell") and implement selection for those cells. The key handler chain can stay the same.
  • aria-activedescendant. The other listbox model keeps DOM focus on the container and moves a virtual cursor with aria-activedescendant. keyrove moves real focus, so it does not implement that model; the roving stop above is its equivalent.

Search documentation

↑ ↓ to selectEnter to openEsc to close