|
|
5 months ago | |
|---|---|---|
| .. | ||
| components | 5 months ago | |
| README.md | 6 months ago | |
| exports.ts | 6 months ago | |
| index.ts | 6 months ago | |
| langs.ts | 6 months ago | |
| listbox-provider.svelte.ts | 5 months ago | |
| types.ts | 5 months ago | |
README.md
Listbox
A standalone role="listbox" with roving-tabindex keyboard navigation, alphanumeric typeahead, single or multiple selection, ARIA groups, and native form integration. Use it when the list is embedded in the layout (sidebars, file pickers, command palettes where the list is always open). For dropdown lists inside a popover, use Select instead — Select already embeds listbox semantics.
Anatomy
<Listbox.Provider bind:value aria-label="Fruits">
<Listbox.Item value="apple">
Apple
<Listbox.ItemIndicator>✓</Listbox.ItemIndicator>
</Listbox.Item>
<Listbox.Item value="banana">Banana</Listbox.Item>
<Listbox.Group>
<Listbox.GroupLabel>Citrus</Listbox.GroupLabel>
<Listbox.Item value="lemon">Lemon</Listbox.Item>
<Listbox.Item value="orange">Orange</Listbox.Item>
</Listbox.Group>
</Listbox.Provider>
Parts
| Part | Element | Description |
|---|---|---|
Provider |
<div> |
Root with role="listbox". Holds state + keyboard + registry. |
Item |
<div> |
One option. role="option", aria-selected, roving tabindex. |
ItemIndicator |
<span> |
Renders only when the parent Item is selected (unless forceMount). |
Group |
<div> |
ARIA group container. Pair with GroupLabel. |
GroupLabel |
<div> |
Heading that labels the surrounding Group. |
Props
Provider
| Prop | Type | Default | Description |
|---|---|---|---|
id |
string |
auto | DOM id. |
value |
string[] |
[] |
Bindable selection (always an array; in single mode holds ≤1 entry). |
onValueChange |
(v: string[]) => void |
— | Fires on every selection change. |
selectionMode |
'single' | 'multiple' |
'single' |
How Enter/Space and click behave. |
loop |
boolean |
true |
Arrow-key wrapping at ends. |
typeahead |
boolean |
true |
Alphanumeric typeahead jump. |
typeaheadTimeout |
number |
500 |
ms before the typeahead buffer resets. |
orientation |
'horizontal' | 'vertical' |
'vertical' |
Layout + which arrow keys step. |
dir |
'ltr' | 'rtl' |
from Soma | Reading direction. Flips horizontal arrows. |
disabled |
boolean |
false |
OR-merged with Field.Provider. |
readonly |
boolean |
false |
OR-merged. |
required |
boolean |
false |
OR-merged. |
invalid |
boolean |
false |
OR-merged. |
name |
string |
— | When set, renders one hidden input per selected value for form submission. |
aria-label |
string |
translated | Accessible label. Overrides the translated default (Options). |
aria-labelledby |
string |
— | Id of an external label. When set, aria-label is omitted. |
Item
| Prop | Type | Default | Description |
|---|---|---|---|
id |
string |
auto | DOM id. |
value |
string |
— | Required. Selection key. Distinct values required within a single listbox. |
disabled |
boolean |
false |
Item-specific disable (ORed with Provider). |
textValue |
string |
— | Text used for typeahead when it differs from textContent. |
ItemIndicator
| Prop | Type | Default | Description |
|---|---|---|---|
id |
string |
auto | DOM id. |
forceMount |
boolean |
false |
Keep in DOM when unselected — useful for CSS exit transitions. |
Snippet props
// Provider
{ value: string[]; isEmpty: boolean; isFocused: boolean }
// Item
{ isSelected: boolean; isHighlighted: boolean; isDisabled: boolean; value: string }
ARIA
| Part | Attribute | Value |
|---|---|---|
| Provider | role |
listbox |
| Provider | aria-orientation |
horizontal | vertical |
| Provider | aria-multiselectable |
true when selectionMode='multiple' |
| Provider | aria-label |
Translated default (Options) unless overridden |
| Provider | aria-labelledby |
When aria-labelledby prop is set |
| Provider | aria-disabled / -readonly / -required / -invalid |
when respective flag is on |
| Item | role |
option |
| Item | aria-selected |
true | false |
| Item | aria-disabled |
when disabled |
| Item | tabindex |
0 on the roving target, -1 on the rest |
| Group | role |
group |
| Group | aria-labelledby |
DOM id of the sibling GroupLabel |
Exactly one Item holds tabindex=0 at any time: the first selected + enabled item, or (if nothing is selected) the first enabled item.
Data Attributes
| Part | Attribute | Values |
|---|---|---|
| Provider | data-listbox |
Always present |
| Provider | data-orientation |
horizontal | vertical |
| Provider | data-disabled |
when disabled |
| Provider | data-readonly |
when readonly |
| Provider | data-required |
when required |
| Provider | data-invalid |
when invalid |
| Provider | data-focused |
while focus is inside |
| Provider | data-empty |
when value.length === 0 |
| Item | data-listbox-item |
Always present |
| Item | data-state |
selected | unselected |
| Item | data-highlighted |
while pointer/focus is on it |
| Item | data-disabled |
when disabled |
| Item | data-orientation |
inherited from Provider |
| Item | data-value |
the item's value |
| ItemIndicator | data-listbox-item-indicator |
Always present |
| ItemIndicator | data-state |
selected | unselected |
| Group | data-listbox-group |
Always present |
| GroupLabel | data-listbox-group-label |
Always present |
Keyboard
| Key | Action |
|---|---|
ArrowDown / ArrowUp |
Move focus to next / previous enabled item (vertical orientation). |
ArrowRight / ArrowLeft |
Same, horizontal orientation. RTL flips these. |
Home / End |
Jump to first / last enabled item. |
Enter / Space |
Toggle selection of the focused item. |
A…Z / 0…9 |
Typeahead: jump to the next item whose text starts with the typed prefix. Repeat to cycle. |
Ctrl/Cmd+A |
Select all enabled items (only when selectionMode='multiple'). |
| Click | Select (single) / toggle (multiple) — focus moves to the clicked item. |
Disabled items are skipped entirely by arrow nav and typeahead.
i18n
| Key | English | Spanish |
|---|---|---|
label |
Options |
Opciones |
Override per-instance with the aria-label or aria-labelledby prop.
Comparison
| Feature | Soma | Radix | Ark UI | bits-ui | React Aria |
|---|---|---|---|---|---|
| Dedicated component (not inside Select) | ✅ | ❌¹ | ✅ | ❌² | ✅ |
| Single + multiple | ✅ | — | ✅ | — | ✅ |
| Roving tabindex | ✅ | — | ✅ | — | ✅ |
| Alphanumeric typeahead | ✅ | — | ✅ | — | ✅ |
Ctrl/Cmd+A select-all (multi) |
✅ | — | ❌ | — | ✅ |
Groups (role="group" + label) |
✅ | — | ✅ | — | ✅ |
| Horizontal orientation | ✅ | — | ✅ | — | ✅ |
| RTL-aware arrow keys | ✅ | — | ✅ | — | ✅ |
| ItemIndicator (for check marks) | ✅ | — | ✅ | — | ✅ |
| Hidden inputs for native form | ✅ | — | ❌ | — | ❌ |
| Field OR-merge | ✅ | — | ❌ | — | ❌ |
Translated default aria-label |
✅ | — | ⚠️ | — | ⚠️ |
¹ Radix embeds listbox semantics in Select.Content (not reusable standalone).
² bits-ui's Listbox is built on top of their Select internals; no separate export.
Usage
Single-select
<script lang="ts">
import { Listbox } from '$soma/components';
let value = $state<string[]>(['apple']);
</script>
<Listbox.Provider bind:value aria-label="Fruit">
<Listbox.Item value="apple">Apple</Listbox.Item>
<Listbox.Item value="banana">Banana</Listbox.Item>
<Listbox.Item value="cherry">Cherry</Listbox.Item>
</Listbox.Provider>
Multi-select with indicators
<Listbox.Provider bind:value selectionMode="multiple" aria-label="Toppings">
{#each toppings as t (t.value)}
<Listbox.Item value={t.value}>
<span>{t.label}</span>
<Listbox.ItemIndicator>✓</Listbox.ItemIndicator>
</Listbox.Item>
{/each}
</Listbox.Provider>
Grouped
<Listbox.Provider bind:value aria-label="Team members">
<Listbox.Group>
<Listbox.GroupLabel>Engineering</Listbox.GroupLabel>
<Listbox.Item value="a">Alice</Listbox.Item>
<Listbox.Item value="b">Bob</Listbox.Item>
</Listbox.Group>
<Listbox.Group>
<Listbox.GroupLabel>Design</Listbox.GroupLabel>
<Listbox.Item value="c">Carla</Listbox.Item>
</Listbox.Group>
</Listbox.Provider>
Inside a form
<form onsubmit={onSubmit}>
<Listbox.Provider bind:value selectionMode="multiple" name="roles">
<Listbox.Item value="admin">Admin</Listbox.Item>
<Listbox.Item value="editor">Editor</Listbox.Item>
<Listbox.Item value="viewer">Viewer</Listbox.Item>
</Listbox.Provider>
<button type="submit">Save</button>
</form>
name="roles" produces one <input type="hidden" name="roles" value="…"> per selected entry; the browser appends duplicates to FormData.
Out of scope (v2 roadmap)
Explicit list of features the user signed off as not in the v1 release — documented so a future PR can pick them up without re-litigating scope.
Shift+Arrow range-extension (multi-select)
What: In selectionMode='multiple', hold Shift while pressing ArrowDown/ArrowUp to extend the selection from the previously selected item to the focused item — and Shift+Home / Shift+End to extend to the boundary.
Reference libraries: React Aria implements it; Radix doesn't expose Listbox standalone; Ark has a partial implementation (range via click but not keyboard); bits-ui omits it.
Why out-of-scope in v1: Range extension requires tracking an "anchor" item (the starting point of the range), swapping it when the user clicks with a modifier, and layering the range logic on top of the existing keyboard nav. Not hard in isolation but it doubles the complexity of the keydown handler. Ctrl/Cmd+A (select-all) covers the primary "bulk select" use case for v1.
Cost estimate: ~60 lines in the provider (anchor state + 4 branch keys in handleItemKeydown) + keyboard table row + README example. No breaking change.
Implementation notes
- Items register themselves on mount via the Provider's registry rather than
querySelectorAll. Mounted groups, lazy-rendered rows and portaled content all work without extra bookkeeping. - DOM order is enforced with
Node.compareDocumentPositionon the registry — insertion order can differ when items are conditionally rendered. - Typeahead buffer: repeated same-letter keypresses cycle through matches; a multi-letter prefix (
"ba","ban") matches from the current index, not the next. Buffer resets aftertypeaheadTimeoutms with no further key input. - Roving-tabindex target is picked on every render: prefer the first selected+enabled item, fall back to the first enabled item. This is stable as items appear/disappear.
- In
singlemode, clicking the already-selected item keeps it selected (no unintentional clear). Usevalue = []manually (or an explicit Clear button) to reset. - Multi-selection with
Shift+Arrowrange-extension is not implemented — reserved for a future version.Ctrl/Cmd+Acovers the "select all" use case, and clicks / Enter / Space toggle per item.