listbox()

listbox.mjs

Turns elements already in the page into an APG listbox. Like tree() and accordion(): it adds keyboard behavior and ARIA attributes to existing markup, and stop() takes both away again.

Use it when the choices are elements you cannot restructure — words inside a paragraph, cells in a rendered table, markers on a timeline — and especially when the listbox has to live inside another composite widget. Use contained for the nested case; ordinary roving tabindex does not handle it correctly.

Signature

ExportSignatureDescription
listbox listbox(container, options) Makes container a role="listbox" over options.items and returns a handle

Options

OptionDefaultMeaning
items—The elements to make options, in order
label—aria-label for the listbox; give it one
orientation'horizontal'Which arrow pair moves along the list
containedfalseThis listbox lives inside another composite widget — see below
active0Which option gets focus on start
typeaheadtruePrintable keys jump to the option whose text starts with what was typed
on_pick—Enter / Space on the focused option, as (el, index)
on_active—The active option changed, as (el, index) — arrows, Home/End, typeahead, restrict() and the first focus. Most callers do not need it — see below
on_cancel—Esc. The decorator does not stop automatically — call stop()

Handle

MemberMeaning
restrict(items, active?)Replace the option set. Options no longer in it lose their role and cannot be reached by any key
focus_at(index)Move focus within the set
active()The focused option, or null
stop()End it. Every attribute this wrote is removed, and the container gets its previous role / aria-label back

The caller supplies styling, including the focus ring: the options are the page's own elements, so module-level styling could conflict with the page's existing styles. The caller also manages selection. An option without aria-selected receives aria-selected="false" while the listbox is active. An option already marked aria-selected="true" keeps that value, and stop() removes the attribute either way.

Keyboard

KeyDoes
← → (or ↑ ↓ when vertical)Previous / next option. No wrap — see below
Home / EndFirst / last option of the current set
Enter / Spaceon_pick
Escon_cancel
A printable keyTypeahead — the letters accumulate into one prefix, and the buffer clears after 500 ms. The match is looked for from the option after the active one and wraps once, so repeating a single letter walks the options that start with it
Tab, Shift+F10, ContextMenu, anything with Alt/Ctrl/⌘Left alone, so they keep bubbling to whatever owns them

Why arrow navigation does not wrap. A listbox used to pick the two ends of a range is read as “from here to there”, and wrapping past the last option to the first turns one arrow press into the opposite end of the range.

on_active — let the listbox announce navigation

Moving focus to a role="option" element already tells a screen reader the option's name and position. Do not repeat that text in a live region from on_active, or the user will hear the same information twice.

Use on_active to announce information that the focused option does not convey. For example, while choosing a range, focus may announce “brown” while the selected range is “quick brown”. Show that range visually as well as announcing it.

It fires for every route into focus_at(), the first focus and restrict() included, so a caller that only wants genuine user moves checks its own state rather than expecting the callback to be filtered.

contained — a listbox inside another widget

A composite widget is one Tab stop, and two roving-tabindex scopes nested inside each other see the same keydown. Enable all three behaviors with contained: true:

Shift+F10 is deliberately not in that list: focus is on a real element, so the platform's context-menu opener should keep working exactly as it does one level up.

Demo — a word range inside a roving-tabindex region

Tab into the region: it has one stop, on the row's own button, whichever mode is live. Press Enter there to start picking, then ←/→ or a letter to move, Enter to fix the range's start, Enter again for its end, Esc to leave. Once the start is fixed, options before it become unavailable through restrict(), instead of being rejected after selection.

alpha bravo charlie delta echo

foxtrot golf hotel

Show code import {listbox} from '/ui/lib/listbox.mjs' let scope = null const start = (row) => { const words = [...row.querySelectorAll('span')] let first = null scope = listbox(row, { items: words, label: 'Words', contained: true, on_pick: (el) => { if (!first) { const rest = words.slice(words.indexOf(el) + 1) if (rest.length) { first = el; return scope.restrict(rest) } } // …use the range (first ?? el) … el … stop(row) }, on_cancel: () => stop(row), }) } const stop = (row) => { scope.stop(); scope = null; row.querySelector('.dot').focus() }