GrInputTag

Пакет: @feugene/granularityядроГруппа: Формы

Берут, когда значения придумывает пользователь.

Когда брать

  • значения придумывает пользователь — теги статьи, ключевые слова, адреса рассылки: справочника нет;
  • значения вставляют пачкой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

PropTypeпо умолчаниюОписание
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 | undefinedfalseОбязательное поле (`aria-required`). Складывается с `required` у `GrFormField`.
size"xs" | "sm" | "md" | "lg" | undefinedundefined
placeholderstring | undefinedundefined
ariaLabelstring | undefinedundefinedИмя контрола, когда он используется вне `GrFormField`. Внутри поля имя даёт связка с его `<label for>` — там проп не нужен. Без того и другого у инпута нет доступного имени вовсе: placeholder именем не считается.
clearableboolean | undefinedundefinedКнопка «снести все теги». Настраивается через `GrConfigProvider`.
loadingboolean | undefinedfalseФоновая работа: спиннер + `aria-busy`. Асинхронный `beforeAdd` поднимает его сам.
namestring | undefinedundefinedИмя для нативной формы: hidden input на каждый тег.
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>) | undefinedundefinedПроверка тега перед добавлением. Может быть асинхронной (проверка на сервере). Отклонённый тег не добавляется и уходит в событие `reject`.
tagClosableboolean | undefinedtrue
removeTagLabelstring | undefinedundefinedi18n-friendly aria-label for the per-tag remove button.
clearAllLabelstring | undefinedundefinedi18n aria-label кнопки «снести все».
modelValueобязательныйstring[]
prefixMinWidthstring | undefinedШирины аддонов `prefix`/`suffix` — общий контракт контролов пакета (`docs/form-controls.md`).
prefixMaxWidthstring | undefined
suffixMinWidthstring | undefined
suffixMaxWidthstring | undefined
prefixFixedboolean | undefined
suffixFixedboolean | undefined

Slots

SlotTypeОписание
prefixanyАддон слева от чипов: иконка, метка.
suffixanyАддон справа, перед кнопкой очистки.
tag{ tag: string; index: number; remove: () => void; }Свой чип: `remove` снимает значение.

Events

EventTypeОписание
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 / ExposeTypeОписание
focus() => void
blur() => void
clear() => void

Примеры 5

Аддоны вокруг чипов

Слоты prefix и suffix в оболочке: иконка слева, счётчик набора справа — чипы остаются на своём месте.

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>

Проверка тега перед добавлением

Асинхронный beforeAdd со спиннером, событие reject для объяснения отказа и clearable для сброса набора.

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>

Базовый набор тегов с живой сводкой

Базовый live-demo фиксирует основной UX: ввод, Enter/separator commit и отражение списка тегов на стороне хоста.

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>

Управляемый предел и семантическое состояние

Отдельно документируем сценарий с max: компонент удобно использовать для curated lists и constrained profile metadata.

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>

Свой слот тега для семантических бейджей

Через slot tag витрина показывает, как host-screen может переоформить tag-pill и добавить собственные маркеры статуса.

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>

Доступность

Паттерн APG
Клавиши
Enter / разделитель — добавить тег; Backspace в пустом поле — удалить последний; из пустого поля — на последний чип. По чипам: / (за последним — поле ввода), Home/End, Delete/Backspace — удалить. Все крестики — одна остановка Tab (roving tabindex)

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты