Navigation

v0.0.4
Forms

Combobox

A text field that opens a listbox of matches.

On this page

A combobox is a text field that opens a listbox of matches: a select you can type into. It is made of parts you already have, an input group and a floating listbox, inside a combobox wrapper that ties the two together. The stylesheet places and draws it; the combobox behavior filters the options, moves through them and writes the chosen one into the field.

Combobox

<div class="field max-w(320) mx-auto">  <label for="region">Region</label>  <div class="combobox">    <div class="input-group">      <input class="input-group-field" id="region" role="combobox" aria-expanded="false" aria-controls="region-list" aria-autocomplete="list" autocomplete="off" placeholder="Search regions">      <button type="button" class="combobox-toggle" tabindex="-1" aria-label="Show regions"></button>    </div>    <div id="region-list" popover="manual" class="listbox" role="listbox" aria-label="Regions">      <div class="listbox-option" role="option" id="region-hnd" aria-checked="false">Tokyo</div>      <div class="listbox-option" role="option" id="region-sin" aria-checked="false">Singapore</div>      <div class="listbox-option" role="option" id="region-fra" aria-checked="false">Frankfurt</div>      <div class="listbox-option" role="option" id="region-lhr" aria-checked="false">London</div>      <div class="listbox-option" role="option" id="region-iad" aria-checked="false">Virginia</div>      <div class="listbox-option" role="option" id="region-pdx" aria-checked="false" aria-disabled="true">Oregon</div>      <div class="listbox-empty">No regions match.</div>    </div>  </div></div><script type="module">  import { attach } from '@master/ui/behaviors'  attach()</script>
Type to filter, move with the arrow keys and choose with Enter or a click.

#What the stylesheet does

  • Placement. The combobox wrapper names itself as an anchor and scopes the name to itself. Its listbox opens 6px under the whole field, not under the caret or the button, and takes at least the field's width through anchor-size(). Nothing is measured, and several comboboxes on a page do not interfere.
  • The chevron. combobox-toggle is a small button at the field's end. Its chevron turns over while the field says aria-expanded="true".
  • The empty state. listbox-empty appears by itself once every option is hidden.
  • The field's states. Hover, focus, invalid and disabled come from the input group.

In a browser without anchor positioning the listbox still opens, centered in the viewport as any popover is.

#The behavior

combobox takes the root, the element with the combobox class, and returns the function that detaches it. It has no options. It finds the field by role="combobox" and the list by role="listbox".

app.js
import { combobox } from '@master/ui/behaviors/combobox.js'const detach = combobox(document.querySelector('.combobox'))
  • Typing sets hidden on the options whose text does not hold what is typed, whatever its case, opens the list and makes the first match the active option.
  • The active option has aria-selected="true" and is named by aria-activedescendant on the field. Focus stays in the field.
  • Choosing, with Enter or the pointer, writes the option's text into the field, without its listbox-description and listbox-end, marks the option aria-checked="true" and the others false, and closes the list. Typing over a chosen value takes its check away.
  • Opening and closing. The listbox is popover="manual", so typing and clicking in the field do not dismiss it. The behavior opens it on a click in the field, on the toggle and on the arrow keys, closes it on Esc and when focus leaves the combobox, and keeps aria-expanded in step.
  • Events. The field dispatches input, then change, when an option is chosen, as it does for typing. The value is the field's value.

Disabled and hidden options are skipped. While an input method composes, Enter and the arrows belong to it.

Without the behavior the field is a text input: the reader can type a value, and the list does not open.

#Choosing a control

For up to a dozen plain options, a select does this with no script and the platform's own type-ahead. Reach for a combobox when the list is long enough to search, loads as the reader types, or has rich rows.

#Keyboard

The keys are those of the behavior. With the list closed, and with Shift held, Home and End move the caret as in any field. Enter without an active option is left alone, so it still submits a form.

KeysAction
ArrowDown, ArrowUpOpen the list and move the active option, wrapping at the ends
Home, EndMove to the first or the last option while the list is open
EnterChoose the active option and close the list
EscapeClose the list; the field keeps its text
TypingFilter the options and open the list on the first match

#Accessibility

  • The input has role="combobox", aria-controls pointing at the listbox, aria-autocomplete="list" and aria-expanded.
  • Focus stays in the input; the active option is announced through aria-activedescendant.
  • Take the toggle button out of the tab order with tabindex="-1": the arrow keys already open the list from the field.
  • Set autocomplete="off" so the browser's own suggestions do not cover the list.

#React, Svelte and Vue

The combobox component renders this markup from a list of options, attaches the behavior and passes the chosen value in and out. Copy it into the project with npx master-ui add combobox; React, Svelte and Vue says how the components are built and used.

ui/combobox.tsx
'use client'import { useId, useState, type ComponentProps, type ReactNode } from 'react'import { combobox } from '@master/ui/behaviors/combobox.js'import { useBehavior } from './use-behavior'export interface ComboboxOption {  /** What choosing the option gives `onValueChange`. */  value: string  /** What the option says, and what the field holds once it is chosen. */  label: string  description?: string  disabled?: boolean}export interface ComboboxProps extends Omit<ComponentProps<'input'>, 'value' | 'defaultValue' | 'role'> {  options: ComboboxOption[]  /** The value of the chosen option; null while the field holds text no option was chosen for. */  value?: string | null  defaultValue?: string | null  onValueChange?: (value: string | null) => void  /** False when `options` are already the matches, from a server or a search of your own. */  filter?: boolean  /** The name of the list, for assistive technology. */  listLabel: string  /** The name of the button that opens the list. */  toggleLabel: string  /** What the list says when no option matches. */  empty?: ReactNode}/** * A combobox. Name the field with `aria-label`, or give it an `id` a label points at; every other prop of * an input goes to the field, so `onChange` hears what is typed. */export function Combobox({ options, value, defaultValue, onValueChange, filter = true, listLabel, toggleLabel, empty, className, ...field }: ComboboxProps) {  const id = useId()  const ref = useBehavior(combobox, { filter }, { value, defaultValue, onValueChange })  const [first] = useState(value ?? defaultValue)  return (    <div className={className ? `combobox ${className}` : 'combobox'} ref={ref}>      <div className="input-group">        <input className="input-group-field" role="combobox" aria-expanded="false" aria-controls={`${id}list`} aria-autocomplete="list" autoComplete="off" defaultValue={options.find(option => option.value === first)?.label} {...field} />        <button type="button" className="combobox-toggle" tabIndex={-1} aria-label={toggleLabel} />      </div>      <div id={`${id}list`} popover="manual" className="listbox" role="listbox" aria-label={listLabel}>        {options.map(option => (          <div className="listbox-option" role="option" key={option.value} data-value={option.value} aria-checked={option.value === first} aria-disabled={option.disabled || undefined}>            {option.description              ? <span className="listbox-body"><span>{option.label}</span><span className="listbox-description">{option.description}</span></span>              : option.label}          </div>        ))}        {empty !== undefined && <div className="listbox-empty">{empty}</div>}      </div>    </div>  )}
ui/Combobox.svelte
<script lang="ts" module>  import type { HTMLInputAttributes } from 'svelte/elements'  export interface ComboboxOption {    /** What choosing the option gives `onValueChange`. */    value: string    /** What the option says, and what the field holds once it is chosen. */    label: string    description?: string    disabled?: boolean  }  export interface ComboboxProps extends Omit<HTMLInputAttributes, 'value' | 'role' | 'children'> {    options: ComboboxOption[]    /** The value of the chosen option; null while the field holds text no option was chosen for. Bind it, or leave it out. */    value?: string | null    onValueChange?: (value: string | null) => void    /** False when `options` are already the matches, from a server or a search of your own. */    filter?: boolean    /** The name of the list, for assistive technology. */    listLabel: string    /** The name of the button that opens the list. */    toggleLabel: string    /** What the list says when no option matches. */    empty?: string  }</script><script lang="ts">  import { untrack } from 'svelte'  import { combobox } from '@master/ui/behaviors/combobox.js'  import { behavior } from './behavior.svelte.js'  let { options, value = $bindable(), onValueChange, filter = true, listLabel, toggleLabel, empty, class: className, ...field }: ComboboxProps = $props()  const id = $props.id()  const first = untrack(() => value)  const text = untrack(() => options.find(option => option.value === first)?.label)  const attach = behavior(combobox, () => ({ filter }), {    value: () => value,    change: (next) => { value = next; onValueChange?.(next) },  })</script><!--  @component  A combobox. Name the field with `aria-label`, or give it an `id` a label points at; every other prop of  an input goes to the field, so `oninput` hears what is typed.--><div class={['combobox', className]} {@attach attach}>  <div class="input-group">    <input class="input-group-field" role="combobox" aria-expanded="false" aria-controls="{id}list" aria-autocomplete="list" autocomplete="off" value={text} {...field} />    <button type="button" class="combobox-toggle" tabindex={-1} aria-label={toggleLabel}></button>  </div>  <div id="{id}list" popover="manual" class="listbox" role="listbox" aria-label={listLabel}>    {#each options as option (option.value)}      <!-- The behavior writes `aria-selected` on the option the keys are on. -->      <!-- svelte-ignore a11y_role_has_required_aria_props -->      <div class="listbox-option" role="option" data-value={option.value} aria-checked={option.value === first} aria-disabled={option.disabled || undefined}>        {#if option.description}          <span class="listbox-body"><span>{option.label}</span><span class="listbox-description">{option.description}</span></span>        {:else}          {option.label}        {/if}      </div>    {/each}    {#if empty !== undefined}<div class="listbox-empty">{empty}</div>{/if}  </div></div>
ui/Combobox.vue
<script lang="ts">export interface ComboboxOption {  /** What choosing the option gives the model. */  value: string  /** What the option says, and what the field holds once it is chosen. */  label: string  description?: string  disabled?: boolean}</script><script setup lang="ts">import { useAttrs, useId, useTemplateRef } from 'vue'import { combobox } from '@master/ui/behaviors/combobox.js'import { useBehavior } from './use-behavior'defineOptions({ inheritAttrs: false })const { options, filter = true } = defineProps<{  options: ComboboxOption[]  /** False when `options` are already the matches, from a server or a search of your own. */  filter?: boolean  /** The name of the list, for assistive technology. */  listLabel: string  /** The name of the button that opens the list. */  toggleLabel: string  /** What the list says when no option matches. */  empty?: string}>()/** The value of the chosen option; null while the field holds text no option was chosen for: `v-model`. */const model = defineModel<string | null>()/* * A combobox. Name the field with `aria-label`, or give it an `id` a label points at; every attribute but * the class goes to the field, so `@change` hears what is typed. */const id = useId()const attrs = useAttrs()const field = () => {  const { class: _, ...rest } = attrs  return rest}const first = model.value// The first label is the field's default value, an attribute: what is typed after is not Vue's to write.const firstLabel = options.find(option => option.value === first)?.labelconst root = useTemplateRef<HTMLDivElement>('root')useBehavior(root, combobox, () => ({ filter }), model)</script><template>  <div class="combobox" :class="$attrs.class" ref="root">    <div class="input-group">      <input class="input-group-field" role="combobox" aria-expanded="false" :aria-controls="`${id}-list`" aria-autocomplete="list" autocomplete="off" :value.attr="firstLabel" v-bind="field()">      <button type="button" class="combobox-toggle" tabindex="-1" :aria-label="toggleLabel" />    </div>    <div :id="`${id}-list`" popover="manual" class="listbox" role="listbox" :aria-label="listLabel">      <div v-for="option in options" :key="option.value" class="listbox-option" role="option" :data-value="option.value" :aria-checked="option.value === first" :aria-disabled="option.disabled || undefined">        <span v-if="option.description" class="listbox-body"><span>{{ option.label }}</span><span class="listbox-description">{{ option.description }}</span></span>        <template v-else>{{ option.label }}</template>      </div>      <div v-if="empty !== undefined" class="listbox-empty">{{ empty }}</div>    </div>  </div></template>

#Classes

ClassDescription
comboboxThe wrapper that anchors the listbox to the field
combobox-toggleA chevron button at the field's end that opens and closes the list

It is used with input-group, input-group-field and a listbox that has the popover attribute.

A combobox is composed from these components; each keeps its own classes and states.

#Source

@master/ui/components/combobox.css
/* * Combobox: a text field that opens a listbox of matches. * The wrapper names itself as an anchor and scopes that name, so its listbox opens against the whole field * and takes at least its width, with no measuring. Filtering, the active option and the popover's open state * belong to the script; the stylesheet reads aria-expanded to turn the chevron. */@layer components {  .combobox {    display: block;    min-width: 0;    anchor-name: --combobox;    anchor-scope: --combobox;    /*     * The list. Without anchor positioning it stays where it is in the flow, directly under the field,     * which is why it is written after the field in the markup.     */    .listbox[popover] {      position-anchor: --combobox;      inset: auto;      margin: 6px 0;      min-inline-size: anchor-size(--combobox inline);    }  }  /* The button that opens the list from the field's end: a chevron that turns over while the list is open. */  .combobox-toggle {    position: relative;    display: inline-grid;    flex: none;    place-items: center;    width: 1.5rem;    height: 1.5rem;    /* Its chevron ends 2px inside the field's padding at every size, where a select's chevron ends. */    margin-block: 0;    margin-inline: 0 -.4375rem;    padding: 0;    border: 0;    border-radius: calc((var(--radius-chip) - 2px) * var(--corner-scale));    corner-shape: var(--corner-shape);    background: transparent;    -webkit-tap-highlight-color: transparent;    color: var(--color-text-muted);    appearance: none;    cursor: pointer;    &::before {      content: '';      width: .875rem;      height: .875rem;      background: currentColor;      mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4.5 6.5 3.5 3.5 3.5-3.5'/%3E%3C/svg%3E") center / contain no-repeat;      transition: rotate var(--duration-base) var(--easing-glide);    }    @media (hover: hover) {      &:hover {        color: var(--color-text-strong);      }    }    /* Pressed: the strong ink, as under the pointer. */    &:active {      color: var(--color-text-strong);    }    &:focus-visible {      @apply --focus-ring;      outline-offset: 0;    }    @media (prefers-reduced-motion: reduce) {      &::before {        transition: none;      }    }    @media (forced-colors: active) {      &::before {        background: ButtonText;      }    }    /* On touch the toggle is a 44px target; the field keeps its size around it. */    @media (pointer: coarse) {      width: 2.75rem;      height: 2.75rem;      margin-block: 0;      margin-inline: -.375rem -1.0625rem;    }  }  .combobox:has([aria-expanded="true"]) .combobox-toggle::before {    rotate: x 180deg;  }}