RZREDZ.UI

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.

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

<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".

classrest:hover:focus-visiblearia-expandeddisabledaria-invalid
combobox-toggle
classrest:hoverdata-activearia-selectedaria-disabled
menu-item

Keyboard and accessibility

KeyOn the buttonIn the search
Enter, SpaceOpens the panel, the focus in its searchEnter takes the active option: one closes the panel, many stay
↓ / ↑Opens the panelThe next / the previous option, round the ends; the first press goes to the chosen option
Page Down / Page Up-Ten options on / back
A letterOpens the panel with the letter in the searchTypes; 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 with role="dialog"; the library keeps its aria-expanded, and its aria-controls only 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-activedescendant names the active option, aria-selected marks 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 voiceover holds these words against what VoiceOver says in Safari.
  • A choice fires input and change on 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.

KeyForIn English
removeA chip's remove button; {label} is the value's labelRemove {label}
createThe option of a list with data-create; {label} is the search's wordsCreate “{label}”
chip.invalidWords a search with a pattern or type="email" refuses; {label} is the words{label} is not valid
found.one, found.otherThe 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.