GrAutocomplete
Берут, когда вариантов слишком много для списка.
Когда брать
- вариантов слишком много для списка — пользователи, города, теги, репозитории: их ищут вводом, а не просматривают;
- список приходит с сервера —
sourceиdebounceвместе с состояниями «идёт поиск» и «ничего не найдено»; - нужен минимум символов —
minQueryLengthне даёт уйти на сервер за первой же буквой; - значений несколько —
multipleпоказывает выбранное чипами, каждый снимается с клавиатуры; - значения может не быть в справочнике —
allowCustomValueразрешает своё.
Когда взять другое
| Нужно | Берите |
|---|---|
| Вариантов 5–50 и их просматривают, а не ищут | GrSelect |
| Варианты — дерево с уровнями | GrTreeSelect |
| Ищут команду приложения, а не значение поля | GrCommandPalette |
| Справочника нет вовсе, вводят свои строки | GrInputTag |
| Вариантов 2–5 и они видны сразу | GrSegmented |
Удалённый поиск
Два пути, взаимоисключающих по смыслу.
fetchOptions — запрос ведёт компонент:
<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()
}
Дебаунс, отмена предыдущего запроса и loading — на компоненте. Ответ на
отменённый запрос игнорируется: при быстром вводе в списке всегда результат
последнего запроса, а не того, который вернулся позже. Локальная фильтрация в
этом режиме выключена — список фильтрует сервер. Ошибка (кроме отмены) уходит
событием searchError, список при этом становится пустым.
search — запрос ведёт приложение:
<GrAutocomplete
v-model="user"
:options="options"
:loading="pending"
:filterable="false"
@search="load"
/>
Путь для тех, у кого запрос живёт в сторе, кэше или своей отмене. Гонку ответов в этом случае разруливает приложение: компонент отдаёт только дебаунснутый запрос и не знает, какой ответ ему принесли.
Произвольные значения
allowCustomValue добавляет в список опцию «Add …». Она такая же опция
списка, как остальные, и участвует в навигации стрелками: без этого закоммитить
своё значение с клавиатуры было нельзя — при непустом списке Enter всегда
уходил в активную опцию.
Значение набирается текстом, поэтому оно строковое по природе: при числовом
TValue ветка неприменима.
Чипы (multiple)
Крестики чипов намеренно не табируемы: у combobox фокус живёт на <input>,
и двадцать выбранных значений не должны давать двадцать остановок Tab.
Навигация — стрелками: ← из пустого запроса переводит на последний чип,
←/→ ходят между ними, Delete/Backspace удаляет, Esc, → с последнего
и любой печатный символ возвращают в поле.
Backspace в пустом поле по-прежнему сносит последний чип без захода в
навигацию.
Состав панели
Прямые потомки role="listbox" — только опции: роль объявляет остальных
недопустимыми детьми. Загрузка, «введите ещё N символов» и «ничего не найдено»
живут ниже списка в одном живом регионе role="status" aria-live="polite" —
иначе асинхронные состояния менялись бы молча.
Опции не забирают фокус (mousedown подавлен), поэтому выбор мышью оставляет
каретку в поле — панель не переоткрывается и таб-порядок не сбивается.
Виртуализация
virtual оставляет в DOM только окно вокруг вьюпорта — высоту окна задаёт
dropdownMaxHeight. Профильный сценарий один: удалённый поиск по справочнику,
где совпадений тысячи.
<GrAutocomplete v-model="city" :options="cities" virtual />
Три следствия, о которых стоит знать заранее.
Размер набора объявляется явно. В обычном режиме диктор выводит его из DOM,
но при неполном наборе получил бы «1 из 12» на списке в десять тысяч. Поэтому
при virtual опции несут aria-setsize и aria-posinset — от всего
отфильтрованного списка, а не от окна. В обычном режиме этих атрибутов нет: там
они были бы шумом.
Строка «Add …» — элемент набора. При allowCustomValue она стоит первой и
прокручивается вместе со списком, а не прилипает к верху панели: клавиатура и
так ходит по ней как по обычной опции.
Активная опция всегда смонтирована. Стрелки прокручивают список до неё
прежде, чем перевести aria-activedescendant, — иначе атрибут указывал бы на
узел, которого в DOM нет. Устройство и ограничения примитива —
virtual-list.md.
Программное управление
const box = useTemplateRef('box')
box.value?.focus()
box.value?.open()
box.value?.close()Состояния
disabled и readonly приходят и пропом, и из GrFormField.
Оба запирают контрол одинаково: панель не открывается, опции не выбираются,
чипы не удаляются, кнопки очистки нет. Разница только в семантике — readonly
отдаёт значение в форму и объявляется aria-readonly.
Слоты
| Слот | Что заменяет |
|---|---|
prefix / suffix | аддон в оболочке поля |
option | содержимое опции (option, selected) |
loading | строку загрузки |
empty | текст «ничего не найдено» |
Аддоны `prefix` / `suffix`
Слоты кладут в оболочку иконку, единицу или метку; ширина ограничивается шестью
пропами (prefixMinWidth/prefixMaxWidth/prefixFixed и то же для суффикса).
Общий контракт контролов — form-controls.md.
Управление панелью и нативная форма
Панель управляема через v-model:open (общий контракт панельных оверлеев, как
у GrPopover): без пропа open — прежнее uncontrolled-поведение, с ним
состоянием владеет родитель, а update:open сопровождает каждое изменение.
Проп name включает участие в нативной форме: hidden-инпуты сериализуют
значение модели, а не текст запроса — по одному на значение при multiple,
ничего при пустом выборе.
`minQueryLength` и стартовый список в remote-режиме
Пока запрос короче minQueryLength, панель показывает подсказку «введите ещё
N символов», а не результаты прошлого запроса — устаревший список под
подсказкой дезинформировал бы. При allowCustomValue подсказка стоит рядом со
строкой «Add …»: та объясняет, что делать с набранным, но не то, куда делись
опции.
В remote-режиме (fetchOptions) проп options — стартовый список до первого
ответа сервера. Его замена родителем снова делает его источником до следующего
ответа и отменяет летящий запрос: тот относится к прежнему набору данных.
Смена отслеживается по составу, а не по идентичности массива, поэтому
инлайн-литерал :options="[...]" безопасен — пересозданный при ререндере
список с теми же опциями remote-результаты не трогает.
Playground 31
Загружается…
<GrAutocomplete />Установка
npm i @feugene/granularityИмпорт
import { GrAutocomplete } from '@feugene/granularity/components/GrAutocomplete'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
modelValueобязательный | GrAutocompleteModelValue<TValue> | — | Выбранное значение (single — строка, multiple — массив строк). |
options | GrAutocompleteOption<TValue>[] | undefined | undefined | Доступные опции. Для локального режима — полный список (фильтруется на клиенте). Для remote-режима (`filterable=false`) — список, который родитель обновляет в ответ на событие `search`. |
multiple | boolean | undefined | false | — |
tagTone | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined | "neutral" | Вид чипов выбранных значений в режиме `multiple`. Рисует их `GrChip`, но шкала пропа осталась бейджевой: `tagSize` публичен, и его значения обязаны означать тот же кегль, что и раньше. Перевод ступеней — в `chipSizeForBadgeScale`. |
tagDark | boolean | undefined | false | — |
tagSize | "xs" | "sm" | "md" | "lg" | undefined | "sm" | — |
tagRadius | GrBadgeRadius | undefined | "round" | — |
disabled | boolean | undefined | false | — |
readonly | boolean | undefined | false | Только для чтения: значение видно и уходит в форму, но не редактируется. |
invalid | boolean | undefined | false | Визуальное и ARIA-состояние ошибки. |
required | boolean | undefined | false | Обязательное поле (`aria-required`). |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
placeholder | string | undefined | undefined | — |
ariaLabel | string | undefined | undefined | — |
clearable | boolean | undefined | undefined | Кнопка очистки выбранного значения/запроса. |
loading | boolean | undefined | false | Внешне управляемое состояние загрузки (для async-сценариев). |
filterable | boolean | undefined | true | Локальная фильтрация опций по введённому запросу. Отключите (`false`) для чисто удалённого поиска — тогда `options` показываются как есть, а фильтрацию выполняет сервер по событию `search`. |
filter | ((option: GrAutocompleteOption<TValue>, query: string) => boolean) | undefined | undefined | Кастомный матчер локальной фильтрации. По умолчанию — подстрока в `label`/`value`. |
fetchOptions | ((query: string, signal: AbortSignal) => Promise<GrAutocompleteOption<TValue>[]>) | undefined | undefined | Удалённая загрузка опций под управлением компонента: дебаунс, отмена устаревшего запроса и `loading` берёт на себя он. Ответ на отменённый запрос игнорируется — при быстром вводе в списке всегда результат последнего запроса, а не того, который вернулся позже. `signal` пробрасывается в `fetch`. Локальная фильтрация в этом режиме выключена: список фильтрует сервер. Альтернатива — событие `search`, если запрос ведёт само приложение. |
minQueryLength | number | undefined | 0 | Минимальная длина запроса до эмита `search` (для дебаунса remote-загрузки). |
debounce | number | undefined | 250 | Задержка дебаунса события `search`, мс. |
allowCustomValue | boolean | undefined | false | Разрешить ввод/коммит значения, которого нет в `options`. |
closeOnSelect | boolean | undefined | true | Закрывать панель после выбора (single всегда закрывает). |
dropdownMaxHeight | number | undefined | 280 | Максимальная высота панели, px. |
virtual | boolean | undefined | false | Виртуализация панели: в DOM живёт только окно вокруг вьюпорта. Высоту окна задаёт `dropdownMaxHeight`. Включается осознанно: на сотне опций выигрыша нет, а в разметке остаётся только окно — вместе с ним меняется и то, что находит `querySelector` потребителя. Профильный сценарий — удалённый поиск по справочнику на тысячи позиций. |
loadingText | string | undefined | undefined | i18n-тексты состояний панели / aria. |
noResultsText | string | undefined | undefined | — |
clearLabel | string | undefined | undefined | — |
open | boolean | undefined | undefined | Контролируемое состояние панели (`v-model:open`). Без пропа панель ведёт себя сама (uncontrolled), с ним — слушайте `update:open` и меняйте проп. |
name | string | undefined | undefined | Имя для нативной формы: hidden input по значению модели (не по тексту запроса). |
prefixMinWidth | string | undefined | undefined | Ширины аддонов `prefix`/`suffix` — общий контракт контролов пакета (`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 | Описание |
|---|---|---|
prefix | any | Аддон слева от поля ввода. |
suffix | any | Аддон справа, перед спиннером и крестиком. |
option | { option: GrAutocompleteOption<TValue>; selected: boolean; } | Строка списка вместо подписи опции. |
loading | any | Содержимое панели, пока едут опции. |
empty | any | Содержимое панели, когда подходящих опций нет. |
Events
| Event | Type | Описание |
|---|---|---|
update:modelValue | [GrAutocompleteModelValue<TValue>] | — |
search | [string] | Дебаунснутый поисковый запрос — точка входа для удалённой загрузки опций. |
searchError | [unknown] | Запрос `fetchOptions` завершился ошибкой (отмена устаревшего сюда не приходит). |
change | [GrAutocompleteModelValue<TValue>] | Значение зафиксировано выбором или снятием опции. |
update:open | [boolean] | Панель открылась/закрылась (`v-model:open`). |
clear | [] | Значение снято кнопкой очистки; только при `clearable`. |
focus | [FocusEvent] | — |
blur | [FocusEvent] | — |
Примеры 5
Аддоны в поле
Слоты prefix и suffix кладут иконку и код стандарта прямо в поле — подпись рядом с контролом больше не нужна.
<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>Одиночный выбор с фильтрацией
Базовый сценарий: текстовый <input role="combobox"> фильтрует опции по мере ввода (локальная фильтрация), clearable очищает выбор. Стрелки/Enter/Home/End работают с клавиатуры, активная опция подсвечивается через aria-activedescendant.
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>В отличие от GrSelect, combobox-ом здесь является сам инпут: набранный текст — это поисковый запрос, а выбор опции заполняет поле.
Множественный выбор снимаемыми чипами
Режим multiple рендерит выбранные значения как удаляемые chips перед инпутом. Backspace при пустом запросе удаляет последний тег, а allow-custom-value позволяет добавить значение, которого нет в списке (Enter).
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>Это ключевое отличие от GrSelect multiple, который показывает выбор строкой «a, b, c»: здесь каждый выбор — самостоятельный интерактивный chip.
Асинхронная удалённая загрузка
Удалённый поиск ведёт сам компонент: fetch-options дебаунсится, предыдущий запрос отменяется через AbortSignal, а ответ на устаревший запрос игнорируется — при быстром вводе в списке всегда результат последнего запроса. Локальную фильтрацию и loading в этом режиме компонент берёт на себя, min-query-length откладывает запрос до нужной длины.
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>Если запрос ведёт само приложение (свой стор, кэш, своя отмена), остаётся прежний путь — дебаунснутое событие search плюс внешние :options и :loading.
Справочник на 10 000 позиций
С virtual в DOM живёт только окно вокруг вьюпорта: панель одинаково отзывчива и на десяти опциях, и на десяти тысячах. Высоту окна задаёт dropdownMaxHeight.
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>Размер набора уходит в aria-setsize/aria-posinset: при неполном наборе диктор иначе объявлял бы «1 из 12» на списке в десять тысяч. Строка «Add …» при allowCustomValue — такой же элемент набора и прокручивается вместе с ним.
Доступность
- Паттерн APG
combobox (editable)- Клавиши
- то же (при
allowCustomValueвариант «Add …» — такая же остановка стрелок, как опция), плюс в multiple:Backspaceв пустом поле — удалить последний чип,←— уйти в чипы, там←/→/Home/End— между ними,Delete/Backspace— удалить,Esc— вернуться в поле