GrInputTag
Lets you enter and edit a list of tags or values.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- the values are invented by the user — the tags of an article, keywords, mailing addresses: there is no reference list;
- the values are pasted in bulk —
separatorssplits a string pasted from the clipboard into chips; - what is added has to be checked —
beforeAddrejects an invalid address or a duplicate before a chip appears; - the number is limited —
maxdoes not let an extra one be added.
When to take something else
| Need | Take |
|---|---|
| There is a reference list of values | GrSelect with multiple and tags |
| The reference list is large, the values are searched by typing | GrAutocomplete |
| There is a single value | GrInput |
| The values are nested | GrTreeSelect |
The keyboard: one tab stop for the whole set
The crosses of the chips live by a roving tabindex: there is exactly one button in the
tab order, and the rest are reachable with the arrows. Before that twenty tags gave
twenty-one Tab stops — the set was impossible to skip past.
| Key | Where | What it does |
|---|---|---|
Enter, a separator | the input field | add a tag |
Backspace | an empty input field | remove the last tag |
← | an empty input field | move to the last chip |
← / → | a chip | the previous / the next chip (past the last one — the input field) |
Home / End | a chip | the first / the last chip |
Delete, Backspace | a chip | remove the chip; the focus moves to a neighbour, and if no tags are left — into the field |
The name of a button names its own tag (“Remove the tag vue”) rather than a faceless “Remove the tag”: on twenty identical buttons there is otherwise no way to choose the right one.
The set of chips is declared a list (role="list" / listitem), so a screen reader reports
the number of tags. The container of the list is display: contents: the role is needed for
the semantics, it does not touch the layout, and the chips wrap in one flow with the field.
The limit of the set
max no longer blocks the field. It used to give the input a disabled at the limit: it
dropped out of the tab order and stopped accepting Backspace — the only way to remove a tag
from the keyboard. Now the field stays alive, extra tags simply are not added, and the
exhaustion of the limit is announced by a live region.
Adding and removing a tag are announced as well: without that a change of the set looked to a
blind user like “nothing happened”. All four announcements go into the shared live region of
the package — announcer.md; the component no longer has a role="status"
of its own.
The check before adding
<GrInputTag
v-model="emails"
:before-add="tag => /.+@.+\..+/.test(tag)"
@reject="showError"
/>
beforeAdd may be asynchronous (a check on the server) — for the duration of the check a
spinner and aria-busy are raised. A second Enter cancels the previous check: the result of
the stale one is not appended. A rejected tag leaves in the reject event, so that the
consumer can explain the reason.
`clearable` and `loading`
clearable adds a “remove all” button (visible only when there are tags and the field is
editable) and a clear event. loading is the same spinner an asynchronous beforeAdd
raises, but under manual control.
The events
| Event | When |
|---|---|
update:modelValue | the set has changed |
add | a tag has been added |
remove | a tag has been removed, the arguments are the tag and its index |
reject | the tag did not pass beforeAdd |
clear | the set was removed with the button |
The imperative API
focus(), blur(), clear() through a ref on the component.
The size and the tokens
size is read from GrConfigProvider (the global size or a pointed componentDefaults),
and clearable has been moved there as well. A blocked field is dimmed with the --gr-muted
background and --gr-muted-fg text rather than with transparency: opacity dilutes tokens
tuned to AA. The cross of a chip inherits the colour of the badge tone at full strength —
dimming it with transparency is not allowed, on a dark chip the contrast fails.
The `prefix` / `suffix` addons
The slots put an icon, a unit or a label into the shell; the width is bounded by six props
(prefixMinWidth/prefixMaxWidth/prefixFixed and the same for the suffix). The shared
contract of the controls — form-controls.md.
The native form
The name prop renders an input[type="hidden"] per tag — the standard serialisation of a
set with a repeated key; an empty set sends nothing.
Playground 28
Loading…
<GrInputTag />Install
npm i @feugene/granularityImport
import { GrInputTag } from '@feugene/granularity/components/GrInputTag'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
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 | A required field (`aria-required`). It adds to the `required` of `GrFormField`. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
placeholder | string | undefined | undefined | — |
ariaLabel | string | undefined | undefined | The name of the control when it is used outside `GrFormField`. Inside a field the name comes from the pairing with its `<label for>` — there the prop is not needed. Without either the input has no accessible name at all: a placeholder does not count as a name. |
clearable | boolean | undefined | undefined | A "remove all tags" button. It is configured through `GrConfigProvider`. |
loading | boolean | undefined | false | Background work: a spinner plus `aria-busy`. An asynchronous `beforeAdd` raises it itself. |
name | string | undefined | undefined | The name for a native form: a hidden input per tag. |
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 | A check of a tag before adding. It may be asynchronous (a check on the server). A rejected tag is not added and leaves in the `reject` event. |
tagClosable | boolean | undefined | true | — |
removeTagLabel | string | undefined | undefined | i18n-friendly aria-label for the per-tag remove button. |
clearAllLabel | string | undefined | undefined | The i18n aria-label of the "remove all" button. |
modelValuerequired | string[] | — | — |
prefixMinWidth | string | undefined | — | The widths of the `prefix`/`suffix` addons — the shared contract of the controls of the package (`docs/form-controls.md`). |
prefixMaxWidth | string | undefined | — | — |
suffixMinWidth | string | undefined | — | — |
suffixMaxWidth | string | undefined | — | — |
prefixFixed | boolean | undefined | — | — |
suffixFixed | boolean | undefined | — | — |
Slots
| Slot | Type | Description |
|---|---|---|
prefix | any | An addon to the left of the chips: an icon, a label. |
suffix | any | An addon on the right, before the clear button. |
tag | { tag: string; index: number; remove: () => void; } | A chip of your own: `remove` takes the value off. |
Events
| Event | Type | Description |
|---|---|---|
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 | Description |
|---|---|---|
focus | () => void | — |
blur | () => void | — |
clear | () => void | — |
Examples 5
Addons around the chips
<script setup lang="ts">
import { ref } from 'vue'
import { GrInputTag } from '@feugene/granularity'
const recipients = ref(['ops@acme.io'])
</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>Validation
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(['ops@example.com'])
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="name@example.com"
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>Basic tag entry with live summary
<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>Controlled limit with semantic state
<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>Custom tag slot for semantic badges
<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>Accessibility
- APG pattern
—