GrInputTag

Package: @feugene/granularitycoreGroup: forms

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 bulkseparators splits a string pasted from the clipboard into chips;
  • what is added has to be checkedbeforeAdd rejects an invalid address or a duplicate before a chip appears;
  • the number is limitedmax does not let an extra one be added.

When to take something else

NeedTake
There is a reference list of valuesGrSelect with multiple and tags
The reference list is large, the values are searched by typingGrAutocomplete
There is a single valueGrInput
The values are nestedGrTreeSelect

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.

KeyWhereWhat it does
Enter, a separatorthe input fieldadd a tag
Backspacean empty input fieldremove the last tag
an empty input fieldmove to the last chip
/ a chipthe previous / the next chip (past the last one — the input field)
Home / Enda chipthe first / the last chip
Delete, Backspacea chipremove 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

EventWhen
update:modelValuethe set has changed
adda tag has been added
removea tag has been removed, the arguments are the tag and its index
rejectthe tag did not pass beforeAdd
clearthe 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…

Code
<GrInputTag />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
tagTone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined"neutral"
tagDarkboolean | undefinedfalse
tagSize"xs" | "sm" | "md" | "lg" | undefined"md"
tagRadiusGrBadgeRadius | undefined"round"
disabledboolean | undefinedfalse
readonlyboolean | undefinedfalse
invalidboolean | undefinedfalse
requiredboolean | undefinedfalseA required field (`aria-required`). It adds to the `required` of `GrFormField`.
size"xs" | "sm" | "md" | "lg" | undefinedundefined
placeholderstring | undefinedundefined
ariaLabelstring | undefinedundefinedThe 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.
clearableboolean | undefinedundefinedA "remove all tags" button. It is configured through `GrConfigProvider`.
loadingboolean | undefinedfalseBackground work: a spinner plus `aria-busy`. An asynchronous `beforeAdd` raises it itself.
namestring | undefinedundefinedThe name for a native form: a hidden input per tag.
maxnumber | undefinedundefined
trimboolean | undefinedtrue
state"default" | "success" | "warning" | "danger" | undefined"default"
separatorsstring[] | undefined[","]
allowDuplicatesboolean | undefinedfalse
addOnBlurboolean | undefinedfalse
clearInputOnAddboolean | undefinedtrue
beforeAdd((tag: string) => boolean | Promise<boolean>) | undefinedundefinedA 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.
tagClosableboolean | undefinedtrue
removeTagLabelstring | undefinedundefinedi18n-friendly aria-label for the per-tag remove button.
clearAllLabelstring | undefinedundefinedThe i18n aria-label of the "remove all" button.
modelValuerequiredstring[]
prefixMinWidthstring | undefinedThe widths of the `prefix`/`suffix` addons — the shared contract of the controls of the package (`docs/form-controls.md`).
prefixMaxWidthstring | undefined
suffixMinWidthstring | undefined
suffixMaxWidthstring | undefined
prefixFixedboolean | undefined
suffixFixedboolean | undefined

Slots

SlotTypeDescription
prefixanyAn addon to the left of the chips: an icon, a label.
suffixanyAn 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

EventTypeDescription
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 / ExposeTypeDescription
focus() => void
blur() => void
clear() => void

Examples 5

Addons around the chips

Addons
<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>

Validation

Enter или запятая — добавить. Разрешены домены example.com и granularity.dev

Крестики чипов — одна остановка `Tab`: между ними ходят стрелки влево-вправо, удаляет `Delete`. Из пустого поля на последний чип уводит стрелка влево.

Validation
<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>

Basic tag entry with live summary

criticalbackend
Current tags: critical, backend

Basic Flow
<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

2/4 selected2 slots left
vuetypescript
Use `max` to keep curated lists compact in profile or filter forms.

Max 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

1. production2. staging
Custom tag slot lets host screens inject status markers, counters or semantic labels.

Custom Tag
<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

Full keyboard contract of the package

Component documentationAll components