GrAutocomplete

Package: @feugene/granularitycoreGroup: forms

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 serversource and debounce together with the “searching” and “nothing found” states;
  • a minimum number of characters is neededminQueryLength keeps the first letter from reaching the server;
  • there are several valuesmultiple shows the selection as chips, each removable from the keyboard;
  • the value may be missing from the reference listallowCustomValue permits one of your own.

When to take something else

NeedTake
There are 5–50 options and they are browsed, not searchedGrSelect
The options are a tree with levelsGrTreeSelect
An application command is being searched, not a field valueGrCommandPalette
There is no reference list at all, strings are typedGrInputTag
There are 2–5 options and they are visible at onceGrSegmented

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

SlotWhat it replaces
prefix / suffixthe addon in the field shell
optionthe content of an option (option, selected)
loadingthe loading row
emptythe “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…

Code
<GrAutocomplete />

Install

npm i @feugene/granularity

Import

import { GrAutocomplete } from '@feugene/granularity/components/GrAutocomplete'

API

Props

PropTypedefaultDescription
modelValuerequiredGrAutocompleteModelValue<TValue>The selected value (single — a string, multiple — an array of strings).
optionsGrAutocompleteOption<TValue>[] | undefinedundefinedThe 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.
multipleboolean | undefinedfalse
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`.
tagDarkboolean | undefinedfalse
tagSize"xs" | "sm" | "md" | "lg" | undefined"sm"
tagRadiusGrBadgeRadius | undefined"round"
disabledboolean | undefinedfalse
readonlyboolean | undefinedfalseRead-only: the value is visible and leaves with the form, but is not edited.
invalidboolean | undefinedfalseThe visual and ARIA state of an error.
requiredboolean | undefinedfalseA required field (`aria-required`).
size"xs" | "sm" | "md" | "lg" | undefinedundefined
placeholderstring | undefinedundefined
ariaLabelstring | undefinedundefined
clearableboolean | undefinedundefinedA button that clears the selected value or the query.
loadingboolean | undefinedfalseThe loading state governed from the outside (for async scenarios).
filterableboolean | undefinedtrueLocal 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) | undefinedundefinedA matcher of your own for the local filtering. By default — a substring in `label`/`value`.
fetchOptions((query: string, signal: AbortSignal) => Promise<GrAutocompleteOption<TValue>[]>) | undefinedundefinedRemote 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.
minQueryLengthnumber | undefined0The minimum length of the query before `search` is emitted (for debouncing the remote loading).
debouncenumber | undefined250The debounce delay of the `search` event, in ms.
allowCustomValueboolean | undefinedfalseAllow entering and committing a value that is not in `options`.
closeOnSelectboolean | undefinedtrueClose the panel after a selection (single always closes).
dropdownMaxHeightnumber | undefined280The maximum height of the panel, in px.
virtualboolean | undefinedfalseVirtualisation 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.
loadingTextstring | undefinedundefinedThe i18n texts of the panel states and of the aria labels.
noResultsTextstring | undefinedundefined
clearLabelstring | undefinedundefined
openboolean | undefinedundefinedThe 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.
namestring | undefinedundefinedThe name for a native form: a hidden input carrying the value of the model (not the text of the query).
prefixMinWidthstring | undefinedundefinedThe widths of the `prefix`/`suffix` addons — the shared contract of the controls of the package (`docs/form-controls.md`).
prefixMaxWidthstring | undefinedundefined
suffixMinWidthstring | undefinedundefined
suffixMaxWidthstring | undefinedundefined
prefixFixedboolean | undefinedfalse
suffixFixedboolean | undefinedfalse

Slots

SlotTypeDescription
prefixanyAn addon to the left of the input field.
suffixanyAn 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.
loadinganyThe content of the panel while the options are on their way.
emptyanyThe content of the panel when there are no matching options.

Events

EventTypeDescription
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

IATA

Addons
<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:

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

DesignPlatform

Backspace on an empty query removes the last tag. Type a new value and press Enter to add a custom team.

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

Type at least 1 character

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.

Async
<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:

Virtual
<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)

Full keyboard contract of the package

Component documentationAll components