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.
- 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>
import {
createTypeahead,
followFocus,
keyRove,
matchesCombo,
} from '@mixedrays/keyrove';
const listbox = document.querySelector('#assignee');
const typeahead = createTypeahead();
const select = (option) => {
for (const each of listbox.querySelectorAll('[role="option"]')) {
each.setAttribute('aria-selected', String(each === option));
}
};
const pick = (e) => {
if (!matchesCombo(e, 'Space, Enter')) return null;
const option = e.target.closest('[role="option"]');
if (!option) return null;
e.preventDefault();
select(option);
return option;
};
listbox.addEventListener('keydown', (e) => {
keyRove(e) || typeahead(e) || pick(e);
});
listbox.addEventListener('focusin', (e) => followFocus(e));
listbox.addEventListener('click', (e) => {
const option = e.target.closest('[role="option"]');
if (option) select(option);
});
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"andaria-selectedon every item,aria-labelon 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-itemon every option andkeyRovefirst 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 nodata-keyrove-loop. - One tab stop.
data-keyrove-roving-tabindexon every option,tabindex="0"on the selected one and-1on 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 thetabindexout and haveinitRovingTabindexset 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.
pickruns after navigation and typeahead. It usesmatchesCombofor exact Space/Enter matching, so Ctrl+Space remains unhandled. It returnsnullfor other keys, allowing another handler to follow it. Typeahead consumes Space when it extends a matching prefix; otherwise,pickhandles it. - The mouse.
followFocusupdates the roving stop onfocusin, 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 makepicktoggle the focused option'saria-selectedinstead of moving a single selection. Nothing about navigation changes. - A grid of options.
data-keyrove-colsadds grid navigation. For an ARIA grid, userole="grid"with rows and cells (role="row"androle="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 witharia-activedescendant. keyrove moves real focus, so it does not implement that model; the roving stop above is its equivalent.