Listbox
The custom element is <hui-listbox>.
Overview
A standalone list over option rows the server rendered: a cursor the keys move, and a selection that is single, multiple with Shift ranges, or none. The options’ aria-selected is the selection, so a swap that writes different rows selected is the whole update.
Example
Source
<hui-listbox name="fruit" aria-label="Fruit" style="max-block-size:13rem"> <div role="option" data-value="apple">Apple</div> <div role="option" data-value="apricot">Apricot</div> <div role="option" data-value="banana" aria-selected="true">Banana</div> <div role="option" data-value="blueberry">Blueberry</div> <div role="option" data-value="cherry" aria-disabled="true">Cherry</div> <div role="option" data-value="date">Date</div> <div role="option" data-value="fig">Fig</div> <div role="option" data-value="grape">Grape</div></hui-listbox>The Go template that renders it:
<hui-listbox name="fruit" aria-label="Fruit"{{ if .Cursor }} cursor="{{ .Cursor }}"{{ end }}> {{ range .Fruit }} <div role="option" data-value="{{ .ID }}"{{ if .Selected }} aria-selected="true"{{ end }}{{ if .Locked }} aria-disabled="true"{{ end }}>{{ .Name }}</div> {{ end }}</hui-listbox>API
Attributes8
| Name | Type | Default | Description |
|---|---|---|---|
selection | "none" | "single" | "multiple" | "single" | Selection mode. multiple sets aria-multiselectable. In single the selection follows the cursor. |
cursor | an option’s value | - | The row the keys act on. Viewer-owned: the element reflects it as the reader moves, and a server that persisted it sends it back to restore the position. |
name | string | - | The form field name. Each selected value is submitted as its own entry, like <select multiple>. |
disabled | boolean | false | Disables the whole list: it leaves the tab order, ignores input and submits nothing. |
required | boolean | false | The list is invalid, and blocks its form, while nothing is selected. |
columns | a <code>grid-template-columns</code> value | - | Column layout for the head and every option, set as --hui-listbox-columns. |
sort | a <code>data-sort-key</code> | - | The column the server sorted by. Mirrored to aria-sort on that head cell. |
sort-direction | "ascending" | "descending" | "none" | "none" | Which way. |
Properties5
| Name | Type | Default | Description |
|---|---|---|---|
values | string[] | - | Read-only. The selected values, in document order, read from the options’ aria-selected. |
value | string | - | The first selected value, for parity with hui-select. Setting it selects exactly that value, or exactly an array of values; no event fires. |
list | HTMLElement | - | Read-only. The element carrying role="listbox": [data-rows] when present, otherwise the host. |
selection | SelectionMode | 'single' | Reflects to selection. |
cursor | string | undefined | - | Reflects to cursor. |
Events5
| Name | Description |
|---|---|
input | The selection changed. bubbles and composed. In multiple it is a CustomEvent whose detail is the values array, which Alpine’s x-model binds. |
change | The selection changed, once per change, after input. hx-trigger="change" works unchanged. |
hui-cursor-change | The reader moved the cursor, after cursor is updated. Detail: { value }. |
hui-activate | Enter or a double-click on an enabled row. Cancellable. Detail: { value }. |
hui-sort | A sort head was activated. Cancellable: preventing it leaves sort unchanged until the server answers. Detail: { column, direction }. |
Slots1
| Name | Description |
|---|---|
(default) | The [role="option"] rows, optionally in [role="group"]s, or a [data-head] and a [data-rows] holding them. Light DOM: there is no shadow root. |
CSS custom properties4
| Name | Default | Description |
|---|---|---|
--hui-listbox-columns | - | The column template, set from columns. |
--hui-accent | oklch(0.96 0.003 325.6) | The cursor row’s fill while the list has focus. |
--hui-primary | oklch(0.496 0.265 301.924) | The tint behind a selected row, at 8 percent. |
--hui-ring | oklch(0.62 0.019 323.02) | The cursor’s outline while focus is elsewhere. |
Classes1
| Name | Description |
|---|---|
.hui-listbox__sort | The sort control hui-listbox wires into a column head with data-sort-key. Its indicator shows the direction from the head’s aria-sort. |
States
Multiple selection
With selection="multiple" the cursor moves freely and Space marks rows: Shift with an arrow, Home, End or a page key extends a range from the anchor, Ctrl or Cmd + A selects every enabled row, Ctrl or Cmd + click toggles one, and Shift + click selects a range. A form receives one sources entry per selected row.
Source
<hui-listbox name="sources" selection="multiple" aria-label="Sources" style="max-block-size:13rem"> <div role="option" data-value="holiday" aria-selected="true">Holiday 2024</div> <div role="option" data-value="scans">Scans</div> <div role="option" data-value="phone" aria-selected="true">Phone backup</div> <div role="option" data-value="locked" aria-disabled="true">Locked archive</div> <div role="option" data-value="music">Music</div> <div role="option" data-value="projects">Projects</div> <div role="option" data-value="receipts">Receipts</div></hui-listbox>Option groups
A role="group" child holds options under a [data-group-label] heading, which names the group. The keys, type-ahead and Shift ranges walk every option in document order, across groups, and a label is never the cursor.
Source
<hui-listbox selection="multiple" aria-label="Places" style="max-block-size:15rem"> <div role="group"> <div data-group-label>Local</div> <div role="option" data-value="home">Home</div> <div role="option" data-value="scans">Scans</div> </div> <div role="group"> <div data-group-label>Remote</div> <div role="option" data-value="server">Media server</div> <div role="option" data-value="backup">Offsite backup</div> </div></hui-listbox>Column heads
A [data-head] above a [data-rows] child lays head cells and option cells on the one columns template, so they line up at any width, and the head stays in view while the rows scroll. A head cell with data-sort-key becomes a button that emits hui-sort; the rows are never reordered - the server sorts and re-renders.
Source
<hui-listbox selection="multiple" columns="minmax(0, 1fr) 6rem 4rem" sort="name" sort-direction="ascending" aria-label="Files" style="max-block-size:13rem"> <div data-head> <span data-sort-key="name">Name</span> <span data-sort-key="modified">Modified</span> <span>Size</span> </div> <div data-rows> <div role="option" data-value="a1"><span>Holiday 2024</span><span>Today</span><span>2 GB</span></div> <div role="option" data-value="a2"><span>Scans</span><span>Yesterday</span><span>84 MB</span></div> <div role="option" data-value="a3"><span>Phone backup</span><span>3 Sep</span><span>11 GB</span></div> <div role="option" data-value="a4"><span>Music</span><span>1 Sep</span><span>40 GB</span></div> <div role="option" data-value="a5"><span>Projects</span><span>28 Aug</span><span>6 GB</span></div> <div role="option" data-value="a6"><span>Receipts</span><span>2 Aug</span><span>12 MB</span></div> </div></hui-listbox>Activation
Enter or a double-click emits hui-activate with the row’s value - opening an item, as distinct from selecting it. The event is cancellable; this example writes the value it hears beside the list.
Source
<hui-listbox id="inbox" selection="multiple" aria-label="Inbox" style="max-block-size:9rem"> <div role="option" data-value="m1">Invoice for September</div> <div role="option" data-value="m2">Your order has shipped</div> <div role="option" data-value="m3">Team lunch on Friday</div> <div role="option" data-value="m4">Weekly report</div></hui-listbox><p style="font-size:.875rem">Opened: <output id="opened">nothing yet</output></p><script> document.querySelector('#inbox').addEventListener('hui-activate', (event) => { document.querySelector('#opened').textContent = event.detail.value; });</script>Server-side mechanics
The server owns the rows and which of them are selected, written as aria-selected="true" - there is no value attribute to disagree with the markup. The list submits each selected value under name, as one entry each, and a form reset restores the selection the server last rendered. The cursor is viewer-owned: reflected to cursor and announced with hui-cursor-change, for a host that wants to persist it. An hx-swap of the rows is the whole update: the server’s selection wins, a Shift range in progress is discarded, and the cursor finds its value again - or, if that row is gone, stays at the same index - with focus left on the list.
Accessibility
- Focus stays on the list and
aria-activedescendantnames the cursor row, so a swap that replaces the rows cannot take focus with it. The element gives every option without anidone. - Selection is the options’
aria-selected; in a selectable list every option states it,trueorfalse. - Each group is named by its
[data-group-label], which the element marksrole="presentation"and pointsaria-labelledbyat. - With column heads the listbox role moves onto
[data-rows], because a listbox may own only options and groups. The head is a one-row table of column headers, named after the list, witharia-sorton the sorted one. - In forced colours a selected row takes the system
Highlightpair and the cursor a system outline, solid while the list has focus and dashed when it does not.
Keyboard
| Keys | Action |
|---|---|
Up, Down | Moves the cursor, skipping disabled rows. In single it also selects. |
Home, End | The first or last enabled row. |
Page Up, Page Down | Moves by the rows visible in the list. |
Space | Selects the cursor row; in multiple, toggles it. |
Shift + Up, Down, Home, End, Page keys | In multiple, extends the range from the anchor. |
Ctrl/Cmd + A | In multiple, selects every enabled row. |
Enter | Emits hui-activate for the cursor row. |
Printable keys | Type-ahead to the next row starting with what was typed. |
Tab | Reaches the sort heads, then the rows, as one stop. |
Gotchas
- Nothing is virtualised: every row the server sends is rendered. A step on 5,000 rows is about a millisecond; selecting all 5,000 at once restyles every row and costs tens of milliseconds in Firefox and WebKit.
- Options take their value from
data-value, thenvalue, then their text, exactly ashui-select’s do, so a list moves between the two without rewriting its rows. - In
multipletheinputevent carries an array, sox-modelbinds an array; insingleit binds the one value.