GrInputTag

  • core
  • forms
  • API · 33 props
  • ⌨ keys

Lets you enter and edit a list of tags or values.

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

Overview

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 — separators splits a string pasted from the clipboard into chips;
  • what is added has to be checked — beforeAdd rejects an invalid address or a duplicate before a chip appears;
  • the number is limited — max 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

Variants every tone and view, rendered by the real component

state

  • default
  • success
  • warning
  • danger

Sizes

  • xs
  • sm
  • md
  • lg

States

  • normal
  • disabled
  • loading
  • readonly
  • invalid

The grid is live: hover and press it. Hover, focus and active states are not drawn here — they belong to the browser, not to a prop.

Playground controls come from the API, not from hand

Stage

Loading…

0 changed· state is in the URL

Examples 3 each with a live demo and its code

  • Invites from a pasted list

    A line pasted from a spreadsheet becomes separate addresses: separators splits on commas, spaces and semicolons, and add-on-blur keeps what was typed but not confirmed. before-add stops a malformed address or a current member before it turns into a chip, max stops the set where the plan stops, and name sends each address with a native form.

    Invite people to Northwind Traders

    2 of 8 · paste a list or press Enter

  • Keywords fixed in place

    editable fixes «acessibility» where it stands — double-click it or press F2 on it — instead of «delete and type again», and edit reports what changed. The # sits in the prefix addon, and tag-tone sets the chips apart from the text around them.

    Tokens are roles, not coloursBlog post · draft
    #design-tokensdark-modeacessibilityvue
    Double-click a keyword or press F2 on it to fix a typo.
  • Domains checked by the server

    An async before-add raises loading by itself while the server looks for the verification record, and a domain without one never becomes a chip. The tag slot fills the chip — a «verified» mark before the domain — while the chip, its tag-tone and its remove button stay the component’s.

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

API generated from the package · cannot drift

Props

PropTypedefaultDescription
disabledboolean | undefinedfalseSwitch the field off: tags are neither added nor removed. Combined with `GrFormField`.
tagTone
"primary" | "neutral" | "success" | "warning" | "danger" | ""primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined
"neutral"The tone of the tag chips. `neutral` by default.
tagDarkboolean | undefinedfalseChips with a fill in the tone instead of a soft surface. `false` by default.
tagSize"xs" | "sm" | "md" | "lg" | undefined"md"The type size of the chips by the `GrBadge` scale — the same step as the tags of `GrSelect`. `md` by default.
tagRadiusGrBadgeRadius | undefined"round"The shape of the chips: `round`, `semi` or `square`. `round` by default.
readonlyboolean | undefinedfalseRead-only: the tags are visible, but they cannot be added, removed or cleared.
invalidboolean | undefinedfalseA validation error: a danger border and `aria-invalid`. Combined with the error of `GrFormField`.
requiredboolean | undefinedfalseA required field (`aria-required`). It adds to the `required` of `GrFormField`.
size"xs" | "sm" | "md" | "lg" | undefinedundefinedThe height and the type size by the controls scale. `md` by default; taken from `GrConfigProvider`.
placeholderstring | undefinedundefinedA hint in an empty field — it disappears with the first tag. It does not replace the label: the name is given by `GrFormField` or `ariaLabel`.
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.

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; }The content of a chip: the label, an icon, a counter. The chip itself, its tone and the remove button stay with the component; `remove` is for your own actions inside the label.

Events

EventSignatureDescription
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

MethodsSignatureDescription
focus() => voidFocus into the tag input.
blur() => voidRemove the focus from the input.
clear() => voidRemove all the tags, as the clear button does, and return the focus to the field. Does nothing in `disabled` and `readonly`.

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrInputTagConfigurableProps, GrInputTagProps, GrInputTagEmits } from '@feugene/granularity/components/GrInputTag'

Filedist/types/components/GrInputTag/config.d.ts

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

Filedist/types/components/GrInputTag/defaults.d.ts

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

Filedist/types/components/GrInputTag/GrInputTag.vue.d.ts

GrInputTagPropsinterface
interface GrInputTagProps {
    modelValue: string[];
    placeholder?: string;
    disabled?: boolean;
    readonly?: boolean;
    invalid?: boolean;
    required?: boolean;
    state?: GrInputTagState;
    size?: GrInputTagSize;
    separators?: string[];
    allowDuplicates?: boolean;
    trim?: boolean;
    max?: number;
    addOnBlur?: boolean;
    clearInputOnAdd?: boolean;
    beforeAdd?: (tag: string) => boolean | Promise<boolean>;
GrInputTagEmitsinterface
interface GrInputTagEmits {
    (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;
    (e: 'reject', value: string): void;
    (e: 'clear'): void;
    (e: 'change', value: string[]): void;
    (e: 'focus', event: FocusEvent): void;
    (e: 'blur', event: FocusEvent): void;
}

Filedist/types/components/GrInputTag/grInputTagStyles.d.ts

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

Filedist/types/components/GrInputTag/safelist.d.ts

grInputTagSafelistconst
const grInputTagSafelist: string[];

Filedist/types/components/GrInputTag/index.d.ts

GrInputTagInstancetype
type GrInputTagInstance = ComponentExposed<typeof GrInputTagComponent>;

Accessibility

APG pattern

—

Keys

Delete / Backspace

Usage from the library prose

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
F2a chipopen editing (with editable)
Entereditingsave
Escapeeditingcancel

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.

Editing a tag in place

editable opens editing with a double click on a tag or with F2 from the keyboard. Before that a chip either existed or was removed as a whole: a typo in design-system-tokens-v2 cost retyping it in full.

F2, not Enter. A chip has a single focusable node — the removal button, and Enter on it already means “remove” by the native activation of a button. Hence the dependency as well: without tagClosable editing cannot be entered from the keyboard at all, and in dev the console warns about it.

The loss of focus saves. That is a deliberate divergence from addOnBlur (which is false by default): it decides whether a half-typed new value becomes a tag, and discarding is safe there — before that there was nothing. Editing, though, starts from an existing value, and quietly reverting what was typed is exactly the outcome that would surprise. There is a way to back out: Escape.

An empty value on Enter removes the tag, on the loss of focus it does not. Removal stays an explicit action: “erased the text and got distracted” must not cost a tag.

beforeAdd checks the edit as well. The prop is named after adding, but a tag has one rule, and a second predicate would diverge from the first; a rejection restores the previous value and is announced by a live region. There are no separators in editing: one edit is one tag, otherwise a comma would silently turn a tile into three.

max does not limit editing — the set does not grow from it.

Inside GrModal and the other layers Escape cancels the edit and does not close the window: the editing session is registered in the shared stack of layers.

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
vue
<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 states

state="success" and "warning" show an icon and a hidden label that goes into the aria-describedby of the control: the colour of the border as the only carrier of meaning is WCAG 1.4.1, and these two states have no ARIA equivalent of aria-invalid. danger gets no icon — its meaning is already carried by aria-invalid and the error text from GrFormField — and the mark goes out as soon as the control is declared invalid.

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 tag slot — the contents of the chip, not the chip

The slot draws the contents of the chip: the label, an icon, a counter. The chip itself (GrChip), its tone and the remove button stay with the component — a chip of your own with a cross inside the slot would give a chip within a chip and two crosses. remove in the slot props is for your own actions inside the label, not for a second remove button.

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