GrAutocomplete

  • ядро
  • Формы
  • API · 36 props
  • ⌨ клавиши

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

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

Обзор

Когда брать

  • вариантов слишком много для списка — пользователи, города, теги, репозитории: их ищут вводом, а не просматривают;
  • список приходит с сервера — source и debounce вместе с состояниями «идёт поиск» и «ничего не найдено»;
  • нужен минимум символов — minQueryLength не даёт уйти на сервер за первой же буквой;
  • значений несколько — multiple показывает выбранное чипами, каждый снимается с клавиатуры;
  • значения может не быть в справочнике — allowCustomValue разрешает своё.

Когда взять другое

НужноБерите
Вариантов 5–50 и их просматривают, а не ищутGrSelect
Варианты — дерево с уровнямиGrTreeSelect
Ищут команду приложения, а не значение поляGrCommandPalette
Справочника нет вовсе, вводят свои строкиGrInputTag
Вариантов 2–5 и они видны сразуGrSegmented

Playground контролы выводятся из API, а не пишутся руками

Сцена

Загружается…

0 изменено· состояние — в адресе

Примеры 5 у каждого — живое демо и его код

  • Находится по тому, что помнишь

    Опции приходят группами — сначала недавние клиенты — в той же форме, что у GrSelect. Свой filter ищет по имени, почте или номеру НДС, а слот #option показывает то, что отличает двух похожих клиентов. #prefix держит значок поиска, clearable очищает поле.

    New invoice

    Name, email or VAT number

    Northwind TradersHamburg · DE 812 345 678 · [email protected]
  • Спрашиваем сервер, но не на каждой клавише

    fetch-options берёт обмен с сервером на себя: ждёт debounce мс после последней клавиши, не спрашивает раньше min-query-length букв и отменяет устаревший запрос через AbortSignal. Слоты #loading и #empty дают ожиданию и пустому ответу свои слова, и диктор их слышит.

    Request a reviewQ3 VAT return · due 10 October
    Введите минимум 2 символа
  • Несколько меток и одна, которой ещё нет

    multiple показывает выбор чипами — tag-tone и tag-radius придают им вид меток, — а выключенный close-on-select держит панель открытой, пока выбирают. allow-custom-value добавляет «Add …» для новой метки. С name скрытые поля отправляют значения, а не набранный текст.

    Export fails for reverse-charge invoices#1824 · opened by Grace Wu
    bugvat

    Sent with the form: nothing

  • Пять тысяч товаров, в DOM — окно

    Каталог, который никто не листает и который не должен целиком жить в DOM: virtual держит только окно строк вокруг вьюпорта, а dropdown-max-height задаёт это окно. filter, берущий каждое слово отдельно, находит «walnut desk», а слот #option ставит цену справа в каждой строке.

    Add an order line5,000 products in the catalogue

API порождается из пакета · разойтись не может

Props

PropTypeпо умолчаниюОписание
modelValueобязательныйv-modelGrAutocompleteModelValue<TValue>—Выбранное значение (single — строка, multiple — массив строк).
optionsGrAutocompleteOptionOrGroup<TValue>[] | undefinedundefinedДоступные опции. Для локального режима — полный список (фильтруется на клиенте). Для remote-режима (`filterable=false`) — список, который родитель обновляет в ответ на событие `search`.
multipleboolean | undefinedfalseВыбор нескольких значений: модель — массив, выбранное стоит в поле чипами. По умолчанию `false`.
tagTone
"primary" | "neutral" | "success" | "warning" | "danger" | ""primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined
"neutral"Вид чипов выбранных значений в режиме `multiple`. Рисует их `GrChip`, но шкала пропа осталась бейджевой: `tagSize` публичен, и его значения обязаны означать тот же кегль, что и раньше. Перевод ступеней — в `chipSizeForBadgeScale`.
tagDarkboolean | undefinedfalseЧипы выбранного залиты тоном, а не стоят на мягкой подложке — для пёстрого фона.
tagSize"xs" | "sm" | "md" | "lg" | undefined"sm"Кегль чипов по бейджевой шкале. По умолчанию `sm`: чип живёт внутри поля, а не в тексте.
tagRadiusGrBadgeRadius | undefined"round"Форма чипов: `round` — таблетка, `semi` — ярлык, `square` — штамп. По умолчанию `round`.
disabledboolean | undefinedfalseПоле недоступно: панель не открывается, опции не выбираются, чипы не снимаются. Наследуется от `GrFormField`.
readonlyboolean | undefinedfalseТолько для чтения: значение видно и уходит в форму, но не редактируется.
invalidboolean | undefinedfalseВизуальное и ARIA-состояние ошибки.
requiredboolean | undefinedfalseОбязательное поле (`aria-required`).
size"xs" | "sm" | "md" | "lg" | undefinedundefinedВысота и кегль поля по шкале контролов. По умолчанию `md`; берётся из `GrConfigProvider`.
placeholderstring | undefinedundefinedПодсказка в пустом поле. В `multiple` с выбранными значениями прячется: место занимают чипы.
ariaLabelstring | undefinedundefinedДоступное имя поля, когда видимой подписи нет. Внутри `GrFormField` имя даёт его `label`.

Слоты

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

События

EventСигнатураОписание
update:modelValue(e: "update:modelValue", value: GrAutocompleteModelValue<TValue>): voidНовое значение (`v-model`): строка или, в `multiple`, массив.
search(e: "search", query: string): voidДебаунснутый поисковый запрос — точка входа для удалённой загрузки опций.
searchError(e: "searchError", error: unknown): voidЗапрос `fetchOptions` завершился ошибкой (отмена устаревшего сюда не приходит).
change(e: "change", value: GrAutocompleteModelValue<TValue>): voidЗначение зафиксировано выбором или снятием опции.
update:open(e: "update:open", value: boolean): voidПанель открылась/закрылась (`v-model:open`).
clear(e: "clear"): voidЗначение снято кнопкой очистки; только при `clearable`.
focus(e: "focus", event: FocusEvent): voidФокус вошёл в контрол. Переход между полем и его панелью событием не считается.
blur(e: "blur", event: FocusEvent): voidФокус ушёл из контрола — из поля и из панели вместе.

Типы

Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.

Импорт
import type { GrAutocompleteConfigurableProps, GrAutocompleteSize, GrAutocompleteOption } from '@feugene/granularity/components/GrAutocomplete'

Файлdist/types/components/GrAutocomplete/config.d.ts

grAutocompleteConfigconst
const grAutocompleteConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrAutocomplete">;

Файлdist/types/components/GrAutocomplete/defaults.d.ts

GrAutocompleteConfigurablePropsinterface

Пропы GrAutocomplete, настраиваемые глобально через <GrConfigProvider :component-defaults="{ GrAutocomplete: { … } }">. Только оформление и поведение по умолчанию — см. GrButton/defaults.ts.

interface GrAutocompleteConfigurableProps {
    size: GrAutocompleteSize;
    clearable: boolean;
}

Файлdist/types/components/GrAutocomplete/grAutocompleteStyles.d.ts

GrAutocompleteSizetype
type GrAutocompleteSize = GrComponentSize;
GrAutocompleteOptiontype

Опция автокомплита. Метка — строка, значение параметризуется.

type GrAutocompleteOption<TValue extends GrAutocompleteValue = string> = {
    value: TValue;
    label: string;
    disabled?: boolean;
};
GrAutocompleteOptionGrouptype

Группа опций: заголовок label + вложенные опции options. Форма та же, что у GrSelect: два соседних контрола, у которых списки различаются только способом ввода, обязаны принимать данные одинаково.

type GrAutocompleteOptionGroup<TValue extends GrAutocompleteValue = string> = {
    label: string;
    options: GrAutocompleteOption<TValue>[];
};
GrAutocompleteOptionOrGrouptype

Элемент списка: либо одиночная опция, либо группа.

type GrAutocompleteOptionOrGroup<TValue extends GrAutocompleteValue = string> = GrAutocompleteOption<TValue> | GrAutocompleteOptionGroup<TValue>;
GrAutocompleteModelValuetype
type GrAutocompleteModelValue<TValue extends GrAutocompleteValue = string> = TValue | TValue[];

Файлdist/types/components/GrAutocomplete/GrAutocomplete.vue.d.ts

GrAutocompletePropsinterface

Публичный GR-примитив «Autocomplete / Combobox» (WAI-ARIA editable combobox). В отличие от GrSelect (select-only combobox с кнопкой-триггером), здесь combobox-ом является сам текстовый <input>: набранный текст — это поисковый запрос, а выбор опции заполняет поле. Поддерживает локальную фильтрацию, удалённую (async) загрузку через событие search + внешний проп loading, произвольные значения (allowCustomValue) и multiple с удаляемыми chips.

interface GrAutocompleteProps<TValue extends GrAutocompleteValue = string> {
    /** Выбранное значение (single — строка, multiple — массив строк). */
    modelValue: GrAutocompleteModelValue<TValue>;
    /**
     * Доступные опции. Для локального режима — полный список (фильтруется на клиенте).
     * Для remote-режима (`filterable=false`) — список, который родитель обновляет
     * в ответ на событие `search`.
     */
    options?: GrAutocompleteOptionOrGroup<TValue>[];
    /** Выбор нескольких значений: модель — массив, выбранное стоит в поле чипами. По умолчанию `false`. */
    multiple?: boolean;
    /**
     * Вид чипов выбранных значений в режиме `multiple`. Рисует их `GrChip`, но
     * шкала пропа осталась бейджевой: `tagSize` публичен, и его значения обязаны
     * означать тот же кегль, что и раньше. Перевод ступеней — в
     * `chipSizeForBadgeScale`.
     */
    tagTone?: GrBadgeTone;
    /** Чипы выбранного залиты тоном, а не стоят на мягкой подложке — для пёстрого фона. */
    tagDark?: boolean;
    /** Кегль чипов по бейджевой шкале. По умолчанию `sm`: чип живёт внутри поля, а не в тексте. */
    tagSize?: GrBadgeSize;
    /** Форма чипов: `round` — таблетка, `semi` — ярлык, `square` — штамп. По умолчанию `round`. */
    tagRadius?: GrBadgeRadius;
    /**
     * Поле недоступно: панель не открывается, опции не выбираются, чипы не
     * снимаются. Наследуется от `GrFormField`.
     */
    disabled?: boolean;
    /** Только для чтения: значение видно и уходит в форму, но не редактируется. */
    readonly?: boolean;
    /** Визуальное и ARIA-состояние ошибки. */
    invalid?: boolean;
    /** Обязательное поле (`aria-required`). */
    required?: boolean;
    /** Высота и кегль поля по шкале контролов. По умолчанию `md`; берётся из `GrConfigProvider`. */
    size?: GrAutocompleteSize;
    /** Подсказка в пустом поле. В `multiple` с выбранными значениями прячется: место занимают чипы. */
    placeholder?: string;
    /** Доступное имя поля, когда видимой подписи нет. Внутри `GrFormField` имя даёт его `label`. */
    ariaLabel?: string;
    /** Кнопка очистки выбранного значения/запроса. */
    clearable?: boolean;
    /** Внешне управляемое состояние загрузки (для async-сценариев). */
    loading?: boolean;
    /**
     * Локальная фильтрация опций по введённому запросу. Отключите (`false`) для
     * чисто удалённого поиска — тогда `options` показываются как есть, а фильтрацию
     * выполняет сервер по событию `search`.
     */
    filterable?: boolean;
    /** Кастомный матчер локальной фильтрации. По умолчанию — подстрока в `label`/`value`. */
    filter?: (option: GrAutocompleteOption<TValue>, query: string) => boolean;
GrAutocompleteEmitsinterface
interface GrAutocompleteEmits<TValue extends GrAutocompleteValue = string> {
    /** Новое значение (`v-model`): строка или, в `multiple`, массив. */
    (e: 'update:modelValue', value: GrAutocompleteModelValue<TValue>): void;
    /** Дебаунснутый поисковый запрос — точка входа для удалённой загрузки опций. */
    (e: 'search', query: string): void;
    /** Запрос `fetchOptions` завершился ошибкой (отмена устаревшего сюда не приходит). */
    (e: 'searchError', error: unknown): void;
    /** Значение зафиксировано выбором или снятием опции. */
    (e: 'change', value: GrAutocompleteModelValue<TValue>): void;
    /** Панель открылась/закрылась (`v-model:open`). */
    (e: 'update:open', value: boolean): void;
    /** Значение снято кнопкой очистки; только при `clearable`. */
    (e: 'clear'): void;
    /** Фокус вошёл в контрол. Переход между полем и его панелью событием не считается. */
    (e: 'focus', event: FocusEvent): void;
    /** Фокус ушёл из контрола — из поля и из панели вместе. */
    (e: 'blur', event: FocusEvent): void;
}

Файлdist/types/components/GrAutocomplete/safelist.d.ts

grAutocompleteSafelistconst
const grAutocompleteSafelist: string[];

Файлdist/types/components/GrAutocomplete/index.d.ts

GrAutocompleteInstancetype
type GrAutocompleteInstance = ComponentExposed<typeof GrAutocompleteComponent>;

Доступность

Паттерн APG

combobox (editable)

Клавиши

←Delete / BackspaceEsc

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

Использование из прозы библиотеки

Группы опций
vue
<GrAutocomplete
  v-model="city"
  :options="[
    { label: 'Европа', options: [{ value: 'lis', label: 'Лиссабон' }] },
    { label: 'Азия', options: [{ value: 'tyo', label: 'Токио' }] },
    { value: 'remote', label: 'Удалённо' },
  ]"
/>

Форма данных та же, что у GrSelect: два соседних контрола, у которых списки различаются только способом ввода, обязаны принимать данные одинаково. Группы и одиночные опции смешиваются в одном массиве.

Фильтр прячет группу целиком, если в ней ничего не совпало. Заголовок над пустотой читается как сбой, а не как «здесь ничего нет».

Клавиатура ходит только по опциям. Заголовок группы стрелками не выбирается и в набор для диктора не входит: прямыми потомками role="listbox" обязаны быть опции, поэтому заголовок лежит внутри своей группы и даёт ей имя через aria-labelledby. При виртуализации окно может начаться ниже заголовка — тогда имя группы уходит в aria-label, и группа всё равно остаётся названной.

Одинаковое значение в разных группах — не ошибка: ключ строки собирается с индексом группы, поэтому две такие опции не схлопываются в один узел, а наведение мышью подсвечивает ту, на которую навели.

Группы приходят и от сервера: fetchOptions вправе вернуть тот же массив с группами, и подпись состава считается по их содержимому — смена опции внутри группы не проходит незамеченной.

Удалённый поиск

Два пути, взаимоисключающих по смыслу.

fetchOptions — запрос ведёт компонент:

vue
<GrAutocomplete
  v-model="user"
  :fetch-options="fetchPeople"
  :min-query-length="1"
  @search-error="reportError"
/>
ts
async function fetchPeople(query: string, signal: AbortSignal) {
  const response = await fetch(`/api/people?q=${query}`, { signal })
  return response.json()
}

Дебаунс, отмена предыдущего запроса и loading — на компоненте. Ответ на отменённый запрос игнорируется: при быстром вводе в списке всегда результат последнего запроса, а не того, который вернулся позже. Локальная фильтрация в этом режиме выключена — список фильтрует сервер. Ошибка (кроме отмены) уходит событием searchError, список при этом становится пустым.

search — запрос ведёт приложение:

vue
<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 подавлен), поэтому выбор мышью оставляет каретку в поле — панель не переоткрывается и таб-порядок не сбивается.

Высота панели

dropdownMaxHeight — потолок списка, а не панели. Саму панель ограничивает место, оставшееся до края вьюпорта: слой сообщает замер в --gr-floating-available-height, и на коротком экране сжимается именно список — поле ввода и строка статуса остаются на виду, а полоса прокрутки одна. Механизм общий на списочные панели пакета и описан в ../overlays.md.

Виртуализация

virtual оставляет в DOM только окно вокруг вьюпорта — его высоту задаёт dropdownMaxHeight или доступное место, если его меньше. Профильный сценарий один: удалённый поиск по справочнику, где совпадений тысячи.

vue
<GrAutocomplete v-model="city" :options="cities" virtual />

Три следствия, о которых стоит знать заранее.

Размер набора объявляется явно. В обычном режиме диктор выводит его из DOM, но при неполном наборе получил бы «1 из 12» на списке в десять тысяч. Поэтому при virtual опции несут aria-setsize и aria-posinset — от всего отфильтрованного списка, а не от окна. В обычном режиме этих атрибутов нет: там они были бы шумом.

Строка «Add …» — элемент набора. При allowCustomValue она стоит первой и прокручивается вместе со списком, а не прилипает к верху панели: клавиатура и так ходит по ней как по обычной опции.

Активная опция всегда смонтирована. Стрелки прокручивают список до неё прежде, чем перевести aria-activedescendant, — иначе атрибут указывал бы на узел, которого в DOM нет. Устройство и ограничения примитива — virtual-list.md.

Программное управление
ts
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-результаты не трогает.