Combobox
A choice from a list long enough to search: a button in the look of a select shows the value and opens a panel with a search field and the list. One core - the WAI-ARIA combobox with a listbox - also drives the command palette and a list in the page.
Examples
The face is a button with role="combobox" that opens its panel by the browser's command; the panel is a popover with the search, the listbox and the value: one hidden field per chosen value, named by the list's data-name, so a form posts it as it is. Typing filters the list - case and accents aside, every word found, the best first - and the arrow keys move the active option while the focus stays in the search.
<div class="form-floating">
<button class="form-select combobox-toggle" type="button" id="combobox-one-face" role="combobox" aria-haspopup="dialog" aria-expanded="false" commandfor="combobox-one-panel" command="toggle-popover"><span class="combobox-value" data-placeholder="Choose a language…">Thai</span></button>
<label for="combobox-one-face">Default language</label>
</div>
<div class="menu combobox-menu" id="combobox-one-panel" popover role="dialog" aria-label="Default language">
<div class="combobox-search">
<i class="bi bi-search" aria-hidden="true"></i>
<input class="form-control form-control-sm combobox-search-input" type="search" role="combobox" aria-controls="combobox-one-list" aria-expanded="true" aria-autocomplete="list" aria-label="Find a language" placeholder="Find a language" autocomplete="off" autofocus>
</div>
<div class="combobox-list" id="combobox-one-list" role="listbox" aria-label="Languages" data-name="language">
<div class="menu-item" role="option" id="combobox-one-en" data-value="en" aria-selected="false">English</div>
<div class="menu-item" role="option" id="combobox-one-th" data-value="th" aria-selected="true">Thai</div>
<div class="menu-item" role="option" id="combobox-one-ru" data-value="ru" aria-selected="false">Russian</div>
<div class="menu-item" role="option" id="combobox-one-de" data-value="de" aria-selected="false">German</div>
<div class="menu-item" role="option" id="combobox-one-fr" data-value="fr" aria-selected="false">French</div>
<div class="menu-item" role="option" id="combobox-one-es" data-value="es" aria-selected="false">Spanish</div>
<div class="menu-item" role="option" id="combobox-one-zh" data-value="zh" aria-selected="false">Chinese</div>
<div class="menu-item" role="option" id="combobox-one-ja" data-value="ja" aria-selected="false">Japanese</div>
<div class="menu-item" role="option" id="combobox-one-ko" data-value="ko" aria-selected="false">Korean</div>
<div class="menu-item" role="option" id="combobox-one-ar" data-value="ar" aria-selected="false">Arabic</div>
<div class="menu-item" role="option" id="combobox-one-he" data-value="he" aria-selected="false">Hebrew</div>
<div class="menu-item" role="option" id="combobox-one-vi" data-value="vi" aria-selected="false">Vietnamese</div>
</div>
<p class="combobox-no-results" hidden>Nothing found</p>
<input type="hidden" name="language" value="th" data-label="Thai">
</div>Many - a list on the button
A list with aria-multiselectable="true" keeps its panel open while options are chosen, and a field name[] holds each value. The button lists them in the page's language - "English, Thai, and Russian" - while they fit, else the first ones and the count of the rest.
<div class="form-floating">
<button class="form-select combobox-toggle" type="button" id="combobox-many-face" role="combobox" aria-haspopup="dialog" aria-expanded="false" commandfor="combobox-many-panel" command="toggle-popover"><span class="combobox-value" data-placeholder="Choose languages…">English, Thai, and Russian</span></button>
<label for="combobox-many-face">Site languages</label>
</div>
<div class="menu combobox-menu" id="combobox-many-panel" popover role="dialog" aria-label="Site languages">
<div class="combobox-search">
<i class="bi bi-search" aria-hidden="true"></i>
<input class="form-control form-control-sm combobox-search-input" type="search" role="combobox" aria-controls="combobox-many-list" aria-expanded="true" aria-autocomplete="list" aria-label="Find a language" placeholder="Find a language" autocomplete="off" autofocus>
</div>
<div class="combobox-list" id="combobox-many-list" role="listbox" aria-label="Languages" aria-multiselectable="true" data-name="languages[]">
<div class="menu-item" role="option" id="combobox-many-en" data-value="en" aria-selected="true">English</div>
<div class="menu-item" role="option" id="combobox-many-th" data-value="th" aria-selected="true">Thai</div>
<div class="menu-item" role="option" id="combobox-many-ru" data-value="ru" aria-selected="true">Russian</div>
<div class="menu-item" role="option" id="combobox-many-de" data-value="de" aria-selected="false">German</div>
<div class="menu-item" role="option" id="combobox-many-fr" data-value="fr" aria-selected="false">French</div>
<div class="menu-item" role="option" id="combobox-many-es" data-value="es" aria-selected="false">Spanish</div>
<div class="menu-item" role="option" id="combobox-many-zh" data-value="zh" aria-selected="false">Chinese</div>
<div class="menu-item" role="option" id="combobox-many-ja" data-value="ja" aria-selected="false">Japanese</div>
</div>
<p class="combobox-no-results" hidden>Nothing found</p>
<input type="hidden" name="languages[]" value="en" data-label="English">
<input type="hidden" name="languages[]" value="th" data-label="Thai">
<input type="hidden" name="languages[]" value="ru" data-label="Russian">
</div>Many - chips
Where the chosen values are the field's content - categories, tags - the list names a container by data-chips: each value shows there as a chip with its remove button, and the button keeps its placeholder.
<div class="vstack gap-8">
<div class="form-floating">
<button class="form-select combobox-toggle" type="button" id="combobox-chips-face" role="combobox" aria-haspopup="dialog" aria-expanded="false" commandfor="combobox-chips-panel" command="toggle-popover"><span class="combobox-value" data-placeholder="Add a category…"></span></button>
<label for="combobox-chips-face">Categories</label>
</div>
<div class="combobox-chips" id="combobox-chips-list-chips"></div>
</div>
<div class="menu combobox-menu" id="combobox-chips-panel" popover role="dialog" aria-label="Categories">
<div class="combobox-search">
<i class="bi bi-search" aria-hidden="true"></i>
<input class="form-control form-control-sm combobox-search-input" type="search" role="combobox" aria-controls="combobox-chips-list" aria-expanded="true" aria-autocomplete="list" aria-label="Find a category" placeholder="Find a category" autocomplete="off" autofocus>
</div>
<div class="combobox-list" id="combobox-chips-list" role="listbox" aria-label="Categories" aria-multiselectable="true" data-name="categories[]" data-chips="combobox-chips-list-chips">
<div class="menu-item" role="option" id="combobox-chips-beaches" data-value="beaches" aria-selected="true">Beaches</div>
<div class="menu-item" role="option" id="combobox-chips-islands" data-value="islands" aria-selected="true">Islands</div>
<div class="menu-item" role="option" id="combobox-chips-diving" data-value="diving" aria-selected="false">Diving and snorkeling</div>
<div class="menu-item" role="option" id="combobox-chips-food" data-value="food" aria-selected="false">Food tours</div>
<div class="menu-item" role="option" id="combobox-chips-temples" data-value="temples" aria-selected="true">Temples</div>
<div class="menu-item" role="option" id="combobox-chips-markets" data-value="markets" aria-selected="false">Night markets</div>
<div class="menu-item" role="option" id="combobox-chips-parks" data-value="parks" aria-selected="false">National parks</div>
</div>
<p class="combobox-no-results" hidden>Nothing found</p>
<input type="hidden" name="categories[]" value="beaches" data-label="Beaches">
<input type="hidden" name="categories[]" value="islands" data-label="Islands">
<input type="hidden" name="categories[]" value="temples" data-label="Temples">
</div>Tags that create values
A list with data-create ends with “Create …” while the search's words are no option's label and no chosen one - the page's text create. Taken, the new label is held in a hidden field data-create names, beside the list's own; the search empties for the next one. A page that makes the value on its server cancels redz:create on the list and answers with a promise of {value, label}: the list is busy until it settles, the new value is an option from then on - or, rejected, its words are said.
<div class="vstack gap-8">
<div class="form-floating">
<button class="form-select combobox-toggle" type="button" id="combobox-tags-face" role="combobox" aria-haspopup="dialog" aria-expanded="false" commandfor="combobox-tags-panel" command="toggle-popover"><span class="combobox-value" data-placeholder="Add a tag…"></span></button>
<label for="combobox-tags-face">Tags</label>
</div>
<div class="combobox-chips" id="combobox-tags-chips"></div>
</div>
<div class="menu combobox-menu" id="combobox-tags-panel" popover role="dialog" aria-label="Tags">
<div class="combobox-search">
<i class="bi bi-search" aria-hidden="true"></i>
<input class="form-control form-control-sm combobox-search-input" type="search" role="combobox" aria-controls="combobox-tags-list" aria-expanded="true" aria-autocomplete="list" aria-label="Find or create a tag" placeholder="Find or create a tag" autocomplete="off" autofocus>
</div>
<div class="combobox-list" id="combobox-tags-list" role="listbox" aria-label="Tags" aria-multiselectable="true" data-name="tags[]" data-create="tags_new[]" data-chips="combobox-tags-chips">
<div class="menu-item" role="option" id="combobox-tags-family" data-value="family" aria-selected="true">Family</div>
<div class="menu-item" role="option" id="combobox-tags-sunset" data-value="sunset" aria-selected="false">Sunset</div>
<div class="menu-item" role="option" id="combobox-tags-vegan" data-value="vegan" aria-selected="false">Vegetarian lunch</div>
</div>
<input type="hidden" name="tags[]" value="family" data-label="Family">
</div>document.addEventListener('redz:create', (event) => {
if (event.target.id !== 'tags') {
return; // the new label goes into the field data-create names
}
event.preventDefault();
event.detail.respondWith(fetch('/tags', {method: 'POST', body: new URLSearchParams({label: event.detail.label})})
.then((answer) => answer.ok ? answer.json() : Promise.reject('The tag could not be made')));
});
Free values
A list with data-create and no options of its own takes free values - e-mail addresses, phone numbers, words - as chips: Enter or a comma takes the words, a value held already is not taken twice. A search with a pattern or type="email" refuses words it does not hold: the search is marked aria-invalid and the page's text chip.invalid says why.
<div class="card">
<div class="card-body vstack gap-8">
<label class="form-label" for="combobox-free-search">Send the report to</label>
<input class="form-control" id="combobox-free-search" type="email" role="combobox" aria-controls="combobox-free-list" aria-expanded="true" aria-autocomplete="list" placeholder="name@example.com" autocomplete="off">
<div class="combobox-list" id="combobox-free-list" role="listbox" aria-label="Addresses" aria-multiselectable="true" data-name="to[]" data-create="to[]" data-chips="combobox-free-chips"></div>
<div class="combobox-chips" id="combobox-free-chips"></div>
</div>
</div>Options in levels
Options with data-level 2 or 3 are indented under the one before them, and a parent can be chosen as any option. The search keeps the page's order and shows a match with its parents, so a city is found with its country. The level is drawn, not said - an option has no aria-level in ARIA: an option whose name needs its parent carries it in its words or in its menu-item-description.
Nothing found
<div class="card">
<div class="card-body vstack gap-8">
<input class="form-control" type="search" role="combobox" aria-controls="combobox-levels-list" aria-expanded="true" aria-autocomplete="list" aria-label="Find a destination" placeholder="Find a destination" autocomplete="off">
<div class="combobox-list" id="combobox-levels-list" role="listbox" aria-label="Destinations" data-name="destination">
<div class="menu-item" role="option" id="combobox-levels-th" data-value="th" data-level="1" aria-selected="false">Northland</div>
<div class="menu-item" role="option" id="combobox-levels-south" data-value="th-south" data-level="2" aria-selected="false">The south</div>
<div class="menu-item" role="option" id="combobox-levels-main-island" data-value="main-island" data-level="3" aria-selected="true">Main Island</div>
<div class="menu-item" role="option" id="combobox-levels-south-bay" data-value="south-bay" data-level="3" aria-selected="false">South Bay</div>
<div class="menu-item" role="option" id="combobox-levels-north" data-value="th-north" data-level="2" aria-selected="false">The north</div>
<div class="menu-item" role="option" id="combobox-levels-hill-town" data-value="hill-town" data-level="3" aria-selected="false">Hill Town</div>
<div class="menu-item" role="option" id="combobox-levels-my" data-value="my" data-level="1" aria-selected="false">Southland</div>
<div class="menu-item" role="option" id="combobox-levels-pearl-isle" data-value="pearl-isle" data-level="2" aria-selected="false">Pearl Isle</div>
</div>
<p class="combobox-no-results" hidden>Nothing found</p>
<input type="hidden" name="destination" value="main-island" data-label="Main Island">
</div>
</div>Groups, descriptions and icons
Options sit in groups - role="group" named by its menu-header - and carry an icon and a menu-item-description, which the search reads too; data-keywords adds words the option answers without showing them, and aria-disabled="true" keeps an option out of reach.
<div class="form-floating">
<button class="form-select combobox-toggle" type="button" id="combobox-groups-face" role="combobox" aria-haspopup="dialog" aria-expanded="false" commandfor="combobox-groups-panel" command="toggle-popover"><span class="combobox-value" data-placeholder="Choose a role…">Editor</span></button>
<label for="combobox-groups-face">Role</label>
</div>
<div class="menu combobox-menu" id="combobox-groups-panel" popover role="dialog" aria-label="Role">
<div class="combobox-search">
<i class="bi bi-search" aria-hidden="true"></i>
<input class="form-control form-control-sm combobox-search-input" type="search" role="combobox" aria-controls="combobox-groups-list" aria-expanded="true" aria-autocomplete="list" aria-label="Find a role" placeholder="Find a role" autocomplete="off" autofocus>
</div>
<div class="combobox-list" id="combobox-groups-list" role="listbox" aria-label="Roles" data-name="role">
<div role="group" aria-labelledby="combobox-groups-content">
<div class="menu-header" id="combobox-groups-content" role="presentation">Content</div>
<div class="menu-item" role="option" id="combobox-groups-editor" data-value="editor" data-label="Editor" aria-selected="true"><i class="bi bi-pencil-square" aria-hidden="true"></i><span class="menu-item-content"><span>Editor</span><span class="menu-item-description">Writes and publishes pages</span></span></div>
<div class="menu-item" role="option" id="combobox-groups-translator" data-value="translator" data-label="Translator" data-keywords="languages" aria-selected="false"><i class="bi bi-translate" aria-hidden="true"></i><span class="menu-item-content"><span>Translator</span><span class="menu-item-description">Translates what editors write</span></span></div>
</div>
<div role="group" aria-labelledby="combobox-groups-site">
<div class="menu-header" id="combobox-groups-site" role="presentation">Site</div>
<div class="menu-item" role="option" id="combobox-groups-manager" data-value="manager" data-label="Site manager" aria-selected="false"><i class="bi bi-globe2" aria-hidden="true"></i><span class="menu-item-content"><span>Site manager</span><span class="menu-item-description">Runs one site and its settings</span></span></div>
<div class="menu-item" role="option" id="combobox-groups-owner" data-value="owner" data-label="Owner" aria-selected="false" aria-disabled="true"><i class="bi bi-key" aria-hidden="true"></i><span class="menu-item-content"><span>Owner</span><span class="menu-item-description">Only an owner gives this role</span></span></div>
</div>
</div>
<p class="combobox-no-results" hidden>Nothing found</p>
<input type="hidden" name="role" value="editor" data-label="Editor">
</div>Options from the server
A page that finds options itself listens for redz:search on the search and cancels it: the list is marked aria-busy until the page puts its options in - or sets aria-busy="false" when it cannot. This example answers from a short list after 400 ms.
<div class="form-floating">
<button class="form-select combobox-toggle" type="button" id="combobox-remote-face" role="combobox" aria-haspopup="dialog" aria-expanded="false" commandfor="combobox-remote-panel" command="toggle-popover"><span class="combobox-value" data-placeholder="Find a person…"></span></button>
<label for="combobox-remote-face">Assigned to</label>
</div>
<div class="menu combobox-menu" id="combobox-remote-panel" popover role="dialog" aria-label="Assigned to">
<div class="combobox-search">
<i class="bi bi-search" aria-hidden="true"></i>
<input class="form-control form-control-sm combobox-search-input" type="search" role="combobox" aria-controls="combobox-remote-list" aria-expanded="true" aria-autocomplete="list" aria-label="Find a person" placeholder="Find a person" autocomplete="off" autofocus>
</div>
<div class="combobox-list" id="combobox-remote-list" role="listbox" aria-label="People" data-name="assignee" data-docs-remote></div>
<p class="combobox-no-results" hidden>Nobody found</p>
</div>document.addEventListener('redz:search', (event) => {
const list = document.getElementById(event.target.getAttribute('aria-controls'));
if (list.id !== 'people' || event.detail.query.length < 2) {
return; // the library filters the options it has
}
event.preventDefault();
const {query, signal} = event.detail; // signal aborts when the query changes or the panel closes
fetch('/people?q=' + encodeURIComponent(query), {signal})
.then((answer) => answer.json())
.then((people) => list.replaceChildren(...people.map(option)))
.catch(() => signal.aborted || list.setAttribute('aria-busy', 'false'));
});
A value set from the page
REDZ.ui.combobox.set(list, values) sets what a list holds as a choice does: its hidden fields, its button or chips, then input and change. list is the listbox or its selector, values one value or a list of them. A form's own reset goes back to the value the page came with; a Discard that goes back to the last saved value calls this.
A list in the page
The same search and list without a button and a panel - a filter in a side column, a long choice in a settings page.
Nothing found
<div class="card">
<div class="card-body vstack gap-8">
<input class="form-control" type="search" role="combobox" aria-controls="combobox-inline-list" aria-expanded="true" aria-autocomplete="list" aria-label="Find a time zone" placeholder="Find a time zone" autocomplete="off">
<div class="combobox-list" id="combobox-inline-list" role="listbox" aria-label="Time zones" data-name="zone">
<div class="menu-item" role="option" id="combobox-inline-bkk" data-value="Asia/Bangkok" aria-selected="true">Bangkok · UTC+7</div>
<div class="menu-item" role="option" id="combobox-inline-sgp" data-value="Asia/Singapore" aria-selected="false">Singapore · UTC+8</div>
<div class="menu-item" role="option" id="combobox-inline-dxb" data-value="Asia/Dubai" aria-selected="false">Dubai · UTC+4</div>
<div class="menu-item" role="option" id="combobox-inline-msk" data-value="Europe/Moscow" aria-selected="false">Moscow · UTC+3</div>
<div class="menu-item" role="option" id="combobox-inline-ber" data-value="Europe/Berlin" aria-selected="false">Berlin · UTC+1</div>
<div class="menu-item" role="option" id="combobox-inline-nyc" data-value="America/New_York" aria-selected="false">New York · UTC−5</div>
</div>
<p class="combobox-no-results" hidden>Nothing found</p>
<input type="hidden" name="zone" value="Asia/Bangkok" data-label="Bangkok · UTC+7">
</div>
</div>States
The button carries a field's states; an option is active where the arrow keys are - data-active, which the library sets - and chosen by aria-selected="true".
| class | rest | :hover | :focus-visible | aria-expanded | disabled | aria-invalid |
|---|---|---|---|---|---|---|
| combobox-toggle |
| class | rest | :hover | data-active | aria-selected | aria-disabled |
|---|---|---|---|---|---|
| menu-item |
Keyboard and accessibility
| Key | On the button | In the search |
|---|---|---|
| Enter, Space | Opens the panel, the focus in its search | Enter takes the active option: one closes the panel, many stay |
| ↓ / ↑ | Opens the panel | The next / the previous option, round the ends; the first press goes to the chosen option |
| Page Down / Page Up | - | Ten options on / back |
| A letter | Opens the panel with the letter in the search | Types; the first match becomes active |
| Esc | - | Empties the search; on an empty search, closes the panel, the focus back on the button |
| Tab | - | Closes the panel and moves on |
- The button is the WAI-ARIA select-only combobox: named by its
<label for>, its value its text, its popup a panel withrole="dialog"; the library keeps itsaria-expanded, and itsaria-controlsonly while the panel is open - VoiceOver otherwise reads the value from the closed list, not from the button. - The search is the editable combobox with list autocomplete: the focus stays in it,
aria-activedescendantnames the active option,aria-selectedmarks the chosen ones. The list follows the search in the page, so a screen reader on a phone reaches it by swiping. - Once the typing pauses, the option Enter would take is said politely by its label - a screen reader busy with the typed keys says nothing of it - and with nothing found, the count, from the page's texts. The probes' engine
voiceoverholds these words against what VoiceOver says in Safari. - A choice fires
inputandchangeon the list; a form's reset puts the values back as the page sent them.
Texts
The library has no words of its own; it reads these from the page's island #redz-ui-text. A count takes the form the page language's plural rules name - found.one, found.few, found.many - and found.other when the page gives no such form.
| Key | For | In English |
|---|---|---|
remove | A chip's remove button; {label} is the value's label | Remove {label} |
create | The option of a list with data-create; {label} is the search's words | Create “{label}” |
chip.invalid | Words a search with a pattern or type="email" refuses; {label} is the words | {label} is not valid |
found.one, found.other | The count of options found; {count} in the page's digits | {count} result, {count} results |
Tokens
From css/components/combobox.css, which declares no custom property of its own.
It reads the theme's --redz-space-8, --redz-focus, --redz-text-muted, --redz-border, --redz-space-4, --redz-bg-hover, --redz-space-12, --redz-primary-subtle, --redz-primary.