Skip to content
Home Theme Gallery

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

Single selection
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
Attributes
NameTypeDefaultDescription
selection"none" | "single" | "multiple""single"Selection mode. multiple sets aria-multiselectable. In single the selection follows the cursor.
cursoran 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.
namestring-The form field name. Each selected value is submitted as its own entry, like <select multiple>.
disabledbooleanfalseDisables the whole list: it leaves the tab order, ignores input and submits nothing.
requiredbooleanfalseThe list is invalid, and blocks its form, while nothing is selected.
columnsa <code>grid-template-columns</code> value-Column layout for the head and every option, set as --hui-listbox-columns.
sorta <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
Properties
NameTypeDefaultDescription
valuesstring[]-Read-only. The selected values, in document order, read from the options’ aria-selected.
valuestring-The first selected value, for parity with hui-select. Setting it selects exactly that value, or exactly an array of values; no event fires.
listHTMLElement-Read-only. The element carrying role="listbox": [data-rows] when present, otherwise the host.
selectionSelectionMode'single'Reflects to selection.
cursorstring | undefined-Reflects to cursor.
Events5
Events
NameDescription
inputThe selection changed. bubbles and composed. In multiple it is a CustomEvent whose detail is the values array, which Alpine’s x-model binds.
changeThe selection changed, once per change, after input. hx-trigger="change" works unchanged.
hui-cursor-changeThe reader moved the cursor, after cursor is updated. Detail: { value }.
hui-activateEnter or a double-click on an enabled row. Cancellable. Detail: { value }.
hui-sortA sort head was activated. Cancellable: preventing it leaves sort unchanged until the server answers. Detail: { column, direction }.
Slots1
Slots
NameDescription
(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
CSS custom properties
NameDefaultDescription
--hui-listbox-columns-The column template, set from columns.
--hui-accentoklch(0.96 0.003 325.6)The cursor row’s fill while the list has focus.
--hui-primaryoklch(0.496 0.265 301.924)The tint behind a selected row, at 8 percent.
--hui-ringoklch(0.62 0.019 323.02)The cursor’s outline while focus is elsewhere.
Classes1
Classes
NameDescription
.hui-listbox__sortThe sort control hui-listbox wires into a column head with data-sort-key. Its indicator shows the direction from the head’s aria-sort.
<span role="columnheader" aria-sort="ascending"><button class="hui-listbox__sort" data-sort-key="name">Name</button></span>

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.

Multiple selection
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.

Option groups
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.

Column heads
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.

Activation
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-activedescendant names the cursor row, so a swap that replaces the rows cannot take focus with it. The element gives every option without an id one.
  • Selection is the options’ aria-selected; in a selectable list every option states it, true or false.
  • Each group is named by its [data-group-label], which the element marks role="presentation" and points aria-labelledby at.
  • 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, with aria-sort on the sorted one.
  • In forced colours a selected row takes the system Highlight pair and the cursor a system outline, solid while the list has focus and dashed when it does not.

Keyboard

Keyboard
KeysAction
Up, DownMoves the cursor, skipping disabled rows. In single it also selects.
Home, EndThe first or last enabled row.
Page Up, Page DownMoves by the rows visible in the list.
SpaceSelects the cursor row; in multiple, toggles it.
Shift + Up, Down, Home, End, Page keysIn multiple, extends the range from the anchor.
Ctrl/Cmd + AIn multiple, selects every enabled row.
EnterEmits hui-activate for the cursor row.
Printable keysType-ahead to the next row starting with what was typed.
TabReaches 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, then value, then their text, exactly as hui-select’s do, so a list moves between the two without rewriting its rows.
  • In multiple the input event carries an array, so x-model binds an array; in single it binds the one value.