GrAutocomplete
A combobox with type-ahead search over options, async loading and multi-select chips.
Overview
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 |
Playground controls come from the API, not from hand
Loading…
0 changed· state is in the URL
<GrAutocomplete
:options="options"
v-model="value"
placeholder="Find a customer"
/>Examples 5 each with a live demo and its code
Found by whatever you remember
Options come in groups — recent customers first — in the same shape
GrSelecttakes. A customfiltermatches the name, the email or the VAT number, and the#optionslot shows what tells two similar customers apart.#prefixholds the search icon,clearableempties the field.New invoice Name, email or VAT number
Northwind TradersHamburg · DE 812 345 678 · [email protected]Asking the server, not on every key
fetch-optionsowns the round trip: it waitsdebouncems after the last key, does not ask beforemin-query-lengthletters and cancels the stale request through theAbortSignal. The#loadingand#emptyslots give the waiting and the empty answer their own words, announced to a screen reader.Request a reviewQ3 VAT return · due 10 OctoberSeveral labels, one nobody has made yet
multipleshows the choice as chips —tag-toneandtag-radiusgive them the shape of labels — andclose-on-selectoff keeps the panel open while picking.allow-custom-valueadds «Add …» for a new label. Withnamethe hidden inputs send the values, not the typed text.Five thousand products, a window in the DOM
A catalogue nobody scrolls through and the DOM should not carry either:
virtualkeeps only a window of rows around the viewport, anddropdown-max-heightsets that window. Afilterthat takes every word on its own finds «walnut desk», and the#optionslot puts the price on the right of each row.Add an order line5,000 products in the catalogue
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
modelValuerequiredv-model | GrAutocompleteModelValue<TValue> | — | The selected value (single — a string, multiple — an array of strings). |
options | GrAutocompleteOptionOrGroup<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 | Selection of several values: the model is an array, and the selection sits in the field as chips. `false` by default. |
tagTone |
| "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 | The chips of the selection are filled with the tone rather than sitting on a soft background — for a busy background. |
tagSize | "xs" | "sm" | "md" | "lg" | undefined | "sm" | The type size of the chips on the badge scale. `sm` by default: a chip lives inside the field, not in text. |
tagRadius | GrBadgeRadius | undefined | "round" | The shape of the chips: `round` — a pill, `semi` — a tag, `square` — a stamp. `round` by default. |
disabled | boolean | undefined | false | The field is unavailable: the panel does not open, options cannot be selected, chips cannot be removed. Inherited from `GrFormField`. |
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 | The height and type size of the field on the control scale. `md` by default; taken from `GrConfigProvider`. |
placeholder | string | undefined | undefined | Hint in an empty field. In `multiple` with selected values it is hidden: the chips take up the room. |
ariaLabel | string | undefined | undefined | The accessible name of the field when there is no visible label. Inside `GrFormField` the name comes from its `label`. |
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 |
| 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 | The text of the panel when nothing was found for the query. i18n: the fallback is `gr.autocomplete.noResults`. |
clearLabel | string | undefined | undefined | The name of the clear button (`aria-label`). i18n: the fallback is `gr.common.clear`. |
openv-model | 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 | The upper limit of the width of the `prefix` addon: wider content is clipped. |
suffixMinWidth | string | undefined | undefined | The lower limit of the width of the `suffix` addon. By default — the addon width for the size step. |
suffixMaxWidth | string | undefined | undefined | The upper limit of the width of the `suffix` addon: wider content is clipped. |
prefixFixed | boolean | undefined | false | A rigid width for the `prefix` addon (`prefixMaxWidth` → `prefixMinWidth` → the default) — when the addons of neighbouring fields must match. |
suffixFixed | boolean | undefined | false | A rigid width for the `suffix` addon (`suffixMaxWidth` → `suffixMinWidth` → the default) — when the addons of neighbouring fields must match. |
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 | Signature | Description |
|---|---|---|
update:modelValue | (e: "update:modelValue", value: GrAutocompleteModelValue<TValue>): void | The new value (`v-model`): a string or, in `multiple`, an array. |
search | (e: "search", query: string): void | The debounced search query — the way in for the remote loading of options. |
searchError | (e: "searchError", error: unknown): void | The `fetchOptions` request failed (a cancellation of a stale one does not arrive here). |
change | (e: "change", value: GrAutocompleteModelValue<TValue>): void | The value was committed by selecting or removing an option. |
update:open | (e: "update:open", value: boolean): void | The panel opened or closed (`v-model:open`). |
clear | (e: "clear"): void | The value was removed with the clear button; only with `clearable`. |
focus | (e: "focus", event: FocusEvent): void | Focus entered the control. Moving between the field and its panel does not count as an event. |
blur | (e: "blur", event: FocusEvent): void | Focus left the control — the field and the panel together. |
Types
Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.
import type { GrAutocompleteConfigurableProps, GrAutocompleteSize, GrAutocompleteOption } from '@feugene/granularity/components/GrAutocomplete'Filedist/types/components/GrAutocomplete/config.d.ts
const grAutocompleteConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrAutocomplete">;Filedist/types/components/GrAutocomplete/defaults.d.ts
interface GrAutocompleteConfigurableProps {
size: GrAutocompleteSize;
clearable: boolean;
}Filedist/types/components/GrAutocomplete/grAutocompleteStyles.d.ts
type GrAutocompleteSize = GrComponentSize;type GrAutocompleteOption<TValue extends GrAutocompleteValue = string> = {
value: TValue;
label: string;
disabled?: boolean;
};type GrAutocompleteOptionGroup<TValue extends GrAutocompleteValue = string> = {
label: string;
options: GrAutocompleteOption<TValue>[];
};type GrAutocompleteOptionOrGroup<TValue extends GrAutocompleteValue = string> = GrAutocompleteOption<TValue> | GrAutocompleteOptionGroup<TValue>;type GrAutocompleteModelValue<TValue extends GrAutocompleteValue = string> = TValue | TValue[];Filedist/types/components/GrAutocomplete/GrAutocomplete.vue.d.ts
interface GrAutocompleteProps<TValue extends GrAutocompleteValue = string> {
modelValue: GrAutocompleteModelValue<TValue>;
options?: GrAutocompleteOptionOrGroup<TValue>[];
multiple?: boolean;
tagTone?: GrBadgeTone;
tagDark?: boolean;
tagSize?: GrBadgeSize;
tagRadius?: GrBadgeRadius;
disabled?: boolean;
readonly?: boolean;
invalid?: boolean;
required?: boolean;
size?: GrAutocompleteSize;
placeholder?: string;
ariaLabel?: string;
clearable?: boolean;
loading?: boolean;
filterable?: boolean;
filter?: (option: GrAutocompleteOption<TValue>, query: string) => boolean;interface GrAutocompleteEmits<TValue extends GrAutocompleteValue = string> {
(e: 'update:modelValue', value: GrAutocompleteModelValue<TValue>): void;
(e: 'search', query: string): void;
(e: 'searchError', error: unknown): void;
(e: 'change', value: GrAutocompleteModelValue<TValue>): void;
(e: 'update:open', value: boolean): void;
(e: 'clear'): void;
(e: 'focus', event: FocusEvent): void;
(e: 'blur', event: FocusEvent): void;
}Filedist/types/components/GrAutocomplete/safelist.d.ts
const grAutocompleteSafelist: string[];Filedist/types/components/GrAutocomplete/index.d.ts
type GrAutocompleteInstance = ComponentExposed<typeof GrAutocompleteComponent>;Accessibility
APG pattern
combobox (editable)
Keys
←Delete / BackspaceEsc
Full contract of the package: docs/keyboard.md ↗
Usage from the library prose
Groups of options
<GrAutocomplete
v-model="city"
:options="[
{ label: 'Europe', options: [{ value: 'lis', label: 'Lisbon' }] },
{ label: 'Asia', options: [{ value: 'tyo', label: 'Tokyo' }] },
{ value: 'remote', label: 'Remote' },
]"
/>The shape of the data is the same as in GrSelect: two
neighbouring controls whose lists differ only in the way of input are obliged to
accept data the same way. Groups and single options mix in one array.
The filter hides a group as a whole if nothing in it matched. A heading over emptiness reads as a failure rather than as “there is nothing here”.
The keyboard walks the options only. The heading of a group is not selected
with the arrows and does not enter the set for the screen reader: the direct
children of role="listbox" are obliged to be options, so the heading lies
inside its group and gives it a name through aria-labelledby. With
virtualisation the window may start below the heading — then the name of the group
goes into aria-label, and the group stays named all the same.
The same value in different groups is not an error: the key of a row is assembled with the index of the group, so two such options do not collapse into a single node, and hovering with the mouse highlights the one that is hovered.
Groups come from the server as well: fetchOptions is entitled to return the same
array with groups, and the signature of the set is computed from their content — a
change of an option inside a group does not go unnoticed.
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.
The height of the panel
dropdownMaxHeight is the ceiling of the list, not of the panel. The panel
itself is bounded by the room left to the edge of the viewport: the layer reports
the measurement in --gr-floating-available-height, and on a short screen it is
the list that shrinks — the input field and the status row stay in view, and there
is a single scrollbar. The mechanism is shared across the list panels of the
package and is described in ../overlays.md.
Virtualisation
virtual keeps in the DOM only the window around the viewport — its height is set
by dropdownMaxHeight or by the available room, if there is less of it. 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.
Props in this example