Combobox
A text input paired with a listbox, narrowed as the user types.
Combobox pairs a text input with a listbox, so the user narrows the list by typing and then either picks a match or keeps what they typed. Useful when typing beats scrolling, or when the options come from a server.
<template>
<Combobox.Root
v-model="value"
v-model:input-value="query"
:options="options"
>
<Combobox.InputGroup
class="group border-surface rounded-component-lg has-[:focus-visible]:focus-ring data-[popup-open=true]:focus-ring relative isolate flex h-12 w-64 cursor-text items-center justify-between gap-1.5 border-2 px-3.5 outline-4 outline-transparent transition-all duration-100 ease-linear"
>
<label
class="flex max-h-full w-full cursor-text flex-col-reverse justify-center gap-0 px-1 transition-all duration-100 ease-linear group-has-[:focus]:gap-1 group-has-[input:not(:placeholder-shown)]:gap-1 before:absolute before:-inset-0.5 before:-z-10 before:content-['']"
>
<span
class="relative flex h-0 w-full items-end transition-all duration-100 ease-linear group-has-[:focus]:h-[0.9375rem] group-has-[input:not(:placeholder-shown)]:h-[0.9375rem]"
>
<Combobox.Input
class="text-primary-solid type-component-lg -my-1.25 box-content block h-[1lh] w-full min-w-0 bg-transparent py-1.25 [line-height:normal]! transition-colors duration-100 ease-linear outline-none placeholder:text-transparent"
placeholder=" "
/>
</span>
<span
class="text-primary-muted type-component-lg group-has-[:focus]:type-component-2xs group-has-[input:not(:placeholder-shown)]:type-component-2xs flex w-full items-center [line-height:normal]! transition-all duration-100 ease-linear"
>
City
</span>
</label>
<Combobox.Clear
class="text-primary-muted hover:text-primary-solid flex shrink-0 items-center transition-all duration-100 ease-linear"
>
<svg class="size-4.5" viewBox="0 0 16 16" aria-hidden="true">
<path
d="m4.5 4.5 7 7m0-7-7 7"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
</Combobox.Clear>
<Combobox.Trigger
class="text-primary-muted group-has-[input:not(:placeholder-shown)]:text-primary-solid flex shrink-0 items-center transition-all duration-100 ease-linear"
>
<svg class="size-4.5" viewBox="0 0 16 16" aria-hidden="true">
<path
d="m4 6.5 4 4 4-4"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Combobox.Trigger>
</Combobox.InputGroup>
<Combobox.Content
class="rounded-component-2xl bg-primary-inverted shadow-component-high w-(--rack-floating-anchor-width) overflow-y-auto p-1.5 backdrop-blur-[8rem]"
>
<Combobox.Item
v-for="city in matches"
:key="city"
class="group/item rounded-component-md type-component-lg text-primary-on-subtle hover:bg-primary-subtle active:bg-primary-subtle-hover data-[highlighted=true]:bg-primary-subtle data-[disabled=true]:text-disabled-on-subtle flex h-12 w-full items-center justify-start gap-4 px-1 transition-colors duration-100 ease-linear select-none [--mirror-combobox-item-cursor:pointer]"
:value="city"
>
<Combobox.ItemText
class="inline-flex h-full min-w-0 flex-1 items-center overflow-hidden pr-1.5 pl-5.5"
>
<span class="truncate">{{ city }}</span>
</Combobox.ItemText>
<Combobox.ItemIndicator
class="text-primary-muted group-hover/item:text-primary-solid group-data-[highlighted=true]/item:text-primary-solid flex shrink-0 items-center pr-4 leading-none transition-colors duration-100 ease-linear"
>
<svg class="size-4.5" viewBox="0 0 16 16" aria-hidden="true">
<path
d="m3.5 8.5 3 3 6-6.5"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Combobox.ItemIndicator>
</Combobox.Item>
<Combobox.Empty
class="type-component-lg text-primary-muted flex h-12 items-center pl-6.5"
>
No matches.
</Combobox.Empty>
</Combobox.Content>
</Combobox.Root>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Combobox } from '@maas/mirror/vue'
const options = {
modal: false,
portal: { disabled: true },
}
const cities = ['Glasgow', 'Bath', 'Nuremberg', 'Munich', 'Rome']
const value = ref<string | null>(null)
const query = ref('')
const matches = computed(() =>
cities.filter((city) =>
city.toLowerCase().includes(query.value.toLowerCase())
)
)
</script>
Usage guidelines
- We do not filter for you. Bind the text with
v-model:input-valueand render your own filtered array, so a search against a server works exactly like a local one. - Make sure the combobox has an accessible name, either through a
Labelinside aFieldor anaria-labelon the input. Combobox.Contentteleports to the end of<body>unless you turn that off withoptions.portal.disabled. Leave it on where the field sits inside anything withoverflow: hidden.- The content resolves everything but
sideandalignonce, during setup, so change the rest before the popup first renders or key the root to remount it.
Anatomy
Assemble the parts. Combobox.Content is one part over four boxes: the
teleport, the positioned element, the popup and the listbox. Everything you put
inside it renders in the listbox.
| Name | Required | Description |
|---|---|---|
| Combobox.Root | true | Renders its children plus one hidden input per selected value. |
| Combobox.InputGroup | Optional. Renders a <div> around the input and its buttons, carrying the popup state. | |
| Combobox.Chips | Optional. Renders a <div> holding one chip per selection under multiple. | |
| Combobox.Chip | Renders a <div> standing for one selected value. | |
| Combobox.ChipRemove | Renders a <button> that drops its chip’s value. | |
| Combobox.Input | true | Renders an <input role="combobox">. Holds focus for the whole interaction. |
| Combobox.Value | Renders a <span> with the selected labels. | |
| Combobox.Clear | Renders a <button>. Clears the value and the input text. | |
| Combobox.Trigger | Renders a <button>. Toggles the popup without ever taking focus. | |
| Combobox.Icon | Renders a <span aria-hidden="true"> for the disclosure marker. | |
| Combobox.Status | Optional. Renders a <div role="status"> for loading and result counts. | |
| Combobox.Content | true | Renders the teleport, the floating box, the popup and the <div role="listbox">. |
| Combobox.Group | Renders a <div role="group"> around a run of items. | |
| Combobox.GroupLabel | Renders a <div> and names its group. | |
| Combobox.Item | true | Renders a <div role="option">. |
| Combobox.ItemText | Renders a <span> and registers its text as the item’s label. | |
| Combobox.ItemIndicator | Renders a <span> while the item is selected. | |
| Combobox.Separator | Renders a <div role="separator"> between groups. | |
| Combobox.Empty | Renders a polite live region, filled while no item is registered. |
<script setup lang="ts">
import { Combobox, Field, Label } from '@maas/mirror/vue'
</script>
<template>
<Field.Root :options="{ name: 'city' }">
<Label>City</Label>
<Combobox.Root v-model="city" v-model:input-value="query">
<Combobox.Input placeholder=" " />
<Combobox.Clear />
<Combobox.Trigger />
<Combobox.Content>
<Combobox.Item v-for="match in matches" :key="match" :value="match">
<Combobox.ItemText>{{ match }}</Combobox.ItemText>
<Combobox.ItemIndicator />
</Combobox.Item>
<Combobox.Empty>No matches.</Combobox.Empty>
</Combobox.Content>
</Combobox.Root>
</Field.Root>
</template>
Examples
Filtering
Bind the text with v-model:input-value and render whatever your own filtering
produces.
<script setup lang="ts">
const city = ref<string | null>(null)
const query = ref('')
const matches = computed(() =>
cities.filter((entry) =>
entry.toLowerCase().startsWith(query.value.toLowerCase())
)
)
</script>
<template>
<Combobox.Root v-model="city" v-model:input-value="query">
<Combobox.Input />
<Combobox.Content>
<Combobox.Item v-for="match in matches" :key="match" :value="match">
<Combobox.ItemText>{{ match }}</Combobox.ItemText>
</Combobox.Item>
<Combobox.Empty>No matches.</Combobox.Empty>
</Combobox.Content>
</Combobox.Root>
</template>
Items register as they mount, so the highlight follows your filtered array: once
the highlighted item leaves the set, the highlight moves to the selected item, or
to the first enabled one while autoHighlight is on. Combobox.Empty reads the
registered items rather than your array, which keeps it right once the items are
grouped or virtualised.
When the popup opens
A handful of options decide that, and they are the only ones Combobox adds
beyond allowCustomValue.
<template>
<Combobox.Root
:options="{
openOnInput: true,
openOnInputClick: true,
openOnFocus: false,
autoHighlight: true,
clearOnEscape: false,
}"
/>
</template>
openOnInput opens on the first keystroke, openOnInputClick opens on a
pointer click so an already filled input reopens without typing, and
openOnFocus opens as soon as the input takes focus, before the reader has
done anything. autoHighlight seeds the first enabled item so Enter always has
something to commit, and clearOnEscape lets an Escape on an already-closed
combobox empty both the value and the text.
Custom values
By default the value has to come from the list, so text that matches nothing is
discarded on Enter. Set allowCustomValue and the typed text becomes the value
instead.
<template>
<Combobox.Root v-model="tag" :options="{ allowCustomValue: true }" />
</template>
The input then takes data-custom-value until an item is picked or the combobox
is cleared, so style the difference rather than tracking it yourself.
.input[data-custom-value='true'] {
font-style: italic;
}
Multiple selection
Set options.multiple and the value becomes an array: picking an item toggles
it, the popup stays open, and the input text is left alone so the query survives
the selection.
<script setup lang="ts">
const tags = ref<Array<string>>([])
const query = ref('')
</script>
<template>
<Combobox.Root
v-model="tags"
v-model:input-value="query"
:options="{ multiple: true }"
/>
</template>
The value and options.multiple have to agree, or the root throws
invalid_multiple_value.
Chips
We find a multiple selection easier to read as chips inside the control than as
a list beside it. Combobox.Chips hands out one entry per selection, each
already carrying its key and its label, and Combobox.ChipRemove drops the
chip it sits in. Backspace on an empty input removes the last one, so the
keyboard never needs the buttons.
<script setup lang="ts">
const tags = ref<Array<string>>([])
const query = ref('')
</script>
<template>
<Combobox.Root
v-model="tags"
v-model:input-value="query"
:options="{ multiple: true }"
>
<Combobox.InputGroup>
<Combobox.Chips v-slot="{ entries }">
<Combobox.Chip
v-for="entry in entries"
:key="entry.key"
:value="entry.value"
>
{{ entry.label }}
<Combobox.ChipRemove :aria-label="`Remove ${entry.label}`" />
</Combobox.Chip>
<Combobox.Input />
</Combobox.Chips>
<Combobox.Clear />
<Combobox.Trigger />
</Combobox.InputGroup>
</Combobox.Root>
</template>
The chips are not in the tab order: the input keeps focus for the whole interaction, the way it does everywhere else in this component.
<template>
<Combobox.Root
v-model="value"
v-model:input-value="query"
:options="options"
>
<Combobox.InputGroup
class="group border-surface rounded-component-lg has-[:focus-visible]:focus-ring data-[popup-open=true]:focus-ring relative isolate flex w-64 cursor-text items-center justify-between gap-1.5 border-2 px-3.5 py-2.5 outline-4 outline-transparent transition-all duration-100 ease-linear"
>
<Combobox.Chips
v-slot="{ entries }"
class="flex min-w-0 flex-1 flex-wrap items-center gap-1.5"
>
<Combobox.Chip
v-for="entry in entries"
:key="entry.key"
class="rounded-component-md bg-primary-subtle text-primary-on-subtle type-component-2xs inline-flex h-7 max-w-full shrink-0 items-center gap-1 pr-1 pl-2 transition-all duration-100 ease-linear"
:value="entry.value"
>
<span class="truncate">{{ entry.label }}</span>
<Combobox.ChipRemove
:aria-label="`Remove ${entry.label}`"
class="text-primary-muted hover:text-primary-solid flex size-5 shrink-0 items-center justify-center transition-colors duration-100 ease-linear"
>
<svg class="size-3.5" viewBox="0 0 16 16" aria-hidden="true">
<path
d="m4.5 4.5 7 7m0-7-7 7"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
</Combobox.ChipRemove>
</Combobox.Chip>
<Combobox.Input
class="text-primary-solid type-component-lg placeholder:text-primary-muted h-7 min-w-16 flex-1 bg-transparent [line-height:normal]! transition-colors duration-100 ease-linear outline-none"
placeholder="Add a city"
/>
</Combobox.Chips>
<Combobox.Trigger
class="text-primary-muted group-data-[popup-open=true]:text-primary-solid flex shrink-0 items-center self-start pt-1 transition-all duration-100 ease-linear"
>
<svg class="size-4.5" viewBox="0 0 16 16" aria-hidden="true">
<path
d="m4 6.5 4 4 4-4"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Combobox.Trigger>
</Combobox.InputGroup>
<Combobox.Content
class="rounded-component-2xl bg-primary-inverted shadow-component-high w-(--rack-floating-anchor-width) overflow-y-auto p-1.5 backdrop-blur-[8rem]"
>
<Combobox.Item
v-for="city in matches"
:key="city"
class="group/item rounded-component-md type-component-lg text-primary-on-subtle hover:bg-primary-subtle active:bg-primary-subtle-hover data-[highlighted=true]:bg-primary-subtle flex h-12 w-full items-center justify-start gap-4 px-1 transition-colors duration-100 ease-linear select-none [--mirror-combobox-item-cursor:pointer]"
:value="city"
>
<Combobox.ItemText
class="inline-flex h-full min-w-0 flex-1 items-center overflow-hidden pr-1.5 pl-5.5"
>
<span class="truncate">{{ city }}</span>
</Combobox.ItemText>
<Combobox.ItemIndicator
class="text-primary-muted group-hover/item:text-primary-solid group-data-[highlighted=true]/item:text-primary-solid flex shrink-0 items-center pr-4 leading-none transition-colors duration-100 ease-linear"
>
<svg class="size-4.5" viewBox="0 0 16 16" aria-hidden="true">
<path
d="m3.5 8.5 3 3 6-6.5"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Combobox.ItemIndicator>
</Combobox.Item>
<Combobox.Empty
class="type-component-lg text-primary-muted flex h-12 items-center pl-6.5"
>
No matches.
</Combobox.Empty>
</Combobox.Content>
</Combobox.Root>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Combobox } from '@maas/mirror/vue'
const options = {
modal: false,
multiple: true,
portal: { disabled: true },
}
const cities = ['Glasgow', 'Bath', 'Nuremberg', 'Munich', 'Rome']
const value = ref<Array<string>>([])
const query = ref('')
const matches = computed(() =>
cities.filter((city) =>
city.toLowerCase().includes(query.value.toLowerCase())
)
)
</script>
Object values
An item value does not have to be a string. Hand Combobox.Item the object you
already hold and tell the combobox how to read it, exactly as
Select does.
<script setup lang="ts">
interface City {
code: string
name: string
}
const city = ref<City | null>(null)
</script>
<template>
<Combobox.Root
v-model="city"
v-model:input-value="query"
name="city"
:options="{
isItemEqualToValue: (item, value) => item.code === value.code,
itemToStringLabel: (item) => item.name,
itemToStringValue: (item) => item.code,
}"
>
<Combobox.Input />
</Combobox.Root>
</template>
itemToStringLabel is also the text a selection commits to the input, and
itemToStringValue is what the hidden inputs submit. An object shaped
{ value, label } needs neither.
Announcing an empty or loading list
Combobox.Empty is a polite live region that stays mounted, so a filter that
finds nothing is announced rather than silently rendered. Combobox.Status is
the same idea for everything else the reader should hear, which for a server
list is usually the loading state.
<template>
<Combobox.Root v-model="city" v-model:input-value="query">
<Combobox.Input />
<Combobox.Status v-slot="{ count }">
{{ loading ? 'Searching' : `${count} cities` }}
</Combobox.Status>
<Combobox.Content>
<Combobox.Item v-for="match in matches" :key="match" :value="match">
<Combobox.ItemText>{{ match }}</Combobox.ItemText>
</Combobox.Item>
<Combobox.Empty>No matches.</Combobox.Empty>
</Combobox.Content>
</Combobox.Root>
</template>
Inside a field
Combobox.Input is a field control, and Combobox.Root renders one hidden
input per selected value once it has a name, so
the form sees it.
<template>
<Field.Root :options="{ name: 'city' }">
<Label>City</Label>
<Combobox.Root v-model="city" v-model:input-value="query">
<Combobox.Input />
</Combobox.Root>
<Field.Error />
</Field.Root>
</template>
The hidden inputs hold the value rather than the text, so what the user typed reaches the form only once it has become a value.
Positioning
Combobox.Content positions itself against the input, steered by the
floating keys on the root’s options; the options table below lists every
one of them. The resolved placement comes back
as data-side and data-align.
<template>
<Combobox.Root
:options="{
floating: { side: 'top', align: 'center', sideOffset: 8, flip: false },
}"
/>
</template>
Wrap the control in a Combobox.InputGroup and the popup anchors on the group
instead, which is what it needs when the frame the user sees is wider than the
input inside it. Pass anchor to position against something else again, and
pass any of the eight floating keys straight to the content to override the
options for one instance.
<template>
<Combobox.Content :anchor="() => frame" side="top" :side-offset="12" />
</template>
The content writes --rack-floating-anchor-width,
--rack-floating-anchor-height, --rack-floating-available-width,
--rack-floating-available-height and --rack-floating-transform-origin onto
the floating box, so the popup is already as wide as the anchor and never
taller than the space around it.
.popup {
min-width: var(--mirror-combobox-popup-min-width);
max-height: var(--mirror-combobox-popup-max-height);
}
Where the popup renders
The content teleports to the end of <body>, which is what keeps a popup out
of an ancestor’s overflow: hidden and above the rest of the page. Turn it off
to render in place, or name a target of your own.
<template>
<Combobox.Root :options="{ portal: { to: '#overlays' } }" />
<Combobox.Root :options="{ portal: { disabled: true } }" />
</template>
options.backdrop adds a fixed <div role="presentation"> behind the popup,
for the modal case. It has no dismiss behaviour of its own: the popup’s layer
already closes on a pointer down outside.
<template>
<Combobox.Root :options="{ modal: true, backdrop: true }" />
</template>
Reaching the combobox from anywhere
Give the root an id and useMirrorCombobox(id) opens, clears and reads that
combobox from anywhere in the app.
<script setup lang="ts">
import { useMirrorCombobox } from '@maas/mirror/vue'
import { ComboboxId } from '~/constants/comboboxIds'
const { isOpen, open, clear } = useMirrorCombobox(ComboboxId.City)
</script>
<template>
<Button :aria-expanded="isOpen" @click="open()">Search cities</Button>
<Button @click="clear()">Reset</Button>
</template>
The state is created by whichever side asks for it first, so the toolbar may mount before the combobox does.
Styling from state
Every part writes its state to data-*, so the styling is a matter of
attribute selectors.
.item[data-highlighted='true'] {
background: var(--app-color-primary-bg-subtle);
}
.item[data-disabled='true'] {
opacity: 0.5;
}
.trigger[data-popup-open='true'] {
transform: rotate(180deg);
}
The content’s four boxes are not parts, so they are reached by their
data-scope or by the class each one ships. A class on Combobox.Content
is applied to the popup, the box you paint.
<template>
<Combobox.Content class="popup" />
</template>
.mirror-combobox-floating {
--mirror-combobox-floating-z-index: 80;
}
.popup[data-list-empty='true'] {
min-height: 0;
}
[data-scope='list'][data-multiple='true'] {
padding-inline-start: 0;
}
API reference
Module. A bundled options object on the root, and useMirrorCombobox(id)
as the programmatic API. Every part that reads the combobox takes id to
resolve one it is not nested inside.
Combobox.Root
Renders its children plus one visually hidden <input> per selected value.
Props
disabled, readOnly, required and name resolve in one order everywhere:
the options object first, then the surrounding Field, then false. Leave
the option unset and the field answers; set it, false included, and it wins.
Control is decided once, on the first render, for modelValue, inputValue
and open alike. A binding that later turns undefined does not hand control
back.
Options
ComboboxOptions is SelectOptions without
typeahead, plus the flags of its own listed first below.
Emits
A disabled combobox emits nothing, and a read-only one refuses opening, selecting and clearing before any of them reach the state.
Combobox.Input
Renders an <input role="combobox" type="text" autocomplete="off"> with
aria-haspopup="listbox", the focused element for the whole interaction. It is
a void element, so there is no default slot.
Props
Data attributes
The field state set, plus the combobox-only attributes below.
Combobox.InputGroup
Optional. Renders a <div> around the input and whatever sits beside it,
republishing the popup state so the whole control can be styled from one
element. It also becomes the anchor Combobox.Content measures.
Slot props
Combobox.Value
Renders a <span> with the selected labels, joined with a comma. Useful beside
a Combobox.Input that holds a search query rather than the selection, and as
the source for a chip loop.
Props
Slot props
Combobox.Icon
Renders a <span aria-hidden="true"> with data-popup-open, for the disclosure
marker. No props beyond id and the primitive ones.
Combobox.Trigger
Renders a <button type="button" tabindex="-1" aria-hidden="true"> that toggles
the popup and hands focus straight back to the input.
Props
Slot props
Combobox.Clear
Renders a <button type="button" tabindex="-1"> that clears the value and the
input text, then returns focus to the input. It is not rendered while there is
nothing to clear, and refuses to act while the combobox is disabled or
read-only.
One clear button per combobox, so options.forceMount.clear is the only home
for keeping it mounted with nothing to clear.
Slot props
Combobox.Chips
Renders a <div> holding one Combobox.Chip per selection, plus the input.
Only meaningful under options.multiple.
Slot props
Combobox.Chip
Renders a <div> standing for one selected value, and tells the
Combobox.ChipRemove inside it which value to drop.
Props
Slot props
Combobox.ChipRemove
Renders a <button type="button" tabindex="-1"> that drops its chip’s value and
returns focus to the input. Throws missing_context outside a Combobox.Chip.
Combobox.Empty
Renders a <div aria-live="polite" aria-atomic="true"> that stays mounted for
the whole interaction. Only its content comes and goes, and while there is
something to show it is hidden.
One empty state per combobox, so options.forceMount.empty is the only home
for filling it regardless of the open state and the item count.
Combobox.Status
Renders a <div role="status" aria-live="polite" aria-atomic="true"> for
loading text and result counts. It says nothing on its own.
Slot props
Combobox.Content
Renders everything that floats: the teleport, the floating box, the popup and
the listbox. Everything in its default slot renders inside the listbox. It
anchors on Combobox.InputGroup when there is one and on Combobox.Input
otherwise, and a pointer down or a focus move inside the input, the group or
the trigger is not treated as an outside dismissal.
Layers
None of the four is a part, so each is styled through its data-scope or the
class it ships. A class on the content itself is applied to the popup.
Props
Every prop below but anchor is an escape hatch over an option, forceMount
over options.forceMount.popup and the rest over the matching
options.floating key, and only side and align stay reactive after mount.
Slots
Slot props
All three slots take the same props.
Combobox.Item
Renders a <div role="option">. Items register in document order, so grouping
and portals cannot scramble the arrow-key sequence. Naming a <button> in as
gets the button semantics with it, including the native disabled attribute.
Props
Slot props
Combobox.Group
Renders a <div role="group"> around a run of items, pointing aria-labelledby
at the Combobox.GroupLabel inside it. No props beyond the primitive ones.
Combobox.GroupLabel
Renders a <div> and names its group. Throws missing_context outside a
Combobox.Group.
Combobox.Separator
Renders a <div role="separator"> between groups.
Props
Combobox.ItemText
Renders a <span> and registers its text as the item’s label when no label
prop was given.
Combobox.ItemIndicator
Renders a <span aria-hidden="true"> while the item is selected.
Props
One indicator per item, so forceMount is configured on the part and nowhere
else. Set it on the indicator you want kept in the tree while its item is
unselected. Every other indicator is left where it was.
Slot props
Composable
useMirrorCombobox(id) reaches a combobox from anywhere in the app.
Data attributes
An input has a native placeholder, so data-placeholder from Select never
appears here.
CSS variables
Errors
Accessibility
The input takes role="combobox", aria-autocomplete="list", aria-expanded,
aria-controls and aria-activedescendant, and focus never leaves it, so every
key below is handled there and all of them are ignored while the combobox is
disabled.
Every keyboard move scrolls the highlighted item into view, so a popup capped by
--mirror-combobox-popup-max-height cannot lose the highlight off screen. A
pointer highlight leaves the scroll position where the reader put it.
Typing goes into the input rather than into a typeahead search, which is why
there is no typeahead option here. The trigger, the chips and the items
suppress pointerdown, so clicking any of them never pulls focus out of the
input.
Combobox.Empty and Combobox.Status are both polite live regions, and they
divide the work: Empty announces that a filter found nothing, Status
announces whatever else the consumer wants said, such as a loading state or a
result count.