GrInputTag
Берут, когда значения придумывает пользователь.
Обзор
Когда брать
- значения придумывает пользователь — теги статьи, ключевые слова, адреса рассылки: справочника нет;
- значения вставляют пачкой —
separatorsразбивает вставленную из буфера строку на чипы; - добавляемое надо проверить —
beforeAddотклоняет неверный адрес или дубликат до появления чипа; - количество ограничено —
maxне даёт добавить лишнее.
Когда взять другое
| Нужно | Берите |
|---|---|
| Есть справочник значений | GrSelect с multiple и tags |
| Справочник большой, значения ищут вводом | GrAutocomplete |
| Значение одно | GrInput |
| Значения вложены | GrTreeSelect |
Варианты все тона и виды — настоящим компонентом
state
defaultsuccesswarningdanger
Размеры
xssmmdlg
Состояния
обычнаяdisabledloadingreadonlyinvalid
Матрица живая: наводите и нажимайте. Состояний наведения, фокуса и нажатия здесь нет — они за браузером, а не за пропом.
Playground контролы выводятся из API, а не пишутся руками
Загружается…
0 изменено· состояние — в адресе
<GrInputTag v-model="value" />Примеры 3 у каждого — живое демо и его код
Приглашения из вставленного списка
Строка, вставленная из таблицы, становится отдельными адресами:
separatorsрежет по запятым, пробелам и точкам с запятой, аadd-on-blurсохраняет набранное, но не подтверждённое.before-addостанавливает кривой адрес или нынешнего участника до того, как он станет чипом,maxограничивает набор тарифом, аnameотправляет каждый адрес с нативной формой.Invite people to Northwind Traders 2 of 8 · paste a list or press Enter
Ключевые слова, исправленные на месте
editableисправляет «acessibility» там, где оно стоит, — двойным кликом или F2 на чипе, — вместо «удалить и набрать заново», аeditсообщает, что изменилось.#стоит в аддоне слева, аtag-toneотделяет чипы от текста вокруг.Tokens are roles, not coloursBlog post · draft Double-click a keyword or press F2 on it to fix a typo.#design-tokensdark-modeacessibilityvueДомены, проверенные сервером
Асинхронный
before-addсам поднимаетloading, пока сервер ищет запись подтверждения, и домен без неё не становится чипом. Слотtagнаполняет чип — отметка «подтверждён» перед доменом, — а сам чип, егоtag-toneи кнопка снятия остаются за компонентом.Single sign-onPeople with these email domains sign in through Okta. northwind.example, verified
API порождается из пакета · разойтись не может
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
disabled | boolean | undefined | false | Выключить поле: теги не добавляются и не снимаются. Складывается с `GrFormField`. |
tagTone |
| "neutral" | Тон чипов-тегов. По умолчанию `neutral`. |
tagDark | boolean | undefined | false | Чипы с заливкой тоном вместо мягкой подложки. По умолчанию `false`. |
tagSize | "xs" | "sm" | "md" | "lg" | undefined | "md" | Кегль чипов по шкале `GrBadge` — та же ступень, что у тегов `GrSelect`. По умолчанию `md`. |
tagRadius | GrBadgeRadius | undefined | "round" | Форма чипов: `round`, `semi` или `square`. По умолчанию `round`. |
readonly | boolean | undefined | false | Только для чтения: теги видно, но ни добавить, ни снять, ни очистить их нельзя. |
invalid | boolean | undefined | false | Ошибка валидации: danger-рамка и `aria-invalid`. Складывается с ошибкой `GrFormField`. |
required | boolean | undefined | false | Обязательное поле (`aria-required`). Складывается с `required` у `GrFormField`. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | Высота и кегль по шкале контролов. По умолчанию `md`; берётся из `GrConfigProvider`. |
placeholder | string | undefined | undefined | Подсказка в пустом поле — пропадает с первым тегом. Подпись не заменяет: имя даёт `GrFormField` или `ariaLabel`. |
ariaLabel | string | undefined | undefined | Имя контрола, когда он используется вне `GrFormField`. Внутри поля имя даёт связка с его `<label for>` — там проп не нужен. Без того и другого у инпута нет доступного имени вовсе: placeholder именем не считается. |
clearable | boolean | undefined | undefined | Кнопка «снести все теги». Настраивается через `GrConfigProvider`. |
loading | boolean | undefined | false | Фоновая работа: спиннер + `aria-busy`. Асинхронный `beforeAdd` поднимает его сам. |
name | string | undefined | undefined | Имя для нативной формы: hidden input на каждый тег. |
max | number | undefined | undefined | Предел числа тегов: сверх него теги не добавляются, а скринридер слышит, что предел достигнут. |
trim | boolean | undefined | true | Обрезать пробелы по краям тега. По умолчанию `true`. |
state | "default" | "success" | "warning" | "danger" | undefined | "default" | Подсветка рамки по решению разработчика. `success` и `warning` добавляют иконку и скрытую подпись; `invalid` сильнее. По умолчанию `default`. |
separators | string[] | undefined | [","] | Клавиши, которые превращают набранное в тег, — по ним же режется вставка. `Enter` работает всегда. По умолчанию `[',']`. |
allowDuplicates | boolean | undefined | false | Разрешить одинаковые теги. По умолчанию повтор молча не добавляется. |
addOnBlur | boolean | undefined | false | Недонабранный текст становится тегом при уходе фокуса, а не пропадает. По умолчанию `false`. |
clearInputOnAdd | boolean | undefined | true | Очищать поле ввода после добавления тега. По умолчанию `true`. |
beforeAdd | ((tag: string) => boolean | Promise<boolean>) | undefined | undefined | Проверка тега перед добавлением. Может быть асинхронной (проверка на сервере). Отклонённый тег не добавляется и уходит в событие `reject`. |
editable | boolean | undefined | false | Правка тега на месте: `F2` на чипе или двойной клик по нему открывают поле, `Enter` подтверждает, `Escape` отменяет. Проп, а не поведение по умолчанию: двойной клик по чипу сегодня ничего не делает, и превращать его во вход в правку у всех потребителей — тихая смена поведения. |
tagClosable | boolean | undefined | true | Крестик снятия на каждом чипе. По умолчанию `true`; без него тег снимается только `Backspace` из пустого поля, а правка (`editable`) недоступна с клавиатуры. |
removeTagLabel | string | undefined | undefined | i18n-friendly aria-label for the per-tag remove button. |
clearAllLabel | string | undefined | undefined | i18n aria-label кнопки «снести все». |
modelValueобязательныйv-model | string[] | — | Набор тегов (`v-model`). Компонент его не мутирует — отдаёт новый массив. |
prefixMinWidth | string | undefined | — | Ширины аддонов `prefix`/`suffix` — общий контракт контролов пакета (`docs/form-controls.md`). |
prefixMaxWidth | string | undefined | — | Максимальная ширина префикса. |
suffixMinWidth | string | undefined | — | Минимальная ширина суффикса. По умолчанию — по ступени размера. |
suffixMaxWidth | string | undefined | — | Максимальная ширина суффикса. |
prefixFixed | boolean | undefined | — | Жёсткая ширина префикса (`prefixMaxWidth` → `prefixMinWidth` → по ступени) — чтобы аддоны соседних полей встали ровной колонкой. |
suffixFixed | boolean | undefined | — | Жёсткая ширина суффикса — как у `prefixFixed`. |
Слоты
| Slot | Type | Описание |
|---|---|---|
prefix | any | Аддон слева от чипов: иконка, метка. |
suffix | any | Аддон справа, перед кнопкой очистки. |
tag | { tag: string; index: number; remove: () => void; } | Содержимое чипа: подпись, значок, счётчик. Сам чип, его тон и кнопка снятия остаются за компонентом; `remove` — для своих действий внутри подписи. |
События
| Event | Сигнатура | Описание |
|---|---|---|
update:modelValue | (event: "update:modelValue", value: string[]): void | — |
change | (event: "change", value: string[]): void | — |
clear | (event: "clear"): void | — |
focus | (event: "focus", event: FocusEvent): void | — |
blur | (event: "blur", event: FocusEvent): void | — |
add | (event: "add", value: string): void | — |
remove | (event: "remove", value: string, index: number): void | — |
reject | (event: "reject", value: string): void | — |
edit | (event: "edit", value: string, index: number, previous: string): void | — |
Методы
| Methods | Сигнатура | Описание |
|---|---|---|
focus | () => void | Фокус в поле ввода тега. |
blur | () => void | Снять фокус с поля ввода. |
clear | () => void | Снять все теги, как кнопкой очистки, и вернуть фокус в поле. В `disabled` и `readonly` ничего не делает. |
Типы
Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.
import type { GrInputTagConfigurableProps, GrInputTagProps, GrInputTagEmits } from '@feugene/granularity/components/GrInputTag'Файлdist/types/components/GrInputTag/config.d.ts
const grInputTagConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrInputTag">;Файлdist/types/components/GrInputTag/defaults.d.ts
Пропы GrInputTag, настраиваемые глобально через componentDefaults.
size типизируем через GrComponentSize (та же шкала xs…lg), а не через
GrInputTagSize: последний выводится из GrInput.vue, который импортирует
контекст, — получился бы цикл модулей (то же ограничение, что у GrInput).
interface GrInputTagConfigurableProps {
size: GrComponentSize;
clearable: boolean;
}Файлdist/types/components/GrInputTag/GrInputTag.vue.d.ts
interface GrInputTagProps {
/** Набор тегов (`v-model`). Компонент его не мутирует — отдаёт новый массив. */
modelValue: string[];
/**
* Подсказка в пустом поле — пропадает с первым тегом. Подпись не заменяет:
* имя даёт `GrFormField` или `ariaLabel`.
*/
placeholder?: string;
/** Выключить поле: теги не добавляются и не снимаются. Складывается с `GrFormField`. */
disabled?: boolean;
/** Только для чтения: теги видно, но ни добавить, ни снять, ни очистить их нельзя. */
readonly?: boolean;
/** Ошибка валидации: danger-рамка и `aria-invalid`. Складывается с ошибкой `GrFormField`. */
invalid?: boolean;
/** Обязательное поле (`aria-required`). Складывается с `required` у `GrFormField`. */
required?: boolean;
/**
* Подсветка рамки по решению разработчика. `success` и `warning` добавляют иконку
* и скрытую подпись; `invalid` сильнее. По умолчанию `default`.
*/
state?: GrInputTagState;
/** Высота и кегль по шкале контролов. По умолчанию `md`; берётся из `GrConfigProvider`. */
size?: GrInputTagSize;
/**
* Клавиши, которые превращают набранное в тег, — по ним же режется вставка.
* `Enter` работает всегда. По умолчанию `[',']`.
*/
separators?: string[];
/** Разрешить одинаковые теги. По умолчанию повтор молча не добавляется. */
allowDuplicates?: boolean;
/** Обрезать пробелы по краям тега. По умолчанию `true`. */
trim?: boolean;
/** Предел числа тегов: сверх него теги не добавляются, а скринридер слышит, что предел достигнут. */
max?: number;
/** Недонабранный текст становится тегом при уходе фокуса, а не пропадает. По умолчанию `false`. */
addOnBlur?: boolean;
/** Очищать поле ввода после добавления тега. По умолчанию `true`. */
clearInputOnAdd?: boolean;
/**
* Проверка тега перед добавлением. Может быть асинхронной (проверка на
* сервере). Отклонённый тег не добавляется и уходит в событие `reject`.
*/
beforeAdd?: (tag: string) => boolean | Promise<boolean>;interface GrInputTagEmits {
/** Набор тегов изменился (`v-model`): добавление, снятие, правка или очистка. */
(e: 'update:modelValue', value: string[]): void;
/** Тег добавлен — по одному событию на тег, в том числе при вставке нескольких сразу. */
(e: 'add', value: string): void;
/** Тег снят крестиком или с клавиатуры: значение и позиция, на которой он стоял. */
(e: 'remove', value: string, index: number): void;
/** Тег изменён на месте: новое значение, его позиция и то, что стояло раньше. */
(e: 'edit', value: string, index: number, previous: string): void;
/** Тег не прошёл `beforeAdd`. */
(e: 'reject', value: string): void;
/** Набор снесён кнопкой «очистить». */
(e: 'clear'): void;
/** Набор тегов изменился. */
(e: 'change', value: string[]): void;
/** Фокус вошёл в поле. Переходы между вводом и чипами внутри него события не дают. */
(e: 'focus', event: FocusEvent): void;
/** Фокус покинул поле целиком — не при переходе на крестик чипа. */
(e: 'blur', event: FocusEvent): void;
}Файлdist/types/components/GrInputTag/grInputTagStyles.d.ts
type GrInputTagSize = GrInputSize;type GrInputTagState = GrControlState;Файлdist/types/components/GrInputTag/safelist.d.ts
const grInputTagSafelist: string[];Файлdist/types/components/GrInputTag/index.d.ts
type GrInputTagInstance = ComponentExposed<typeof GrInputTagComponent>;Доступность
Паттерн APG
—
Клавиши
Delete / Backspace
| Клавиша | Действие |
|---|---|
| — | Enter / разделитель — добавить тег; Backspace в пустом поле — удалить последний; ← из пустого поля — на последний чип. По чипам: ←/→ (за последним — поле ввода) |
| — | Home/End |
| Delete / Backspace | удалить. Все крестики — одна остановка Tab (roving tabindex). При editable — F2 на чипе открывает правку |
| — | Enter сохраняет |
| — | Escape отменяет, уход фокуса сохраняет |
Полный контракт пакета: docs/keyboard.md ↗
Использование из прозы библиотеки
Клавиатура: один таб-стоп на весь набор
Крестики чипов живут по roving tabindex: в таб-порядке ровно одна кнопка,
остальные достижимы стрелками. До этого двадцать тегов давали двадцать одну
остановку Tab — набор было невозможно проскочить.
| Клавиша | Где | Что делает |
|---|---|---|
Enter, разделитель | поле ввода | добавить тег |
Backspace | пустое поле ввода | удалить последний тег |
← | пустое поле ввода | перейти на последний чип |
← / → | чип | предыдущий / следующий чип (за последним — поле ввода) |
Home / End | чип | первый / последний чип |
Delete, Backspace | чип | удалить чип; фокус переезжает на соседа, а если тегов не осталось — в поле |
F2 | чип | открыть правку (при editable) |
Enter | правка | сохранить |
Escape | правка | отменить |
Имя кнопки называет свой тег («Удалить тег vue»), а не безликое «Удалить тег»: на двадцати одинаковых кнопках выбрать нужную иначе нельзя.
Набор чипов объявлен списком (role="list" / listitem), поэтому диктор
сообщает количество тегов. Контейнер списка — display: contents: роль нужна
для семантики, раскладку он не трогает, чипы переносятся в одном потоке с полем.
Правка тега на месте
editable открывает правку двойным кликом по тегу или F2 с клавиатуры. До
неё чип либо существовал, либо удалялся целиком: опечатка в
design-system-tokens-v2 стоила полного перенабора.
F2, а не Enter. Фокусируемый узел у чипа один — кнопка снятия, и Enter
на ней уже означает «удалить» нативной активацией кнопки. Отсюда же
зависимость: без tagClosable в правку с клавиатуры не войти вовсе, и в dev об
этом предупреждает консоль.
Уход фокуса сохраняет. Это осознанное расхождение с addOnBlur (тот по
умолчанию false): он решает, становится ли недонабранное новое тегом, и
там отбросить безопасно — до этого не было ничего. Правка же начинается с
существующего значения, и тихо вернуть набранное — как раз тот исход, который
удивит. Отказаться есть чем: Escape.
Пустое значение по Enter снимает тег, по уходу фокуса — нет. Удаление
остаётся явным действием: «стёр текст и отвлёкся» не должно стоить тега.
beforeAdd проверяет и правку. Проп назван про добавление, но правило у тега
одно, и второй предикат разошёлся бы с первым; отказ возвращает прежнее
значение и объявляется живым регионом. Разделителей в правке нет: одна правка —
один тег, иначе запятая молча превращала бы плитку в три.
max правку не ограничивает — набор от неё не растёт.
Внутри GrModal и других слоёв Escape отменяет правку, а окно не закрывает:
сессия правки регистрируется в общем стеке слоёв.
Предел набора
max больше не блокирует поле. Раньше на пределе инпут получал disabled:
он выпадал из таб-порядка и переставал принимать Backspace — единственный
способ убрать тег с клавиатуры. Теперь поле остаётся живым, лишние теги просто
не добавляются, а исчерпание предела объявляется живым регионом.
Добавление и удаление тега тоже объявляются: без этого изменение набора для
незрячего пользователя выглядело как «ничего не произошло». Все четыре
объявления уходят в общий живой регион пакета — announcer.md,
своего role="status" у компонента больше нет.
Проверка перед добавлением
<GrInputTag
v-model="emails"
:before-add="tag => /.+@.+\..+/.test(tag)"
@reject="showError"
/>beforeAdd может быть асинхронным (проверка на сервере) — на время проверки
поднимается спиннер и aria-busy. Второй Enter отменяет предыдущую проверку:
результат устаревшей не дописывается. Отклонённый тег уходит в событие reject,
чтобы потребитель мог объяснить причину.
clearable и loading
clearable добавляет кнопку «снести все» (видна, только когда есть теги и поле
редактируемо) и событие clear. loading — тот же спиннер, что поднимает
асинхронный beforeAdd, но под ручным управлением.
События
| Событие | Когда |
|---|---|
update:modelValue | набор изменился |
add | добавлен тег |
remove | удалён тег, аргументы — тег и его индекс |
reject | тег не прошёл beforeAdd |
clear | набор снесён кнопкой |
Императивный API
focus(), blur(), clear() через ref компонента.
Состояния
state="success" и "warning" показывают иконку и скрытую подпись, которая
уезжает в aria-describedby контрола: цвет рамки как единственный носитель
смысла — это WCAG 1.4.1, а ARIA-эквивалента aria-invalid у этих двух
состояний нет. danger иконки не получает — его смысл уже несут aria-invalid
и текст ошибки от GrFormField, — и признак гаснет, как только контрол
объявлен невалидным.
Размер и токены
size читается из GrConfigProvider (глобальный size или точечный
componentDefaults), туда же вынесен clearable. Заблокированное поле гасится
фоном --gr-muted с текстом --gr-muted-fg, а не прозрачностью: opacity
разбавляет выверенные на AA токены. Крестик чипа наследует цвет тона бейджа в
полную силу — гасить его прозрачностью нельзя, на тёмном чипе контраст
проваливается.
Аддоны prefix / suffix
Слоты кладут в оболочку иконку, единицу или метку; ширина ограничивается шестью
пропами (prefixMinWidth/prefixMaxWidth/prefixFixed и то же для суффикса).
Общий контракт контролов — form-controls.md.
Слот tag — содержимое чипа, а не чип
Слот рисует содержимое чипа: подпись, значок, счётчик. Сам чип
(GrChip), его тон и кнопка снятия остаются за компонентом — свой чип с
крестиком внутри слота дал бы чип в чипе и два крестика. remove в пропах
слота — для своих действий внутри подписи, а не для второй кнопки снятия.
<template>
<GrInputTag v-model="emails">
<template #tag="{ tag }">
<span class="font-500">{{ tag.split('@')[0] }}</span>
<span class="text-[var(--gr-muted-fg)]">@{{ tag.split('@')[1] }}</span>
</template>
</GrInputTag>
</template>Нативная форма
Проп name рендерит input[type="hidden"] на каждый тег — стандартная
сериализация набора повторяющимся ключом; пустой набор не отправляет ничего.
Пропы этого примера