Skip to content

Autocomplete

A text input that suggests as you type, where the text is the value.

View source View as Markdown

Autocomplete is a text field that offers suggestions while the user types, and the text in the field is the value. Reach for it when anything the user writes is acceptable and the list is there to save typing. When only a value from the list will do, that is a Combobox.

Start typing to search
App.vue
<template>
  <div class="flex w-64 flex-col gap-1.5">
    <Autocomplete.Root v-model="value" :items="fonts" :options="options">
      <Autocomplete.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-full 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]"
          >
            <Autocomplete.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"
          >
            Font
          </span>
        </label>

        <Autocomplete.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>
        </Autocomplete.Clear>

        <Autocomplete.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"
        >
          <Autocomplete.Icon
            class="flex items-center transition-transform duration-100 ease-linear data-[popup-open=true]:rotate-180"
          >
            <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>
          </Autocomplete.Icon>
        </Autocomplete.Trigger>
      </Autocomplete.InputGroup>

      <Autocomplete.Status
        v-slot="{ count, empty, open }"
        class="type-component-2xs text-primary-muted px-1 tabular-nums"
      >
        <template v-if="open">
          {{ empty ? 'No matching fonts' : `${count} fonts` }}
        </template>
        <template v-else>Start typing to search</template>
      </Autocomplete.Status>

      <Autocomplete.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]"
      >
        <Autocomplete.Collection v-slot="{ item, value: font }">
          <Autocomplete.Item
            class="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 px-5.5 transition-colors duration-100 ease-linear select-none [--mirror-autocomplete-item-cursor:pointer]"
            :value="font"
          >
            <span class="truncate">{{ item }}</span>
          </Autocomplete.Item>
        </Autocomplete.Collection>

        <Autocomplete.Empty
          class="type-component-lg text-primary-muted flex h-12 items-center pl-5.5"
        >
          No matching fonts.
        </Autocomplete.Empty>
      </Autocomplete.Content>
    </Autocomplete.Root>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Autocomplete } from '@maas/mirror/vue'

const options = {
  autoHighlight: true,
  openOnInputClick: true,
  portal: { disabled: true },
  floating: { sideOffset: 28 },
}

const fonts = [
  'Alte Haas Grotesk',
  'Basis Grotesque',
  'Bely Display',
  'Cardinal Fruit',
  'Domaine Text',
  'Editorial New',
  'Fraunces',
  'Gambetta',
  'Instrument Serif',
  'Louize',
]

const value = ref('')
</script>

Usage guidelines

  • Hand the root an items array and it filters for you. Pass filter: null when the narrowing already happened somewhere else, on a server for instance, and render whatever you were given.
  • Make sure the autocomplete has an accessible name, either through a Label inside a Field or an aria-label on the input.
  • Autocomplete.Status is a live region, so keep it mounted outside the popup. A region that appears together with its first text is announced unreliably.
  • Autocomplete.Content teleports to the end of <body> unless you turn that off with options.portal.disabled. Leave it on where the field sits inside anything with overflow: hidden.
  • The content resolves everything but side and align once, during setup, so change the rest before the popup first renders or key the root to remount it.

Anatomy

Assemble the parts. Autocomplete.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.

NameRequiredDescription
Autocomplete.Root true Renders its children. Owns the text, the source items and the filtering.
Autocomplete.InputGroupOptional. Renders a <div> around the input and its buttons, carrying the popup state.
Autocomplete.Input true Renders an <input role="combobox">. Holds focus for the whole interaction.
Autocomplete.ClearRenders a <button>. Empties the text.
Autocomplete.TriggerRenders a <button>. Toggles the popup without ever taking focus.
Autocomplete.IconRenders a <span aria-hidden="true"> that follows the open state.
Autocomplete.ValueRenders a <span> holding the current text.
Autocomplete.StatusRenders a <div role="status"> announcing how many items are left.
Autocomplete.Content true Renders the teleport, the floating box, the popup and the <div role="listbox">.
Autocomplete.CollectionRenders nothing. Walks the filtered items through a scoped slot.
Autocomplete.Item true Renders a <div role="option">.
Autocomplete.GroupRenders a <div role="group"> holding one group’s items.
Autocomplete.GroupLabelRenders a <div> and names the group.
Autocomplete.SeparatorRenders a <div role="separator">.
Autocomplete.RowRenders a <div role="row"> for a grid layout.
Autocomplete.EmptyRenders a <div> while the popup is open and no item is registered.
<script setup lang="ts">
import { Autocomplete, Field, Label } from '@maas/mirror/vue'
</script>

<template>
  <Field.Root :options="{ name: 'font' }">
    <Label>Font</Label>
    <Autocomplete.Root v-model="font" :items="fonts">
      <Autocomplete.Input placeholder=" " />
      <Autocomplete.Clear />
      <Autocomplete.Trigger>
        <Autocomplete.Icon />
      </Autocomplete.Trigger>
      <Autocomplete.Status v-slot="{ count }">{{ count }} fonts</Autocomplete.Status>
      <Autocomplete.Content>
        <Autocomplete.Collection v-slot="{ item, value }">
          <Autocomplete.Item :value="value">{{ item }}</Autocomplete.Item>
        </Autocomplete.Collection>
        <Autocomplete.Empty>No matching fonts.</Autocomplete.Empty>
      </Autocomplete.Content>
    </Autocomplete.Root>
  </Field.Root>
</template>

Examples

The four modes

options.mode decides two things at once: whether the list narrows as the user types, and whether the highlighted item is written back into the input.

ModeThe listThe input
'list'
Narrows as the query changes.Only ever what the user typed.
'both'Narrows as the query changes.Completed from the highlighted item.
'inline'Stays whole.Completed from the highlighted item.
'none'Stays whole.Only ever what the user typed.
<template>
  <Autocomplete.Root v-model="font" :items="fonts" :options="{ mode: 'both' }" />
</template>

Under both and inline the highlighted item’s label goes into the field while the query stays what the user typed, and the part they did not type is left selected, so the next keystroke replaces it. The input takes data-completing for as long as that lasts, and moving the highlight away or pressing Escape puts the typed text back.

.input[data-completing='true'] {
  color: var(--app-color-primary-fg-muted);
}

Filtering

The default filter keeps every item whose text contains the query, ignoring case and accents, so “jose” finds “José”. Replace it with your own to match differently.

<template>
  <Autocomplete.Root
    :items="fonts"
    :options="{
      filter: (item, query, toStringValue) =>
        toStringValue(item).toLowerCase().startsWith(query.toLowerCase()),
    }"
  />
</template>

Set filter: null when the items arrive already narrowed, from a server for instance, and the root renders them untouched. limit caps how many items make it through, so a long list never paints in full.

Items can be anything, not just strings. itemToStringValue is how the filter, the default label and Autocomplete.Collection read one.

<template>
  <Autocomplete.Root
    :items="fonts"
    :options="{ itemToStringValue: (item) => item.family }"
  />
</template>

Grouping

A grouped source is an array of objects that each carry their own items. The outer Autocomplete.Collection walks the groups, the inner one walks the items of the group it sits in, and a group with nothing left after filtering is dropped rather than rendered as a bare heading.

<script setup lang="ts">
const fonts = [
  { value: 'Sans', items: ['Alte Haas Grotesk', 'Basis Grotesque'] },
  { value: 'Serif', items: ['Bely Display', 'Cardinal Fruit'] },
]
</script>

<template>
  <Autocomplete.Content>
    <Autocomplete.Collection v-slot="{ item: group, index }">
      <Autocomplete.Separator v-if="index > 0" />
      <Autocomplete.Group :items="group.items">
        <Autocomplete.GroupLabel>{{ group.value }}</Autocomplete.GroupLabel>
        <Autocomplete.Collection v-slot="{ item, value }">
          <Autocomplete.Item :value="value">{{ item }}</Autocomplete.Item>
        </Autocomplete.Collection>
      </Autocomplete.Group>
    </Autocomplete.Collection>
  </Autocomplete.Content>
</template>

Telling the user what happened

Autocomplete.Status is a polite live region with the registered item count in its slot. Write the sentence yourself, because only you know the noun. Nothing is registered while the popup is closed, so read open as well or a field nobody has touched reads as a field with no matches.

<template>
  <Autocomplete.Status v-slot="{ count, empty, open }">
    <template v-if="open">
      {{ empty ? 'No matching fonts' : `${count} fonts` }}
    </template>
    <template v-else>Start typing to search</template>
  </Autocomplete.Status>
</template>

Autocomplete.Empty covers the same state visually, inside the popup, and reads the registered items rather than your array, which keeps it right once the items are grouped or virtualised.

When the popup opens

Typing always opens it. Beyond that there are two switches.

<template>
  <Autocomplete.Root
    :options="{ openOnInputClick: true, autoHighlight: true }"
  />
</template>

openOnInputClick opens the popup when the user clicks into a field that already has text, and autoHighlight seeds the first enabled item so Enter always has something to commit. Both are off by default, because a seeded highlight lets Enter commit an item the user never chose.

Inside a field

Autocomplete.Input is a field control, and a native one, so the form reads it directly and no hidden input is needed.

<template>
  <Field.Root :options="{ name: 'font' }">
    <Label>Font</Label>
    <Autocomplete.Root v-model="font" :items="fonts">
      <Autocomplete.Input />
    </Autocomplete.Root>
    <Field.Error />
  </Field.Root>
</template>

The field’s disabled, readOnly, required and name reach the input unless the root or the input overrides them, and the validity comes back out as data-valid and data-invalid.

Positioning

Autocomplete.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>
  <Autocomplete.Root
    :options="{
      floating: { side: 'top', align: 'center', sideOffset: 8, flip: false },
    }"
  />
</template>

Wrap the control in an Autocomplete.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>
  <Autocomplete.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-autocomplete-popup-min-width);
  max-height: var(--mirror-autocomplete-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>
  <Autocomplete.Root :options="{ portal: { to: '#overlays' } }" />
  <Autocomplete.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>
  <Autocomplete.Root :options="{ modal: true, backdrop: true }" />
</template>

Reaching the autocomplete from anywhere

Give the root an id and useMirrorAutocomplete(id) opens, clears and reads that autocomplete from anywhere in the app.

<script setup lang="ts">
import { useMirrorAutocomplete } from '@maas/mirror/vue'
import { AutocompleteId } from '~/constants/autocompleteIds'

const { isOpen, open, clear } = useMirrorAutocomplete(AutocompleteId.Font)
</script>

<template>
  <Button :aria-expanded="isOpen" @click="open()">Search fonts</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 autocomplete 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;
}

.icon[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 Autocomplete.Content is applied to the popup, the box you paint.

<template>
  <Autocomplete.Content class="popup" />
</template>
.mirror-autocomplete-floating {
  --mirror-autocomplete-floating-z-index: 80;
}

.popup[data-list-empty='true'] {
  min-height: 0;
}

[data-scope='list'][data-grid='true'] {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
}

API reference

Module. A bundled options object on the root, and useMirrorAutocomplete(id) as the programmatic API. Every part takes id to resolve an autocomplete it is not nested inside.

Autocomplete.Root

Renders its children. It owns the text, the source items and the filtering, and renders no element of its own.

Props

PropTypeDefault
id
stringgenerated
modelValue
string | undefinedundefined
defaultValue
string''
open
boolean | undefinedundefined
defaultOpen
booleanfalse
items
AutocompleteSource[]
options
AutocompleteOptionssee below

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.

Options

OptionTypeDefault
mode
'list' | 'both' | 'inline' | 'none''list'
filter
((item, query, itemToStringValue) => boolean) | nullcontains
itemToStringValue
(item: unknown) => stringsee description
limit
number-1
grid
booleanfalse
openOnInputClick
booleanfalse
autoHighlight
booleanfalse
modal
booleanfalse
loop
booleantrue
disabled
booleanundefined
readOnly
booleanundefined
required
booleanundefined
name
stringundefined
highlightOnHover
booleantrue
dismiss.escape
booleantrue
dismiss.pointerDownOutside
booleantrue
dismiss.focusOutside
booleantrue
focus.trapped
booleanfalse
focus.restore
booleanfalse
backdrop
booleanfalse
portal.to
string | HTMLElementbody
portal.disabled
booleanfalse
portal.defer
booleanfalse
floating.side
'top' | 'right' | 'bottom' | 'left''bottom'
floating.sideOffset
number4
floating.align
'start' | 'center' | 'end''start'
floating.alignOffset
number0
floating.strategy
'absolute' | 'fixed''absolute'
floating.flip
booleantrue
floating.shift
booleantrue
floating.collisionPadding
number8
forceMount.popup
booleanfalse
forceMount.empty
booleanfalse
forceMount.clear
booleanfalse
transition.popup
string'mirror-autocomplete-popup'
transition.backdrop
string'mirror-autocomplete-backdrop'

Emits

EmitPayload
update:modelValue
string
update:open
boolean
highlightChange
string | number | null

Slot props

PropType
items
AutocompleteSource
value
string

Autocomplete.Input

Renders an <input role="combobox" type="text" autocomplete="off">, the focused element for the whole interaction. It is a void element, so there is no default slot. aria-autocomplete follows options.mode.

Props

PropTypeDefault
placeholder
stringundefined
disabled
booleanoptions.disabled
readOnly
booleanoptions.readOnly
required
booleanoptions.required

Data attributes

The field state set, plus the autocomplete-only attributes below.

Autocomplete.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 Autocomplete.Content measures.

Slot props

PropType
open
boolean

Autocomplete.Trigger

Renders a <button type="button" tabindex="-1" aria-hidden="true"> that toggles the popup and hands focus straight back to the input.

Props

PropTypeDefault
disabled
booleanoptions.disabled

Slot props

PropType
open
boolean
disabled
boolean

Autocomplete.Clear

Renders a <button type="button" tabindex="-1"> that empties the text and returns focus to the input. It is not rendered while there is nothing to clear, and refuses to act while the autocomplete is disabled or read-only.

One clear button per autocomplete, so options.forceMount.clear is the only home for keeping it mounted with nothing to clear.

Autocomplete.Value

Renders a <span> holding the current text. The default slot content is that text, so an empty part is already useful.

Slot props

PropType
value
string

Autocomplete.Icon

Renders a <span aria-hidden="true"> carrying data-popup-open, for the chevron that turns.

Slot props

PropType
open
boolean

Autocomplete.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 Autocomplete.InputGroup when there is one and on Autocomplete.Input otherwise.

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.

LayerElementPublishes
portal
nonenothing
backdrop
div[role=presentation]data-autocomplete, data-state
floating
divdata-side, data-align, data-anchor-hidden, data-state
popup
divdata-autocomplete, data-side, data-align, data-state, data-list-empty
list
div[role=listbox], role=grid under options.griddata-grid, data-list-empty

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.

PropTypeDefault
forceMount
booleanoptions.forceMount.popup
anchor
HTMLElement | (() => HTMLElement | null)the input group, then the input
side
'top' | 'right' | 'bottom' | 'left''bottom'
sideOffset
number4
align
'start' | 'center' | 'end''start'
alignOffset
number0
strategy
'absolute' | 'fixed''absolute'
flip
booleantrue
shift
booleantrue
collisionPadding
number8

Slots

SlotRenders
defaultInside the listbox. Items, groups, rows, separators and the empty state.
headerInside the popup, above the listbox. Anything that is not an option.
footerInside the popup, below the listbox.

Slot props

All three slots take the same props.

PropType
open
boolean
side
AutocompleteSide
align
AutocompleteAlign
items
AutocompleteSource
grouped
boolean

Autocomplete.Collection

Renders nothing of its own and walks the filtered items through its scoped slot. Inside an Autocomplete.Group it walks that group’s items instead, which is how one component covers both levels of a grouped source.

Slot props

PropType
item
unknown
value
string
index
number

Autocomplete.Item

Renders a <div role="option">. Items register in document order, so grouping, rows and portals cannot scramble the arrow-key sequence.

Props

PropTypeDefault
value
string | numbernone
label
stringthe rendered text
disabled
booleanfalse

Slot props

PropType
selected
boolean
highlighted
boolean
disabled
boolean
index
number

Autocomplete.Group

Renders a <div role="group"> labelled by its Autocomplete.GroupLabel, and holds the items a nested Autocomplete.Collection walks.

Props

PropTypeDefault
items
Array<unknown>[]

Slot props

PropType
items
Array<unknown>

Autocomplete.GroupLabel

Renders a <div> and names the group it sits in. Throws missing_autocomplete_group outside one.

Autocomplete.Separator

Renders a <div role="separator">.

Props

PropTypeDefault
orientation
'horizontal' | 'vertical''horizontal'

Autocomplete.Row

Renders a <div role="row">, for items laid out in a grid under options.grid.

Props

PropTypeDefault
index
numberundefined

Autocomplete.Status

Renders a <div role="status" aria-live="polite" aria-atomic="true"> holding whatever sentence you write from the item count.

Slot props

PropType
count
number
empty
boolean
open
boolean

Autocomplete.Empty

Renders a <div role="presentation"> while the popup is open and no Autocomplete.Item is registered.

One empty state per autocomplete, so options.forceMount.empty is the only home for keeping it mounted regardless of the open state and the item count.

Slot props

PropType
query
string

Composable

useMirrorAutocomplete(id) reaches an autocomplete from anywhere in the app.

KeyType
isOpen
ComputedRef<boolean>
open
() => void
close
(restoreFocus?: boolean) => void
toggle
() => void
clear
() => void
value
ComputedRef<string>
setValue
(next: string) => void
query
ComputedRef<string>
highlightedValue
ComputedRef<AutocompleteValueType | null>
filteredItems
ComputedRef<AutocompleteSource>

Data attributes

PartAttributeValue
Autocomplete.Input, Autocomplete.InputGroup, Autocomplete.Trigger, Autocomplete.Clear, Autocomplete.Item
data-disabled
true
Autocomplete.Input
data-readonly
true
Autocomplete.Input
data-required
true
Autocomplete.Input
data-valid
true
Autocomplete.Input
data-invalid
true
Autocomplete.Input
data-dirty
true
Autocomplete.Input
data-touched
true
Autocomplete.Input, Autocomplete.Value
data-filled
true
Autocomplete.Input
data-focused
true
Autocomplete.Input, Autocomplete.Content
data-autocomplete
the instance ID
Autocomplete.Input, Autocomplete.InputGroup, Autocomplete.Value
data-completing
true
Autocomplete.Input, Autocomplete.InputGroup, Autocomplete.Trigger, Autocomplete.Icon, Autocomplete.Status
data-popup-open
true
Autocomplete.Content
data-scope
floating | popup | list | backdrop
Autocomplete.Content, Autocomplete.Empty
data-state
open | closed
Autocomplete.Input, Autocomplete.InputGroup, Autocomplete.Content
data-side
top | right | bottom | left
Autocomplete.Input, Autocomplete.InputGroup, Autocomplete.Content
data-align
start | center | end
Autocomplete.Content
data-anchor-hidden
true
Autocomplete.Input, Autocomplete.InputGroup, Autocomplete.Trigger, Autocomplete.Content, Autocomplete.Status, Autocomplete.Empty
data-list-empty
true
Autocomplete.Content
data-grid
true
Autocomplete.Item
data-selected
true
Autocomplete.Item
data-highlighted
true
Autocomplete.Item
data-index
the index

CSS variables

VariableDefault
--mirror-autocomplete-popup-max-height
var(--rack-floating-available-height, none)
--mirror-autocomplete-popup-min-width
var(--rack-floating-anchor-width, auto)
--mirror-autocomplete-floating-z-index
50
--mirror-autocomplete-backdrop-position
fixed
--mirror-autocomplete-backdrop-z-index
50
--mirror-autocomplete-trigger-cursor
pointer
--mirror-autocomplete-clear-cursor
pointer
--mirror-autocomplete-item-cursor
default
--mirror-autocomplete-item-disabled-cursor
not-allowed

Errors

Code
missing_autocomplete_context
missing_autocomplete_input
duplicate_autocomplete_input
missing_autocomplete_group
missing_item_value
duplicate_item_value

Accessibility

The input takes role="combobox", aria-autocomplete, 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 autocomplete is disabled. aria-controls is only present while the list is, so it never points at an element that has been unmounted.

KeyBehaviour
Printable charactersNative. Types into the input and opens the popup, and the list narrows under list and both.
ArrowDown / ArrowUpOpens the popup while it is closed: ArrowDown lands on the first item and ArrowUp on the last. While it is open, moves the highlight. Under both and inline the move writes the highlighted item into the input.
PageDown / PageUpMoves the highlight ten items while the popup is open. Clamps at the ends whatever loop says.
Home / EndMoves the highlight to the first or last enabled item while the popup is open. Native caret movement while it is closed.
EnterWhile open, fills the input from the highlighted item and closes. With nothing highlighted it closes and the typed text stands. While closed it is native, so the form submits.
EscapeCloses the popup and puts the typed text back over any completion showing.
TabCloses the popup and moves on, without preventing the default. Nothing is committed.

Typing goes into the input rather than into a typeahead search, which is why there is no typeahead option here. The trigger, the clear button and the items all suppress pointerdown, so clicking any of them never pulls focus out of the input.