GrAutocomplete
A combobox with type-ahead search over options, async loading and multi-select chips.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- there are too many options for a list — users, cities, tags, repositories: they are searched by typing, not browsed;
- the list comes from a server —
sourceanddebouncetogether with the “searching” and “nothing found” states; - a minimum number of characters is needed —
minQueryLengthkeeps the first letter from reaching the server; - there are several values —
multipleshows the selection as chips, each removable from the keyboard; - the value may be missing from the reference list —
allowCustomValuepermits one of your own.
When to take something else
| Need | Take |
|---|---|
| There are 5–50 options and they are browsed, not searched | GrSelect |
| The options are a tree with levels | GrTreeSelect |
| An application command is being searched, not a field value | GrCommandPalette |
| There is no reference list at all, strings are typed | GrInputTag |
| There are 2–5 options and they are visible at once | GrSegmented |
Remote search
Two paths, mutually exclusive by meaning.
fetchOptions — the component runs the request:
<GrAutocomplete
v-model="user"
:fetch-options="fetchPeople"
:min-query-length="1"
@search-error="reportError"
/>
async function fetchPeople(query: string, signal: AbortSignal) {
const response = await fetch(`/api/people?q=${query}`, { signal })
return response.json()
}
Debouncing, cancelling the previous request and loading are on the component.
The answer to a cancelled request is ignored: while typing quickly, the list
always holds the result of the last request rather than of the one that came back
later. Local filtering is switched off in this mode — the server filters the list.
An error (other than a cancellation) leaves as a searchError event, and the list
becomes empty.
search — the application runs the request:
<GrAutocomplete
v-model="user"
:options="options"
:loading="pending"
:filterable="false"
@search="load"
/>
The path for those whose request lives in a store, a cache or a cancellation of its own. The race between answers is then settled by the application: the component only emits the debounced query and does not know which answer was brought to it.
Arbitrary values
allowCustomValue adds an “Add …” option to the list. It is an option like any
other and takes part in arrow navigation: without that, committing a value of
your own from the keyboard was impossible — with a non-empty list Enter always
went to the active option.
The value is typed as text, so it is a string by nature: with a numeric TValue
the branch does not apply.
Chips (multiple)
The crosses on the chips are deliberately not tabbable: in a combobox the
focus lives on the <input>, and twenty selected values must not produce twenty
Tab stops. Navigation is by arrows: ← from an empty query moves to the last
chip, ←/→ walk between them, Delete/Backspace removes one, and Esc, →
from the last one and any printable character return to the field.
Backspace in an empty field still removes the last chip without entering
navigation.
What the panel holds
The direct children of role="listbox" are options only: the role declares
everything else an invalid child. Loading, “type N more characters” and “nothing
found” live below the list in a single live region
role="status" aria-live="polite" — otherwise the asynchronous states would change
silently.
The options do not take focus (mousedown is suppressed), so selecting with the
mouse leaves the caret in the field — the panel does not reopen and the tab order
is not disturbed.
Virtualisation
virtual keeps in the DOM only the window around the viewport — the height of the
window is set by dropdownMaxHeight. There is one scenario it is meant for: a
remote search across a reference list with thousands of matches.
<GrAutocomplete v-model="city" :options="cities" virtual />
Three consequences worth knowing in advance.
The size of the set is declared explicitly. In the ordinary mode a screen
reader derives it from the DOM, but with an incomplete set it would say “1 of 12”
about a list of ten thousand. That is why with virtual the options carry
aria-setsize and aria-posinset — for the whole filtered list, not for the
window. In the ordinary mode those attributes are absent: there they would be
noise.
The “Add …” row is a member of the set. With allowCustomValue it stands
first and scrolls with the list rather than sticking to the top of the panel: the
keyboard walks through it like through an ordinary option anyway.
The active option is always mounted. The arrows scroll the list to it before
moving aria-activedescendant — otherwise the attribute would point at a node
that is not in the DOM. How the primitive works and what it costs —
virtual-list.md.
Programmatic control
const box = useTemplateRef('box')
box.value?.focus()
box.value?.open()
box.value?.close()States
disabled and readonly arrive both as props and from
GrFormField. Both lock the control the same way: the panel
does not open, options are not selected, chips are not removed, there is no clear
button. The difference is only in semantics — readonly gives its value to the
form and is announced as aria-readonly.
Slots
| Slot | What it replaces |
|---|---|
prefix / suffix | the addon in the field shell |
option | the content of an option (option, selected) |
loading | the loading row |
empty | the “nothing found” text |
The `prefix` / `suffix` addons
The slots put an icon, a unit or a label into the shell; the width is bounded by
six props (prefixMinWidth/prefixMaxWidth/prefixFixed and the same for the
suffix). The shared contract of the controls —
form-controls.md.
Panel control and the native form
The panel is controllable through v-model:open (the shared contract of panel
overlays, as in GrPopover): without the open prop the previous uncontrolled
behaviour holds, with it the parent owns the state and update:open accompanies
every change.
The name prop enables participation in a native form: hidden inputs serialise
the value of the model, not the text of the query — one per value with
multiple, nothing when the selection is empty.
`minQueryLength` and the starting list in remote mode
While the query is shorter than minQueryLength, the panel shows the “type N more
characters” hint rather than the results of the previous query — a stale list
below the hint would misinform. With allowCustomValue the hint stands next to
the “Add …” row: that row explains what to do with what has been typed, but not
where the options went.
In remote mode (fetchOptions) the options prop is the starting list until the
first answer from the server. Replacing it in the parent makes it the source again
until the next answer and cancels the request in flight: that one belongs to the
previous data set. The change is tracked by content, not by the identity of the
array, so an inline literal :options="[...]" is safe — a list recreated on
re-render with the same options does not touch the remote results.
Playground 31
Loading…
<GrAutocomplete />Install
npm i @feugene/granularityImport
import { GrAutocomplete } from '@feugene/granularity/components/GrAutocomplete'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
modelValuerequired | GrAutocompleteModelValue<TValue> | — | The selected value (single — a string, multiple — an array of strings). |
options | GrAutocompleteOption<TValue>[] | undefined | undefined | The available options. In the local mode — the full list (filtered on the client). In the remote mode (`filterable=false`) — the list the parent updates in response to the `search` event. |
multiple | boolean | undefined | false | — |
tagTone | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined | "neutral" | The look of the chips of the selected values in the `multiple` mode. They are drawn by `GrChip`, but the scale of the prop stayed the badge one: `tagSize` is public, and its values are obliged to mean the same type size as before. The translation of the steps is in `chipSizeForBadgeScale`. |
tagDark | boolean | undefined | false | — |
tagSize | "xs" | "sm" | "md" | "lg" | undefined | "sm" | — |
tagRadius | GrBadgeRadius | undefined | "round" | — |
disabled | boolean | undefined | false | — |
readonly | boolean | undefined | false | Read-only: the value is visible and leaves with the form, but is not edited. |
invalid | boolean | undefined | false | The visual and ARIA state of an error. |
required | boolean | undefined | false | A required field (`aria-required`). |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
placeholder | string | undefined | undefined | — |
ariaLabel | string | undefined | undefined | — |
clearable | boolean | undefined | undefined | A button that clears the selected value or the query. |
loading | boolean | undefined | false | The loading state governed from the outside (for async scenarios). |
filterable | boolean | undefined | true | Local filtering of the options by the typed query. Switch it off (`false`) for a purely remote search — then `options` are shown as they are, and the filtering is done by the server on the `search` event. |
filter | ((option: GrAutocompleteOption<TValue>, query: string) => boolean) | undefined | undefined | A matcher of your own for the local filtering. By default — a substring in `label`/`value`. |
fetchOptions | ((query: string, signal: AbortSignal) => Promise<GrAutocompleteOption<TValue>[]>) | undefined | undefined | Remote loading of the options run by the component: debouncing, cancelling a stale request and `loading` are taken on by it. The answer to a cancelled request is ignored — while typing quickly the list always holds the result of the last request rather than of the one that came back later. `signal` is passed into `fetch`. Local filtering is switched off in this mode: the server filters the list. The alternative is the `search` event, if the request is run by the application itself. |
minQueryLength | number | undefined | 0 | The minimum length of the query before `search` is emitted (for debouncing the remote loading). |
debounce | number | undefined | 250 | The debounce delay of the `search` event, in ms. |
allowCustomValue | boolean | undefined | false | Allow entering and committing a value that is not in `options`. |
closeOnSelect | boolean | undefined | true | Close the panel after a selection (single always closes). |
dropdownMaxHeight | number | undefined | 280 | The maximum height of the panel, in px. |
virtual | boolean | undefined | false | Virtualisation of the panel: only the window around the viewport lives in the DOM. The height of the window is set by `dropdownMaxHeight`. Switch it on deliberately: on a hundred options there is no gain, while only the window is left in the markup — and with it changes what the consumer’s `querySelector` finds. The scenario it is meant for is a remote search across a reference list of thousands of entries. |
loadingText | string | undefined | undefined | The i18n texts of the panel states and of the aria labels. |
noResultsText | string | undefined | undefined | — |
clearLabel | string | undefined | undefined | — |
open | boolean | undefined | undefined | The controlled state of the panel (`v-model:open`). Without the prop the panel runs itself (uncontrolled), with it — listen to `update:open` and change the prop. |
name | string | undefined | undefined | The name for a native form: a hidden input carrying the value of the model (not the text of the query). |
prefixMinWidth | string | undefined | undefined | The widths of the `prefix`/`suffix` addons — the shared contract of the controls of the package (`docs/form-controls.md`). |
prefixMaxWidth | string | undefined | undefined | — |
suffixMinWidth | string | undefined | undefined | — |
suffixMaxWidth | string | undefined | undefined | — |
prefixFixed | boolean | undefined | false | — |
suffixFixed | boolean | undefined | false | — |
Slots
| Slot | Type | Description |
|---|---|---|
prefix | any | An addon to the left of the input field. |
suffix | any | An addon on the right, before the spinner and the cross. |
option | { option: GrAutocompleteOption<TValue>; selected: boolean; } | A row of the list instead of the label of an option. |
loading | any | The content of the panel while the options are on their way. |
empty | any | The content of the panel when there are no matching options. |
Events
| Event | Type | Description |
|---|---|---|
update:modelValue | [GrAutocompleteModelValue<TValue>] | — |
search | [string] | The debounced search query — the way in for the remote loading of options. |
searchError | [unknown] | The `fetchOptions` request failed (a cancellation of a stale one does not arrive here). |
change | [GrAutocompleteModelValue<TValue>] | The value was committed by selecting or removing an option. |
update:open | [boolean] | The panel opened or closed (`v-model:open`). |
clear | [] | The value was removed with the clear button; only with `clearable`. |
focus | [FocusEvent] | — |
blur | [FocusEvent] | — |
Examples 5
Addons in the field
<script setup lang="ts">
import { ref } from 'vue'
import { GrAutocomplete } from '@feugene/granularity'
const airports = [
{ value: 'LED', label: 'Saint Petersburg — LED' },
{ value: 'AER', label: 'Sochi — AER' },
{ value: 'KZN', label: 'Kazan — KZN' },
{ value: 'SVO', label: 'Moscow — SVO' },
]
const from = ref('LED')
</script>
<template>
<GrAutocomplete
v-model="from"
:options="airports"
clearable
placeholder="Where from?"
aria-label="Departure airport"
>
<template #prefix>
<span class="i-lucide-plane-takeoff block h-4 w-4" />
</template>
<template #suffix>
IATA
</template>
</GrAutocomplete>
</template>Filterable single select
Selected: —
<script setup lang="ts">
import { ref } from 'vue'
import { GrAutocomplete } from '@feugene/granularity'
const options = [
{ value: 'vue', label: 'Vue' },
{ value: 'react', label: 'React' },
{ value: 'svelte', label: 'Svelte' },
{ value: 'solid', label: 'Solid' },
{ value: 'angular', label: 'Angular' },
{ value: 'qwik', label: 'Qwik' },
{ value: 'preact', label: 'Preact' },
{ value: 'lit', label: 'Lit' },
]
const framework = ref('')
</script>
<template>
<div class="grid gap-3">
<GrAutocomplete
v-model="framework"
:options="options"
clearable
placeholder="Search a framework…"
aria-label="Search a framework"
/>
<p class="text-sm text-[var(--gr-muted-fg)]">
Selected: <code>{{ framework || '—' }}</code>
</p>
</div>
</template>Multiple with removable chips
Backspace on an empty query removes the last tag. Type a new value and press Enter to add a custom team.
<script setup lang="ts">
import { ref } from 'vue'
import { GrAutocomplete } from '@feugene/granularity'
const options = [
{ value: 'design', label: 'Design' },
{ value: 'platform', label: 'Platform' },
{ value: 'billing', label: 'Billing' },
{ value: 'support', label: 'Support' },
{ value: 'growth', label: 'Growth' },
{ value: 'security', label: 'Security' },
]
const teams = ref<string[]>(['design', 'platform'])
</script>
<template>
<div class="grid gap-3">
<GrAutocomplete
v-model="teams"
multiple
:options="options"
allow-custom-value
:close-on-select="false"
clearable
placeholder="Add teams…"
aria-label="Add teams"
/>
<p class="text-sm text-[var(--gr-muted-fg)]">
Backspace on an empty query removes the last tag. Type a new value and press Enter to add a custom team.
</p>
</div>
</template>Async remote loading
Options are fetched by the component itself: fetchOptions is debounced, the previous request is aborted through its AbortSignal, and a late answer to an outdated query never wins.
<script setup lang="ts">
import { ref } from 'vue'
import { GrAutocomplete, type GrAutocompleteOption } from '@feugene/granularity'
// Игрушечная «база» пользователей — эмулируем удалённый поиск с задержкой.
const DIRECTORY: GrAutocompleteOption[] = [
{ value: 'ada', label: 'Ada Lovelace' },
{ value: 'alan', label: 'Alan Turing' },
{ value: 'grace', label: 'Grace Hopper' },
{ value: 'linus', label: 'Linus Torvalds' },
{ value: 'margaret', label: 'Margaret Hamilton' },
{ value: 'dennis', label: 'Dennis Ritchie' },
{ value: 'ken', label: 'Ken Thompson' },
{ value: 'barbara', label: 'Barbara Liskov' },
]
const user = ref('')
// Разброс задержек нарочный: короткий запрос отвечает дольше длинного, поэтому
// без отмены устаревшего в списке оказался бы ответ на предыдущий ввод.
function latencyFor(query: string): number {
return Math.max(200, 900 - query.length * 150)
}
async function fetchPeople(query: string, signal: AbortSignal): Promise<GrAutocompleteOption[]> {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, latencyFor(query))
signal.addEventListener('abort', () => {
clearTimeout(timer)
reject(signal.reason)
})
})
const needle = query.toLowerCase()
return DIRECTORY.filter(o => o.label.toLowerCase().includes(needle))
}
</script>
<template>
<div class="grid gap-3">
<GrAutocomplete
v-model="user"
:fetch-options="fetchPeople"
:min-query-length="1"
clearable
placeholder="Search people (async)…"
aria-label="Search people"
/>
<p class="text-sm text-[var(--gr-muted-fg)]">
Options are fetched by the component itself: <code>fetchOptions</code> is debounced, the
previous request is aborted through its <code>AbortSignal</code>, and a late answer to an
outdated query never wins.
</p>
</div>
</template>Virtual
Selected: —
<script setup lang="ts">
import { ref } from 'vue'
import { GrAutocomplete } from '@feugene/granularity'
// Справочник, ради которого виртуализация и нужна: без неё каждая панель
// рендерила бы все совпадения разом.
const options = Array.from({ length: 10000 }, (_, index) => ({
value: `city-${index + 1}`,
label: `City ${index + 1}`,
}))
const city = ref('')
</script>
<template>
<div class="grid gap-3">
<GrAutocomplete
v-model="city"
:options="options"
virtual
clearable
placeholder="Search among 10 000 cities…"
aria-label="Search a city"
/>
<p class="text-sm text-[var(--gr-muted-fg)]">
Selected: <code>{{ city || '—' }}</code>
</p>
</div>
</template>Accessibility
- APG pattern
combobox (editable)