GrInputTag

  • ядро
  • Формы
  • API · 33 props
  • ⌨ клавиши

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

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

Обзор

Когда брать

  • значения придумывает пользователь — теги статьи, ключевые слова, адреса рассылки: справочника нет;
  • значения вставляют пачкой — separators разбивает вставленную из буфера строку на чипы;
  • добавляемое надо проверить — beforeAdd отклоняет неверный адрес или дубликат до появления чипа;
  • количество ограничено — max не даёт добавить лишнее.

Когда взять другое

НужноБерите
Есть справочник значенийGrSelect с multiple и tags
Справочник большой, значения ищут вводомGrAutocomplete
Значение одноGrInput
Значения вложеныGrTreeSelect

Варианты все тона и виды — настоящим компонентом

state

  • default
  • success
  • warning
  • danger

Размеры

  • xs
  • sm
  • md
  • lg

Состояния

  • обычная
  • disabled
  • loading
  • readonly
  • invalid

Матрица живая: наводите и нажимайте. Состояний наведения, фокуса и нажатия здесь нет — они за браузером, а не за пропом.

Playground контролы выводятся из API, а не пишутся руками

Сцена

Загружается…

0 изменено· состояние — в адресе

Примеры 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
    #design-tokensdark-modeacessibilityvue
    Double-click a keyword or press F2 on it to fix a typo.
  • Домены, проверенные сервером

    Асинхронный before-add сам поднимает loading, пока сервер ищет запись подтверждения, и домен без неё не становится чипом. Слот tag наполняет чип — отметка «подтверждён» перед доменом, — а сам чип, его tag-tone и кнопка снятия остаются за компонентом.

    Single sign-onPeople with these email domains sign in through Okta.
    northwind.example, verified

API порождается из пакета · разойтись не может

Props

PropTypeпо умолчаниюОписание
disabledboolean | undefinedfalseВыключить поле: теги не добавляются и не снимаются. Складывается с `GrFormField`.
tagTone
"primary" | "neutral" | "success" | "warning" | "danger" | ""primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined
"neutral"Тон чипов-тегов. По умолчанию `neutral`.
tagDarkboolean | undefinedfalseЧипы с заливкой тоном вместо мягкой подложки. По умолчанию `false`.
tagSize"xs" | "sm" | "md" | "lg" | undefined"md"Кегль чипов по шкале `GrBadge` — та же ступень, что у тегов `GrSelect`. По умолчанию `md`.
tagRadiusGrBadgeRadius | undefined"round"Форма чипов: `round`, `semi` или `square`. По умолчанию `round`.
readonlyboolean | undefinedfalseТолько для чтения: теги видно, но ни добавить, ни снять, ни очистить их нельзя.
invalidboolean | undefinedfalseОшибка валидации: danger-рамка и `aria-invalid`. Складывается с ошибкой `GrFormField`.
requiredboolean | undefinedfalseОбязательное поле (`aria-required`). Складывается с `required` у `GrFormField`.
size"xs" | "sm" | "md" | "lg" | undefinedundefinedВысота и кегль по шкале контролов. По умолчанию `md`; берётся из `GrConfigProvider`.
placeholderstring | undefinedundefinedПодсказка в пустом поле — пропадает с первым тегом. Подпись не заменяет: имя даёт `GrFormField` или `ariaLabel`.
ariaLabelstring | undefinedundefinedИмя контрола, когда он используется вне `GrFormField`. Внутри поля имя даёт связка с его `<label for>` — там проп не нужен. Без того и другого у инпута нет доступного имени вовсе: placeholder именем не считается.
clearableboolean | undefinedundefinedКнопка «снести все теги». Настраивается через `GrConfigProvider`.
loadingboolean | undefinedfalseФоновая работа: спиннер + `aria-busy`. Асинхронный `beforeAdd` поднимает его сам.
namestring | undefinedundefinedИмя для нативной формы: hidden input на каждый тег.

Слоты

SlotTypeОписание
prefixanyАддон слева от чипов: иконка, метка.
suffixanyАддон справа, перед кнопкой очистки.
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

grInputTagConfigconst
const grInputTagConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrInputTag">;

Файлdist/types/components/GrInputTag/defaults.d.ts

GrInputTagConfigurablePropsinterface

Пропы GrInputTag, настраиваемые глобально через componentDefaults. size типизируем через GrComponentSize (та же шкала xs…lg), а не через GrInputTagSize: последний выводится из GrInput.vue, который импортирует контекст, — получился бы цикл модулей (то же ограничение, что у GrInput).

interface GrInputTagConfigurableProps {
    size: GrComponentSize;
    clearable: boolean;
}

Файлdist/types/components/GrInputTag/GrInputTag.vue.d.ts

GrInputTagPropsinterface
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>;
GrInputTagEmitsinterface
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

GrInputTagSizetype
type GrInputTagSize = GrInputSize;
GrInputTagStatetype
type GrInputTagState = GrControlState;

Файлdist/types/components/GrInputTag/safelist.d.ts

grInputTagSafelistconst
const grInputTagSafelist: string[];

Файлdist/types/components/GrInputTag/index.d.ts

GrInputTagInstancetype
type GrInputTagInstance = ComponentExposed<typeof GrInputTagComponent>;

Доступность

Паттерн APG

—

Клавиши

Delete / Backspace

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

Использование из прозы библиотеки

Клавиатура: один таб-стоп на весь набор

Крестики чипов живут по 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" у компонента больше нет.

Проверка перед добавлением
vue
<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 в пропах слота — для своих действий внутри подписи, а не для второй кнопки снятия.

vue
<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"] на каждый тег — стандартная сериализация набора повторяющимся ключом; пустой набор не отправляет ничего.