Listbox
stimeo--listbox
A select that opens a list and takes one choice, with focus staying on the button.
Implements the WAI-ARIA Listbox pattern in its open-and-choose form. Pressing the button opens the list. Focus stays on the button while screen readers are told which choice is current. The arrow keys wrap around, Home and End jump to the ends, and typing a character jumps to the choice starting with it. Opening starts from the chosen option, or the first one if there is none. Choosing updates both the button's label and the value your form submits. Esc, a click outside, and Tab all close it, and choosing or pressing Esc returns focus to the button. Placement here is this demo's CSS.
- Apple
- Apricot
- Banana
- Blueberry
- Cherry
- Grape
- Orange
Keyboard
| Key | Action |
|---|---|
| Enter / Space / ↓ / ↑ | Open the listbox while it is closed. |
| ↓ / ↑ | Move the active option while open (wrapping). |
| Home / End | Move to the first / last option. |
| Printable characters | Typeahead to the next option starting with the typed text; repeating a character cycles through the options sharing that letter. |
| Enter / Space | Select the active option and close. |
| Esc | Close without selecting and return focus to the trigger. |
<%# Markup for the listbox demo.
Pressing the role="combobox" trigger opens a role="listbox"; arrows / typeahead
navigate the options to pick one. Focus stays on the trigger and the active option
is shown via aria-activedescendant. The library handles open/close, option
movement, single selection, reflecting into the trigger label and hidden input, and
closing on Escape / outside click. Static placement is in demo.css. %>
<div class="listbox" data-controller="stimeo--listbox">
<span id="listbox-label" class="listbox__label"><%= t("components.listbox.demo.label") %></span>
<button
type="button"
class="demo-trigger listbox__trigger"
role="combobox"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="listbox-options"
aria-labelledby="listbox-label listbox-value"
data-stimeo--listbox-target="trigger"
data-action="click->stimeo--listbox#toggle keydown->stimeo--listbox#onTriggerKeydown">
<span id="listbox-value" data-stimeo--listbox-target="value">
<%= t("components.listbox.demo.placeholder") %>
</span>
<span class="listbox__chevron" aria-hidden="true">▾</span>
</button>
<ul
id="listbox-options"
class="listbox__list"
role="listbox"
aria-label="<%= t("components.listbox.demo.label") %>"
hidden
data-stimeo--listbox-target="list">
<%# Two pairs share a first letter (apple/apricot, banana/blueberry) so pressing
that letter repeatedly cycles between them — the APG type-ahead behavior. %>
<% %w[apple apricot banana blueberry cherry grape orange].each_with_index do |fruit, index| %>
<li
id="listbox-opt-<%= index %>"
class="listbox__option"
role="option"
aria-selected="false"
data-value="<%= fruit %>"
data-stimeo--listbox-target="option"
data-action="click->stimeo--listbox#select">
<%= t("components.listbox.demo.options.#{fruit}") %>
</li>
<% end %>
</ul>
<input type="hidden" name="fruit" data-stimeo--listbox-target="field" />
</div>
/*
* Presentation-only styles for the listbox demo.
* The library toggles the list's hidden, options' aria-selected, and the active
* candidate's data-active highlight. Placement (directly below the trigger) is static
* and the consumer's CSS responsibility; use stimeo-ui/positioning for dynamic flip.
*/
.listbox {
position: relative;
display: inline-flex;
flex-direction: column;
gap: 0.35rem;
min-width: 14rem;
}
.listbox__label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--fg, var(--color-text));
}
/* Only the select-style layout is the demo's own; the look comes from .demo-trigger. */
.listbox__trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.listbox__chevron {
color: var(--color-text-muted);
}
.listbox__list {
position: absolute;
top: calc(100% + 0.25rem);
left: 0;
right: 0;
z-index: 10;
margin: 0;
padding: 0.25rem;
list-style: none;
background: var(--surface, var(--surface-card));
border: 1px solid var(--border-strong);
border-radius: 0.375rem;
box-shadow: 0 8px 24px rgb(15 23 42 / 0.12);
}
.listbox__option {
padding: 0.45rem 0.6rem;
border-radius: 0.25rem;
cursor: pointer;
}
/* A theme-aware soft accent surface, not the raw `--vital-100`: this rule sets a
background but lets the text inherit `--color-text`, which flips with the theme.
A fixed light mint therefore paired light text on light in dark mode — measured
1.02:1, i.e. the row the keyboard is on was effectively invisible. */
.listbox__option[data-active] {
background: var(--color-primary-soft);
}
/* `--accent-800`, one step stronger than the plain accent text token: this text can
land on the tinted active/selected surface as well as the card, and on the tinted
one in dark `--accent-700` measures 4.44:1 — just under the floor. */
.listbox__option[aria-selected="true"] {
font-weight: 600;
color: var(--accent-800);
}
.listbox__option[aria-selected="true"]::after {
content: "✓";
margin-left: 0.4rem;
}
This demo needs no consumer-side JS (the controller handles the behavior).
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--listbox"
Targets
| Name | Description | Attribute |
|---|---|---|
trigger
required
|
The collapsed combobox button that opens the list and tracks the active option via aria-activedescendant. |
data-stimeo--listbox-target="trigger" |
value
|
Span inside the trigger displaying the selected option's label. | data-stimeo--listbox-target="value" |
list
required
|
The role=listbox popup, toggled via its hidden attribute. |
data-stimeo--listbox-target="list" |
option
required
|
A role=option whose aria-selected/data-active state the controller manages. |
data-stimeo--listbox-target="option" |
field
|
Hidden input mirroring the selected value for form submission. | data-stimeo--listbox-target="field" |
Actions
| Name | Description | Action |
|---|---|---|
close
|
Hides the list, clears the active option, and resets the typeahead buffer. | stimeo--listbox#close |
onTriggerKeydown
|
Handles trigger keyboard interaction per the APG select-only model (open keys, ArrowUp/Down wrapping, Home/End, typeahead, Enter/Space, Escape, Tab). | stimeo--listbox#onTriggerKeydown |
open
|
Opens the list and activates the selected option (else the first). | stimeo--listbox#open |
select
|
Selects the clicked option, then closes and returns focus to the trigger. | stimeo--listbox#select |
toggle
|
Toggles the list open/closed on a real mouse click (ignores synthetic keyboard clicks). | stimeo--listbox#toggle |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires on selection; detail { value, option }. |
stimeo--listbox:change |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-expanded |
Trigger | Open/closed state of the listbox. |
aria-activedescendant |
Trigger | The id of the active option (removed when none). |
aria-selected |
Option | "true" on the selected option, "false" on the others. You can render the initial selection server-side; if several are marked, the first wins. |
hidden |
List | Present when closed. |
data-active |
Option | Present on the active option (a CSS highlight hook). |