GrAutocomplete

Пакет: @feugene/granularityядроГруппа: Формы

Берут, когда вариантов слишком много для списка.

Когда брать

  • вариантов слишком много для списка — пользователи, города, теги, репозитории: их ищут вводом, а не просматривают;
  • список приходит с сервера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

PropTypeпо умолчаниюОписание
modelValueобязательныйGrAutocompleteModelValue<TValue>Выбранное значение (single — строка, multiple — массив строк).
optionsGrAutocompleteOption<TValue>[] | undefinedundefinedДоступные опции. Для локального режима — полный список (фильтруется на клиенте). Для remote-режима (`filterable=false`) — список, который родитель обновляет в ответ на событие `search`.
multipleboolean | undefinedfalse
tagTone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined"neutral"Вид чипов выбранных значений в режиме `multiple`. Рисует их `GrChip`, но шкала пропа осталась бейджевой: `tagSize` публичен, и его значения обязаны означать тот же кегль, что и раньше. Перевод ступеней — в `chipSizeForBadgeScale`.
tagDarkboolean | undefinedfalse
tagSize"xs" | "sm" | "md" | "lg" | undefined"sm"
tagRadiusGrBadgeRadius | undefined"round"
disabledboolean | undefinedfalse
readonlyboolean | undefinedfalseТолько для чтения: значение видно и уходит в форму, но не редактируется.
invalidboolean | undefinedfalseВизуальное и ARIA-состояние ошибки.
requiredboolean | undefinedfalseОбязательное поле (`aria-required`).
size"xs" | "sm" | "md" | "lg" | undefinedundefined
placeholderstring | undefinedundefined
ariaLabelstring | undefinedundefined
clearableboolean | undefinedundefinedКнопка очистки выбранного значения/запроса.
loadingboolean | undefinedfalseВнешне управляемое состояние загрузки (для async-сценариев).
filterableboolean | undefinedtrueЛокальная фильтрация опций по введённому запросу. Отключите (`false`) для чисто удалённого поиска — тогда `options` показываются как есть, а фильтрацию выполняет сервер по событию `search`.
filter((option: GrAutocompleteOption<TValue>, query: string) => boolean) | undefinedundefinedКастомный матчер локальной фильтрации. По умолчанию — подстрока в `label`/`value`.
fetchOptions((query: string, signal: AbortSignal) => Promise<GrAutocompleteOption<TValue>[]>) | undefinedundefinedУдалённая загрузка опций под управлением компонента: дебаунс, отмена устаревшего запроса и `loading` берёт на себя он. Ответ на отменённый запрос игнорируется — при быстром вводе в списке всегда результат последнего запроса, а не того, который вернулся позже. `signal` пробрасывается в `fetch`. Локальная фильтрация в этом режиме выключена: список фильтрует сервер. Альтернатива — событие `search`, если запрос ведёт само приложение.
minQueryLengthnumber | undefined0Минимальная длина запроса до эмита `search` (для дебаунса remote-загрузки).
debouncenumber | undefined250Задержка дебаунса события `search`, мс.
allowCustomValueboolean | undefinedfalseРазрешить ввод/коммит значения, которого нет в `options`.
closeOnSelectboolean | undefinedtrueЗакрывать панель после выбора (single всегда закрывает).
dropdownMaxHeightnumber | undefined280Максимальная высота панели, px.
virtualboolean | undefinedfalseВиртуализация панели: в DOM живёт только окно вокруг вьюпорта. Высоту окна задаёт `dropdownMaxHeight`. Включается осознанно: на сотне опций выигрыша нет, а в разметке остаётся только окно — вместе с ним меняется и то, что находит `querySelector` потребителя. Профильный сценарий — удалённый поиск по справочнику на тысячи позиций.
loadingTextstring | undefinedundefinedi18n-тексты состояний панели / aria.
noResultsTextstring | undefinedundefined
clearLabelstring | undefinedundefined
openboolean | undefinedundefinedКонтролируемое состояние панели (`v-model:open`). Без пропа панель ведёт себя сама (uncontrolled), с ним — слушайте `update:open` и меняйте проп.
namestring | undefinedundefinedИмя для нативной формы: hidden input по значению модели (не по тексту запроса).
prefixMinWidthstring | undefinedundefinedШирины аддонов `prefix`/`suffix` — общий контракт контролов пакета (`docs/form-controls.md`).
prefixMaxWidthstring | undefinedundefined
suffixMinWidthstring | undefinedundefined
suffixMaxWidthstring | undefinedundefined
prefixFixedboolean | undefinedfalse
suffixFixedboolean | undefinedfalse

Slots

SlotTypeОписание
prefixanyАддон слева от поля ввода.
suffixanyАддон справа, перед спиннером и крестиком.
option{ option: GrAutocompleteOption<TValue>; selected: boolean; }Строка списка вместо подписи опции.
loadinganyСодержимое панели, пока едут опции.
emptyanyСодержимое панели, когда подходящих опций нет.

Events

EventTypeОписание
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 кладут иконку и код стандарта прямо в поле — подпись рядом с контролом больше не нужна.

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>

Одиночный выбор с фильтрацией

Базовый сценарий: текстовый <input role="combobox"> фильтрует опции по мере ввода (локальная фильтрация), clearable очищает выбор. Стрелки/Enter/Home/End работают с клавиатуры, активная опция подсвечивается через aria-activedescendant.

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>

В отличие от GrSelect, combobox-ом здесь является сам инпут: набранный текст — это поисковый запрос, а выбор опции заполняет поле.

Множественный выбор снимаемыми чипами

Режим multiple рендерит выбранные значения как удаляемые chips перед инпутом. Backspace при пустом запросе удаляет последний тег, а allow-custom-value позволяет добавить значение, которого нет в списке (Enter).

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>

Это ключевое отличие от GrSelect multiple, который показывает выбор строкой «a, b, c»: здесь каждый выбор — самостоятельный интерактивный chip.

Асинхронная удалённая загрузка

Удалённый поиск ведёт сам компонент: fetch-options дебаунсится, предыдущий запрос отменяется через AbortSignal, а ответ на устаревший запрос игнорируется — при быстром вводе в списке всегда результат последнего запроса. Локальную фильтрацию и loading в этом режиме компонент берёт на себя, min-query-length откладывает запрос до нужной длины.

Введите минимум 1 символ

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>

Если запрос ведёт само приложение (свой стор, кэш, своя отмена), остаётся прежний путь — дебаунснутое событие search плюс внешние :options и :loading.

Справочник на 10 000 позиций

С virtual в DOM живёт только окно вокруг вьюпорта: панель одинаково отзывчива и на десяти опциях, и на десяти тысячах. Высоту окна задаёт dropdownMaxHeight.

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>

Размер набора уходит в aria-setsize/aria-posinset: при неполном наборе диктор иначе объявлял бы «1 из 12» на списке в десять тысяч. Строка «Add …» при allowCustomValue — такой же элемент набора и прокручивается вместе с ним.

Доступность

Паттерн APG
combobox (editable)
Клавиши
то же (при allowCustomValue вариант «Add …» — такая же остановка стрелок, как опция), плюс в multiple: Backspace в пустом поле — удалить последний чип, — уйти в чипы, там //Home/End — между ними, Delete/Backspace — удалить, Esc — вернуться в поле

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты