GrInputTag
Берут, когда значения придумывает пользователь.
Когда брать
- значения придумывает пользователь — теги статьи, ключевые слова, адреса рассылки: справочника нет;
- значения вставляют пачкой —
separatorsразбивает вставленную из буфера строку на чипы; - добавляемое надо проверить —
beforeAddотклоняет неверный адрес или дубликат до появления чипа; - количество ограничено —
maxне даёт добавить лишнее.
Когда взять другое
| Нужно | Берите |
|---|---|
| Есть справочник значений | GrSelect с multiple и tags |
| Справочник большой, значения ищут вводом | GrAutocomplete |
| Значение одно | GrInput |
| Значения вложены | GrTreeSelect |
Клавиатура: один таб-стоп на весь набор
Крестики чипов живут по roving tabindex: в таб-порядке ровно одна кнопка,
остальные достижимы стрелками. До этого двадцать тегов давали двадцать одну
остановку Tab — набор было невозможно проскочить.
| Клавиша | Где | Что делает |
|---|---|---|
Enter, разделитель | поле ввода | добавить тег |
Backspace | пустое поле ввода | удалить последний тег |
← | пустое поле ввода | перейти на последний чип |
← / → | чип | предыдущий / следующий чип (за последним — поле ввода) |
Home / End | чип | первый / последний чип |
Delete, Backspace | чип | удалить чип; фокус переезжает на соседа, а если тегов не осталось — в поле |
Имя кнопки называет свой тег («Удалить тег vue»), а не безликое «Удалить тег»: на двадцати одинаковых кнопках выбрать нужную иначе нельзя.
Набор чипов объявлен списком (role="list" / listitem), поэтому диктор
сообщает количество тегов. Контейнер списка — display: contents: роль нужна
для семантики, раскладку он не трогает, чипы переносятся в одном потоке с полем.
Предел набора
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 компонента.
Размер и токены
size читается из GrConfigProvider (глобальный size или точечный
componentDefaults), туда же вынесен clearable. Заблокированное поле гасится
фоном --gr-muted с текстом --gr-muted-fg, а не прозрачностью: opacity
разбавляет выверенные на AA токены. Крестик чипа наследует цвет тона бейджа в
полную силу — гасить его прозрачностью нельзя, на тёмном чипе контраст
проваливается.
Аддоны `prefix` / `suffix`
Слоты кладут в оболочку иконку, единицу или метку; ширина ограничивается шестью
пропами (prefixMinWidth/prefixMaxWidth/prefixFixed и то же для суффикса).
Общий контракт контролов — form-controls.md.
Нативная форма
Проп name рендерит input[type="hidden"] на каждый тег — стандартная
сериализация набора повторяющимся ключом; пустой набор не отправляет ничего.
Playground 28
Загружается…
<GrInputTag />Установка
npm i @feugene/granularityИмпорт
import { GrInputTag } from '@feugene/granularity/components/GrInputTag'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
tagTone | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined | "neutral" | — |
tagDark | boolean | undefined | false | — |
tagSize | "xs" | "sm" | "md" | "lg" | undefined | "md" | — |
tagRadius | GrBadgeRadius | undefined | "round" | — |
disabled | boolean | undefined | false | — |
readonly | boolean | undefined | false | — |
invalid | boolean | undefined | false | — |
required | boolean | undefined | false | Обязательное поле (`aria-required`). Складывается с `required` у `GrFormField`. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
placeholder | string | undefined | undefined | — |
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 | — |
state | "default" | "success" | "warning" | "danger" | undefined | "default" | — |
separators | string[] | undefined | [","] | — |
allowDuplicates | boolean | undefined | false | — |
addOnBlur | boolean | undefined | false | — |
clearInputOnAdd | boolean | undefined | true | — |
beforeAdd | ((tag: string) => boolean | Promise<boolean>) | undefined | undefined | Проверка тега перед добавлением. Может быть асинхронной (проверка на сервере). Отклонённый тег не добавляется и уходит в событие `reject`. |
tagClosable | boolean | undefined | true | — |
removeTagLabel | string | undefined | undefined | i18n-friendly aria-label for the per-tag remove button. |
clearAllLabel | string | undefined | undefined | i18n aria-label кнопки «снести все». |
modelValueобязательный | string[] | — | — |
prefixMinWidth | string | undefined | — | Ширины аддонов `prefix`/`suffix` — общий контракт контролов пакета (`docs/form-controls.md`). |
prefixMaxWidth | string | undefined | — | — |
suffixMinWidth | string | undefined | — | — |
suffixMaxWidth | string | undefined | — | — |
prefixFixed | boolean | undefined | — | — |
suffixFixed | boolean | undefined | — | — |
Slots
| Slot | Type | Описание |
|---|---|---|
prefix | any | Аддон слева от чипов: иконка, метка. |
suffix | any | Аддон справа, перед кнопкой очистки. |
tag | { tag: string; index: number; remove: () => void; } | Свой чип: `remove` снимает значение. |
Events
| Event | Type | Описание |
|---|---|---|
update:modelValue | [value: string[]] | — |
change | [value: string[]] | — |
clear | [] | — |
focus | [event: FocusEvent] | — |
blur | [event: FocusEvent] | — |
add | [value: string] | — |
remove | [value: string, index: number] | — |
reject | [value: string] | — |
Methods / Expose
| Methods / Expose | Type | Описание |
|---|---|---|
focus | () => void | — |
blur | () => void | — |
clear | () => void | — |
Примеры 5
Аддоны вокруг чипов
Слоты prefix и suffix в оболочке: иконка слева, счётчик набора справа — чипы остаются на своём месте.
<script setup lang="ts">
import { ref } from 'vue'
import { GrInputTag } from '@feugene/granularity'
const recipients = ref(['[email protected]'])
</script>
<template>
<GrInputTag
v-model="recipients"
clearable
placeholder="Add a recipient"
aria-label="Recipients"
>
<template #prefix>
<span class="i-lucide-mail block h-4 w-4" />
</template>
<template #suffix>
{{ recipients.length }}/10
</template>
</GrInputTag>
</template>Проверка тега перед добавлением
Асинхронный beforeAdd со спиннером, событие reject для объяснения отказа и clearable для сброса набора.
Enter или запятая — добавить. Разрешены домены example.com и granularity.dev
<script setup lang="ts">
import { ref } from 'vue'
import { GrFormField, GrInputTag } from '@feugene/granularity'
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const KNOWN_DOMAINS = ['example.com', 'granularity.dev']
const recipients = ref(['[email protected]'])
const error = ref('')
// Проверка асинхронная намеренно: так же выглядит обращение к серверу за
// «существует ли такой адрес». На время проверки поле показывает спиннер.
async function beforeAdd(tag: string): Promise<boolean> {
error.value = ''
if (!EMAIL_RE.test(tag)) {
error.value = `«${tag}» не похож на адрес`
return false
}
await new Promise(resolve => setTimeout(resolve, 500))
const domain = tag.split('@')[1] ?? ''
if (!KNOWN_DOMAINS.includes(domain)) {
error.value = `Домен ${domain} не в списке разрешённых`
return false
}
return true
}
</script>
<template>
<div class="grid gap-3">
<GrFormField
label="Получатели"
hint="Enter или запятая — добавить. Разрешены домены example.com и granularity.dev"
:error="error"
>
<GrInputTag
v-model="recipients"
:before-add="beforeAdd"
:separators="[',', ' ']"
clearable
placeholder="[email protected]"
tag-tone="primary"
@clear="error = ''"
/>
</GrFormField>
<div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
Крестики чипов — одна остановка `Tab`: между ними ходят стрелки влево-вправо, удаляет `Delete`.
Из пустого поля на последний чип уводит стрелка влево.
</div>
</div>
</template>Базовый набор тегов с живой сводкой
Базовый live-demo фиксирует основной UX: ввод, Enter/separator commit и отражение списка тегов на стороне хоста.
<script setup lang="ts">
import { ref } from 'vue'
import { GrInputTag } from '@feugene/granularity'
const tags = ref(['critical', 'backend'])
</script>
<template>
<div class="grid gap-4">
<GrInputTag
v-model="tags"
placeholder="Type a tag and press Enter"
aria-label="Incident tags"
add-on-blur
:separators="[',', ';']"
/>
<div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4 text-sm text-[var(--gr-muted-fg)]">
Current tags: <span class="font-semibold text-[var(--gr-fg)]">{{ tags.join(', ') || 'none' }}</span>
</div>
</div>
</template>Управляемый предел и семантическое состояние
Отдельно документируем сценарий с max: компонент удобно использовать для curated lists и constrained profile metadata.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrBadge, GrInputTag } from '@feugene/granularity'
const skills = ref(['vue', 'typescript'])
const remaining = computed(() => 4 - skills.value.length)
</script>
<template>
<div class="grid gap-4">
<div class="flex flex-wrap gap-2">
<GrBadge tone="neutral" radius="round">{{ skills.length }}/4 selected</GrBadge>
<GrBadge tone="neutral" radius="round">{{ remaining }} slots left</GrBadge>
</div>
<GrInputTag
v-model="skills"
:max="4"
state="success"
placeholder="Add skill tags"
aria-label="Skill tags"
tag-tone="primary"
tag-radius="round"
/>
<div class="text-sm text-[var(--gr-muted-fg)]">
Use `max` to keep curated lists compact in profile or filter forms.
</div>
</div>
</template>Свой слот тега для семантических бейджей
Через slot tag витрина показывает, как host-screen может переоформить tag-pill и добавить собственные маркеры статуса.
<script setup lang="ts">
import { ref } from 'vue'
import { GrInputTag } from '@feugene/granularity'
const environments = ref(['production', 'staging'])
</script>
<template>
<div class="grid gap-4">
<GrInputTag
v-model="environments"
placeholder="Environment alias"
aria-label="Environment aliases"
tag-tone="warning"
tag-dark
>
<template #tag="{ tag, index }">
<span class="inline-flex items-center gap-2">
<span class="inline-flex h-2 w-2 rounded-full bg-current opacity-70" />
<span>{{ index + 1 }}. {{ tag }}</span>
</span>
</template>
</GrInputTag>
<div class="text-sm text-[var(--gr-muted-fg)]">
Custom tag slot lets host screens inject status markers, counters or semantic labels.
</div>
</div>
</template>Доступность
- Паттерн APG
—- Клавиши
Enter/ разделитель — добавить тег;Backspaceв пустом поле — удалить последний;←из пустого поля — на последний чип. По чипам:←/→(за последним — поле ввода),Home/End,Delete/Backspace— удалить. Все крестики — одна остановкаTab(roving tabindex)