listbox()
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.
| Export | Signature | Description |
|---|---|---|
listbox |
listbox(container, options) |
Makes container a role="listbox" over options.items and returns a handle |
| Option | Default | Meaning |
|---|---|---|
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 |
contained | false | This listbox lives inside another composite widget — see below |
active | 0 | Which option gets focus on start |
typeahead | true | Printable 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() |
| Member | Meaning |
|---|---|
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.
| Key | Does |
|---|---|
| ← → (or ↑ ↓ when vertical) | Previous / next option. No wrap — see below |
| Home / End | First / last option of the current set |
| Enter / Space | on_pick |
| Esc | on_cancel |
| A printable key | Typeahead — 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:
tabindex="-1" and focus is moved programmatically, because the enclosing
widget already owns the region's one stop. The usual roving-tabindex pattern — 0 on the
active option — adds an unwanted second Tab stop inside the widget.
preventDefault() does not stop that — so
without stopPropagation() one ↓ moves both scopes at once.
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.
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