Listbox
stimeo--listbox
A select-only listbox: open a popup, pick one option, with activedescendant navigation.
The stimeo--listbox controller implements the WAI-ARIA Listbox pattern in its collapsed (Select-Only Combobox) form. Pressing the combobox trigger opens the listbox; focus stays on the trigger while the active option is tracked with aria-activedescendant rather than by moving DOM focus. ArrowUp/ArrowDown (wrapping), Home/End, and printable-character typeahead move the active option; opening picks the selected option, else the first. Selecting syncs aria-selected, reflects the label into the trigger and the hidden field's value, and dispatches stimeo--listbox:change. Enter/Space select and close; Escape and outside click or Tab close; closing via select/Escape returns focus to the trigger. Static placement is your CSS; dynamic placement is delegated to the opt-in stimeo-ui/positioning.
- Apple
- Banana
- 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 first option starting with the typed text. |
| 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="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">
<% %w[apple banana 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));
}
.listbox__trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-strong);
border-radius: 0.375rem;
background: var(--surface, var(--surface-card));
color: var(--fg, var(--color-text));
font: inherit;
cursor: pointer;
}
.listbox__trigger[aria-expanded="true"] {
border-color: var(--accent, var(--color-primary));
}
.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;
}
.listbox__option[data-active] {
background: var(--vital-100);
}
.listbox__option[aria-selected="true"] {
font-weight: 600;
color: var(--accent, var(--color-primary));
}
.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 only. |
hidden |
List | Present when closed. |
data-active |
Option | Present on the active option (a CSS highlight hook). |