Tags Input
Token/chip input that lets users add, remove and paste comma-separated tags with full keyboard and form support, and an email mode for recipients with a flagged chip and message per invalid address.
Preview
<x-ui.tags-input name="tags" :value="['design','laravel']" placeholder="Add a tag…" class="w-80" />
Installation
php artisan ui:add tags-input
Note
This component ships an Alpine behavior module at
resources/js/ui/tags-input.js. Import it once from your bundle so it registers on alpine:init:
import './tags-input.js';
Registry contract
php artisan ui:add tags-input
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/tags-input.blade.php -
resources/js/ui/tags-input.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Tags Input (`tags-input`)
Token/chip input that lets users add, remove and paste comma-separated tags with full keyboard and form support, and an email mode for recipients with a flagged chip and message per invalid address.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add tags-input
```
## Usage
```blade
<x-ui.tags-input name="tags" :value="['design','laravel']" placeholder="Add a tag…" class="w-80" />
```
## Props
- `name` (mixed|null, default `null`) — Form field name; inherited from a wrapping field via @aware or set directly. Each tag submits as name[].
- `value` (array, default `[]`) — Initial list of tags.
- `max` (mixed|null, default `null`) — Maximum number of tags allowed. At the limit a draft is kept, and a visible message linked to the field is shown and announced.
- `placeholder` (string|null, default `null`) — Placeholder text shown in the draft input. Null: the translated "Add tag…", or "Add an email address…" in email mode.
- `separator` (string, default `,`) — Key that, alongside Enter, confirms the current draft as a chip.
- `pattern` (mixed|null, default `null`) — Optional regex a draft must match before it becomes a chip; a non-matching draft stays in the box and is flagged invalid. In email mode it replaces the built-in address check, and a miss becomes a flagged chip.
- `inputmode` (string|null, default `null`) — Virtual keyboard hint forwarded to the draft input, such as email or numeric. Null: text, or email in email mode.
- `type` (text|email, default `text`) — email makes a recipients field: a comma, semicolon, space or new line ends an address, a pasted list splits on all of them, duplicates are matched without case, the draft is added on blur, and an address that is not valid becomes a chip flagged invalid with its own visible and announced message.
- `label` (string|null, default `null`) — Accessible name of the draft field. Null: a <label for> that points at input-id names it, else the placeholder does.
- `inputId` (string|null, default `null`) — The id of the draft field, so a <label for="…"> names it.
- `addOnBlur` (bool|null, default `null`) — Confirms the draft as a chip when focus leaves the field. Null: on in email mode, off in text mode.
- `invalidMessage` (string|null, default `null`) — Message for an invalid chip; :value is the entry. Null: the translated ":value is not a valid email address." (email) or ":value is not valid.".
- `maxMessage` (string|null, default `null`) — Message shown at the limit; :max is the limit. Null: the translated "You can add up to :max addresses." (email) or "You can add up to :max items.".
## Use when
- Use when users must enter freeform information that cannot be reliably selected from a list.
- Collecting an open-ended list of short values, such as skills or labels, as removable chips.
- Collecting email recipients (To, Cc, invitations): use type="email" so a pasted list splits into one chip per address and each invalid address is flagged with its own message.
- Needing paste support so a separator-delimited list adds every token at once.
## Avoid when
- Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
- The options are a fixed, known list to choose from rather than free entry; use select-advanced or combobox with multiple instead.
- Choosing people who are already known (members, contacts) by name and avatar; use people-picker instead.
- A single free-text value; use input instead.
## Anti-patterns
- Using freeform entry for a bounded answer set
## Rules
- Use the `<brok:tags-input>` tag (or `<x-ui.tags-input>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/tags-input
- Registry JSON (files, props, contract): https://brokui.dev/r/open/tags-input.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
Long Content
{{-- A narrow column with long addresses: a chip truncates instead of
widening the page, and the messages wrap under the chips. --}}
<div class="w-full max-w-xs">
<x-ui.tags-input
type="email"
name="cc"
:label="__('Cc')"
:value="[
'procurement.department.regional.distribution@northwind-traders.example',
'ada@example.com',
'accounts-payable@',
'grace.hopper@example.com',
]"
/>
</div>
Recipients
{{-- type="email" turns the chips into recipients: a pasted list splits on
commas, semicolons, spaces and new lines, an address that is not valid
stays as a flagged chip with its own message, and `max` stops at five.
Each address posts as recipients[]. --}}
<div class="w-full max-w-md space-y-2">
<x-ui.label for="recipients-draft">{{ __('To') }}</x-ui.label>
<x-ui.tags-input
type="email"
name="recipients"
input-id="recipients-draft"
:value="['ada@example.com']"
:max="5"
/>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| name | mixed | null | null | Form field name; inherited from a wrapping field via @aware or set directly. Each tag submits as name[]. |
| value | array | [] | Initial list of tags. |
| max | mixed | null | null | Maximum number of tags allowed. At the limit a draft is kept, and a visible message linked to the field is shown and announced. |
| placeholder | string | null | null | Placeholder text shown in the draft input. Null: the translated "Add tag…", or "Add an email address…" in email mode. |
| separator | string | , | Key that, alongside Enter, confirms the current draft as a chip. |
| pattern | mixed | null | null | Optional regex a draft must match before it becomes a chip; a non-matching draft stays in the box and is flagged invalid. In email mode it replaces the built-in address check, and a miss becomes a flagged chip. |
| inputmode | string | null | null | Virtual keyboard hint forwarded to the draft input, such as email or numeric. Null: text, or email in email mode. |
| type | text | email | text | email makes a recipients field: a comma, semicolon, space or new line ends an address, a pasted list splits on all of them, duplicates are matched without case, the draft is added on blur, and an address that is not valid becomes a chip flagged invalid with its own visible and announced message. |
| label | string | null | null | Accessible name of the draft field. Null: a <label for> that points at input-id names it, else the placeholder does. |
| inputId | string | null | null | The id of the draft field, so a <label for="…"> names it. |
| addOnBlur | bool | null | null | Confirms the draft as a chip when focus leaves the field. Null: on in email mode, off in text mode. |
| invalidMessage | string | null | null | Message for an invalid chip; :value is the entry. Null: the translated ":value is not a valid email address." (email) or ":value is not valid.". |
| maxMessage | string | null | null | Message shown at the limit; :max is the limit. Null: the translated "You can add up to :max addresses." (email) or "You can add up to :max items.". |
Slots
default— Unused; the chips render from the value/name state only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Enter or the configured separator key confirms the current draft as a chip; Backspace on an empty draft removes the last chip.
- Pasting a separator-delimited list adds every token as a chip in one action.
- Each tag mirrors to a hidden input name[] when name is set, so the full array submits with the form.
- ArrowLeft (ArrowRight in RTL) at the start of the draft focuses the last chip's remove button; the arrows walk the chips and past the last one back to the draft. Backspace on a focused chip removes it and focuses the chip before it; Delete removes it and focuses the chip now in its place. Every removal is announced.
- type="email": a comma, semicolon, space or new line ends an address, a pasted list splits on all of them, a duplicate (in any case) is skipped, blur adds the draft, and an address that is not valid becomes a chip flagged invalid with a visible message linked to the field through aria-describedby.
- Server validation per entry (name.0, name.1 in the error bag) marks that chip invalid with the server message; the root's data-errors carries it outside the ignored chip region, so every Livewire render that changes it reaches the right chip.
- Livewire-safe: the chip list and the hidden name[] inputs are drawn by Alpine and carry wire:ignore, and the draft carries wire:ignore.self, so a render of the parent component keeps the chips, the invalid marks and the aria-describedby links; wire:model on the root still syncs both ways.
- Every add or remove sets the x-modelable array (wire:model, x-model) and dispatches a bubbling change { value } from the root; focus leaving the control dispatches blur on the root, so wire:model.live.blur sends the list then.
- Livewire: wire:model inside a dialog that the same render opens still draws the chips; the chip lists redraw after each model change, outside the morph.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, behaviorTest.
Guidance
Collect unpredictable freeform information.
Use when
- Use when users must enter freeform information that cannot be reliably selected from a list.
- Collecting an open-ended list of short values, such as skills or labels, as removable chips.
- Collecting email recipients (To, Cc, invitations): use type="email" so a pasted list splits into one chip per address and each invalid address is flagged with its own message.
- Needing paste support so a separator-delimited list adds every token at once.
Avoid when
- Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
- The options are a fixed, known list to choose from rather than free entry; use select-advanced or combobox with multiple instead.
- Choosing people who are already known (members, contacts) by name and avatar; use people-picker instead.
- A single free-text value; use input instead.
Use instead
- Radio or checkbox for bounded choices
- Select or combobox for known options
Anti-patterns
- Using freeform entry for a bounded answer set
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Enter
- Focus
managed
- Each chip's remove button carries an aria-label naming the tag being removed, not a bare icon, and adds "(not valid)" for an invalid chip.
- A draft that fails pattern, or any invalid chip, sets aria-invalid on the draft; each invalid chip's message and the limit message are visible text linked through aria-describedby.
- Additions of an invalid entry, removals, duplicates and the limit are announced once through a polite role="status" region.
- An invalid chip is marked by a destructive border, an icon and the message, not by colour alone.
- aria-describedby on the draft names only ids that exist: your own aria-describedby ids, the wrapping field's hint ({name}-description) when it has one, the field's {name}-error only while the field shows a message for the list itself, and each invalid chip's own message.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="tags-input-{{ $record->id }}">
<x-ui.tags-input name="tags" :value="['design','laravel']" placeholder="Add a tag…" class="w-80" />
</div>
Validation
Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:tags-input
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{-- A wrapping <x-ui.field name="…"> shares its name down via @aware so this
composite self-wires old()/aria-invalid/aria-describedby off the shared
$errors bag. Inert with no field ancestor and no own `name` —
fully backward-compatible. --}}
@aware([
'name' => null,
'hint' => null,
])
@props([
'name' => null,
'value' => [],
'max' => null,
// Null: the translated "Add tag…" (text) or "Add an email address…" (email).
'placeholder' => null,
'separator' => ',',
// Optional regex a draft must match before it becomes a chip (e.g. an email
// address); a non-matching draft stays in the box and is flagged invalid.
// In email mode it replaces the built-in address check.
'pattern' => null,
// Null: text, or email in email mode.
'inputmode' => null,
// text: free tags. email: recipients. A comma, semicolon, space or new
// line ends an address, a pasted list splits on all of them, duplicates
// are matched without case, and an address that is not valid becomes a
// flagged chip with its own message instead of staying in the draft.
'type' => 'text',
// Accessible name of the draft field. Null: a <label for="input-id">
// names it, else the placeholder does.
'label' => null,
// The id of the draft field, so a <label for="…"> names it.
'inputId' => null,
// Confirm the draft when focus leaves the field. Null: on in email mode.
'addOnBlur' => null,
// Message for an invalid chip; ":value" is the address. Null: translated default.
'invalidMessage' => null,
// Message once `max` chips are there; ":max" is the limit. Null: translated default.
'maxMessage' => null,
])
@php
$awareName = $name ?? null;
$fieldName = $awareName ?? $attributes->get('name');
$type = $type === 'email' ? 'email' : 'text';
$isEmail = $type === 'email';
$bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
// The submitted name is an array (`name[]`); flag invalid when the bag has an
// error for the field itself or any indexed child key (`name.0`, `name.1`, …).
$hasBagError = $bag && filled($fieldName) && ($bag->has($fieldName) || $bag->has($fieldName.'.*'));
// old() repopulates the chip array after a failed redirect-back; fall back to
// the passed value (coerced to a list) when there is no flashed input.
$resolvedTags = filled($fieldName) ? old($fieldName, $value) : $value;
$resolvedTags = is_array($resolvedTags) ? array_values($resolvedTags) : (array) $resolvedTags;
// A server message for one entry (`name.1`) belongs to that chip; the
// script matches the index to the list it holds when the render lands.
// Without a name, the wire:model target is the error-bag key.
$errorKey = $fieldName;
if (blank($errorKey)) {
foreach ($attributes->getAttributes() as $attributeName => $attributeValue) {
if (str_starts_with((string) $attributeName, 'wire:model') && is_string($attributeValue)) {
$errorKey = $attributeValue;
break;
}
}
}
$chipErrors = [];
if ($bag && filled($errorKey)) {
foreach ($bag->getMessages() as $key => $messages) {
if (preg_match('/^'.preg_quote((string) $errorKey, '/').'\.(\d+)$/', (string) $key, $match) === 1 && $messages !== []) {
$chipErrors[(int) $match[1]] = (string) reset($messages);
}
}
}
// A message for the list itself (not for one entry) keeps the field invalid.
$fieldError = $bag && filled($errorKey) && $bag->has($errorKey);
$hasBagError = $hasBagError || $chipErrors !== [];
// The draft is described by the consumer's own ids, the wrapping field's
// hint (`{name}-description`) and its error (`{name}-error`). x-ui.field
// draws the error only for a message on the list itself; chip messages
// (name.2) have their own ids, so every id here names an element that
// exists.
$describedBy = collect(preg_split('/\s+/', trim((string) $attributes->get('aria-describedby', ''))) ?: [])
->when(filled($awareName) && filled($hint ?? null), fn ($ids) => $ids->push($awareName.'-description'))
->when(filled($awareName) && $bag && filled($bag->first($awareName)), fn ($ids) => $ids->push($awareName.'-error'))
->filter()
->unique()
->implode(' ');
$max = is_numeric($max) && (int) $max > 0 ? (int) $max : null;
$placeholder = filled($placeholder) ? (string) $placeholder : ($isEmail ? __('Add an email address…') : __('Add tag…'));
$inputmode = filled($inputmode) ? (string) $inputmode : ($isEmail ? 'email' : 'text');
$addOnBlur = $addOnBlur === null ? $isEmail : filter_var($addOnBlur, FILTER_VALIDATE_BOOLEAN);
$accessibleName = filled($label) ? (string) $label : (filled($inputId) ? null : $placeholder);
$messages = [
'invalid' => filled($invalidMessage) ? (string) $invalidMessage : ($isEmail ? __(':value is not a valid email address.') : __(':value is not valid.')),
'max' => $max === null ? '' : (filled($maxMessage)
? str_replace(':max', (string) $max, (string) $maxMessage)
: ($isEmail ? __('You can add up to :max addresses.', ['max' => $max]) : __('You can add up to :max items.', ['max' => $max]))),
'duplicate' => __(':value is already in the list.'),
'removed' => __('Removed :value'),
'remove' => __('Remove :value'),
'removeInvalid' => __('Remove :value (not valid)'),
];
$baseId = 'tags-input-'.substr(md5((string) ($inputId ?? $fieldName ?? $placeholder).$type), 0, 8);
@endphp
{{--
Tags Input (dice-ui port) — a token/chip input. Users type a value and press
Enter or the configured separator key to confirm it as a chip. Backspace on
an empty draft removes the last chip. Pasting a separator-delimited list adds
all tokens at once. When `name` is provided, each tag is mirrored to a hidden
`<input name="name[]">` so the full array is submitted with the form.
ArrowLeft (ArrowRight in RTL) at the start of the draft moves into the
chips' remove buttons; the arrows walk the chips, and Backspace or Delete
removes the focused one. type="email" makes it a recipients field.
--}}
<div
{{-- The first tags, the list-level error and the field error id sit in data attributes, so the
x-data expression stays the same when a Livewire render changes them
(a changed x-data starts the component again). --}}
x-data="uiTagsInput({ max: @js($max), separator: @js($separator), pattern: @js($pattern), type: @js($type), addOnBlur: @js($addOnBlur), messages: @js($messages), baseId: @js($baseId) })"
data-value="{{ json_encode($resolvedTags) }}"
@if ($fieldError) data-field-error="true" @endif
@if (filled($describedBy)) data-described-by="{{ $describedBy }}" @endif
{{-- The chip array is modelable: `x-model` (Alpine) and `wire:model`
(Livewire binds through the same x-model contract) attach here. --}}
x-modelable="model"
data-slot="tags-input"
data-type="{{ $type }}"
{{-- Server messages per entry index; a Livewire render updates this attribute. --}}
data-errors="{{ json_encode((object) $chipErrors) }}"
@if ($hasBagError) data-invalid="true" @endif
x-on:focusout="onFocusOut()"
{{ $attributes->except(['name', 'aria-describedby'])->merge(['class' => 'flex flex-wrap items-center gap-2 rounded-md border border-input bg-background px-2 py-2 focus-within:ring-2 focus-within:ring-ring']) }}
>
{{-- Hidden form inputs — one per tag, submitted as an array. Alpine draws
them, so a Livewire render leaves this region alone (wire:ignore). --}}
@if (filled($fieldName))
<div hidden wire:ignore data-slot="tags-input-values">
<template x-for="tag in chips" :key="tag">
<input type="hidden" name="{{ $fieldName }}[]" :value="tag" />
</template>
</div>
@endif
{{-- Tag chips. Alpine draws them from the model, so a Livewire render of
the parent must not morph them away (wire:ignore). Server messages per
chip still arrive: they travel in the root's data-errors, outside this
region, and the script re-reads that attribute on every change. --}}
<ul role="list" wire:ignore data-slot="tags-input-list" class="contents">
<template x-for="(tag, i) in chips" :key="tag">
<li
role="listitem"
data-slot="tags-input-tag"
x-bind:data-invalid="isInvalid(tag) ? 'true' : null"
class="inline-flex min-w-0 max-w-full items-center gap-1 rounded-sm border border-transparent bg-secondary px-2 py-0.5 text-sm text-secondary-foreground data-[invalid=true]:border-destructive data-[invalid=true]:bg-background data-[invalid=true]:text-destructive"
>
<svg x-show="isInvalid(tag)" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3 shrink-0">
<circle cx="12" cy="12" r="10" /><path d="M12 8v4M12 16h.01" />
</svg>
<span class="min-w-0 truncate" x-text="tag"></span>
<button
type="button"
data-slot="tags-input-remove"
@click="removeTag(i, 'draft')"
@keydown.backspace.prevent="removeTag(i, 'before')"
@keydown.delete.prevent="removeTag(i, 'here')"
@keydown.left.prevent="onChipArrow(i, 'left')"
@keydown.right.prevent="onChipArrow(i, 'right')"
:aria-label="removeLabel(tag)"
class="-my-2 -ms-1 -me-2 flex size-6 shrink-0 items-center justify-center rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring motion-reduce:transition-none"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="size-3">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</li>
</template>
</ul>
{{-- Draft input --}}
<input
type="text"
data-slot="tags-input-draft"
x-ref="draft"
@if (filled($inputId)) id="{{ $inputId }}" @endif
x-model="draft"
{{-- Alpine owns the invalid state and the described-by list (it links
each chip message); a Livewire render keeps the attributes. --}}
wire:ignore.self
@keydown.enter.prevent="addTag()"
@keydown.backspace="onBackspace()"
@keydown="onSeparatorKey($event)"
@keydown.left="onDraftArrow($event, 'left')"
@keydown.right="onDraftArrow($event, 'right')"
@paste="onPaste($event)"
@blur="onDraftBlur()"
inputmode="{{ $inputmode }}"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
placeholder="{{ $placeholder }}"
@if (filled($accessibleName)) aria-label="{{ $accessibleName }}" @endif
@if ($hasBagError) aria-invalid="true" @endif
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
x-bind:aria-invalid="fieldInvalid ? 'true' : null"
x-bind:aria-describedby="describedByIds"
class="min-w-[6rem] flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
/>
{{-- One visible message per invalid chip and the limit, linked to the draft. --}}
<ul data-slot="tags-input-errors" x-show="invalidTags.length > 0 || atLimit" x-cloak wire:ignore class="basis-full space-y-1 px-1 text-sm text-destructive">
<template x-for="tag in invalidTags" :key="'error-' + tag">
<li data-slot="tags-input-error" x-bind:id="errorId(tag)" x-text="errorFor(tag)"></li>
</template>
<li data-slot="tags-input-limit" x-show="atLimit" x-bind:id="baseId + '-limit'" class="text-muted-foreground" x-text="messages.max"></li>
</ul>
<span data-slot="tags-input-status" role="status" wire:ignore class="sr-only" x-text="announcement"></span>
</div>
/**
* Tags Input behavior — a token/chip input that lets the user type values,
* confirm them (Enter or comma), remove them (× button or Backspace on empty
* draft), and paste a separator-delimited list all at once.
*
* Keyboard in the chips: ArrowLeft (ArrowRight in RTL) at the start of the
* draft moves focus to the last chip's remove button; the arrows walk the
* chips and past the last one back to the draft; Backspace removes the
* focused chip and moves to the one before it, Delete moves to the one now
* in its place. Removals and refusals are announced in a polite region.
*
* Email mode (`type: 'email'`): a comma, semicolon or space also ends an
* address, a paste splits on those and new lines, duplicates are matched
* without case, the draft is added on blur, and an address that fails the
* check becomes a chip flagged invalid, with a visible message linked to
* the draft (aria-describedby) instead of staying in the box. A server
* message per entry comes from the root's `data-errors` { index: message }
* (name.N in the error bag), which a Livewire render updates. The chips and
* the hidden inputs carry wire:ignore, so that render never removes them;
* this observer is how the new messages still reach their chips.
*
* Every change dispatches a bubbling `change` { value } from the root, and
* focus leaving the control dispatches `blur` on the root, so wire:model
* with .live, .change or .blur sends the list at that moment.
*
* Self-registers on `alpine:init` so import order does not matter — import
* this file once from your bundle (e.g. resources/js/ui/index.js).
*/
const EMAIL = /^[^\s@,;<>()]+@[^\s@,;<>()]+\.[^\s@,;<>()]+$/;
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiTagsInput', (config = {}) => {
const email = config.type === 'email';
let observer = null;
let focusOutTimer = null;
// The root element, kept from init: a chip's handler runs in the
// scope of an element that the removal just took out of the page,
// where $root and $refs no longer resolve.
let rootEl = null;
// True while a paste adds several entries: they are announced together.
let quiet = false;
// True while a removal moves focus to the next chip: the removed
// button's focusout is not a leave.
let moving = false;
return {
tags: [],
draft: '',
max: config.max ?? null,
separator: config.separator ?? ',',
pattern: config.pattern ? new RegExp(config.pattern) : (email ? EMAIL : null),
messages: config.messages ?? {},
baseId: config.baseId ?? 'tags-input',
serverErrors: {},
serverFieldError: false,
fieldDescribedBy: null,
announcement: '',
limitShown: false,
// Bumped after the model changes, so the chip lists draw again
// even when the change arrived inside a Livewire morph.
chipRevision: 0,
init() {
rootEl = this.$root;
let first = config.tags ?? [];
try {
if (rootEl.dataset.value !== undefined) first = JSON.parse(rootEl.dataset.value);
} catch {
first = [];
}
// x-modelable may already have set the bound value.
if (this.tags.length === 0 && Array.isArray(first)) this.tags = first.map(String);
this.readServerErrors();
observer = new MutationObserver(() => this.readServerErrors());
observer.observe(rootEl, { attributes: true, attributeFilter: ['data-errors', 'data-field-error', 'data-described-by'] });
},
destroy() {
observer?.disconnect();
observer = null;
clearTimeout(focusOutTimer);
},
/**
* Modelable value (`x-modelable="model"` on the root): the chip array.
* `x-model` and Livewire's `wire:model` bind an array property here.
*/
get model() {
return this.tags;
},
set model(v) {
if (Array.isArray(v)) {
this.tags = v.map(String);
} else {
this.tags = v == null || v === '' ? [] : [String(v)];
}
// Livewire can set the model while it morphs a teleported
// region (a dialog that opens in the same render). Alpine
// drops the effects that run then, so the lists read
// `chips` and draw again once the morph is done.
queueMicrotask(() => {
this.chipRevision++;
});
},
/** The chips the lists draw: `tags`, redrawn after each model change. */
get chips() {
void this.chipRevision;
return this.tags;
},
/** True while a non-empty draft fails the pattern (text mode). */
get draftInvalid() {
const v = this.draft.trim();
return Boolean(!email && v && this.pattern && !this.pattern.test(v));
},
/** Chips that fail the check or carry a server message. */
get invalidTags() {
return this.chips.filter((tag) => this.isInvalid(tag));
},
get atLimit() {
return this.limitShown && Boolean(this.max) && this.tags.length >= this.max;
},
get fieldInvalid() {
return this.serverFieldError || this.draftInvalid || this.invalidTags.length > 0;
},
get describedByIds() {
const ids = [this.fieldDescribedBy, ...this.invalidTags.map((tag) => this.errorId(tag))];
if (this.atLimit) ids.push(`${this.baseId}-limit`);
const list = ids.filter(Boolean).join(' ');
return list === '' ? null : list;
},
isInvalid(tag) {
if (Object.prototype.hasOwnProperty.call(this.serverErrors, tag)) return true;
return email && Boolean(this.pattern) && !this.pattern.test(tag);
},
errorFor(tag) {
if (Object.prototype.hasOwnProperty.call(this.serverErrors, tag)) return this.serverErrors[tag];
return this.format(this.messages.invalid, tag);
},
errorId(tag) {
return `${this.baseId}-error-${this.tags.indexOf(tag)}`;
},
removeLabel(tag) {
return this.format(this.isInvalid(tag) ? this.messages.removeInvalid : this.messages.remove, tag);
},
format(message, value) {
return String(message ?? '').replace(':value', () => value);
},
readServerErrors() {
let parsed = {};
try {
parsed = JSON.parse(rootEl.dataset.errors || '{}') ?? {};
} catch {
parsed = {};
}
// Keys are entry indexes (name.2); the render that brought
// them validated the list as it is now, so map them to text.
const errors = {};
for (const [index, message] of Object.entries(typeof parsed === 'object' ? parsed : {})) {
const tag = this.tags[Number(index)];
if (tag !== undefined && message) errors[tag] = String(message);
}
this.serverErrors = errors;
this.serverFieldError = rootEl.dataset.fieldError === 'true';
// The ids of the field's hint and error elements (and the
// consumer's own), sent only while each element is drawn.
this.fieldDescribedBy = rootEl.dataset.describedBy || null;
},
has(value) {
return email
? this.tags.some((tag) => tag.toLowerCase() === value.toLowerCase())
: this.tags.includes(value);
},
/**
* Adds one value. Returns false when it was refused (limit, or a
* text-mode pattern miss) so the caller keeps the draft.
*/
push(value) {
if (this.has(value)) {
this.announce(this.format(this.messages.duplicate, value));
return true;
}
if (this.max && this.tags.length >= this.max) {
this.limitShown = true;
this.announce(this.messages.max ?? '');
return false;
}
if (!email && this.pattern && !this.pattern.test(value)) return false;
this.tags.push(value);
if (this.isInvalid(value)) this.announce(this.errorFor(value));
return true;
},
addTag() {
const v = this.draft.trim();
if (!v) return;
const before = this.tags.length;
if (this.push(v)) {
this.draft = '';
if (this.tags.length !== before) this.changed();
}
},
/**
* Removes the chip at `i` and announces it. `focus` says where
* focus goes: 'draft' (a click on ×), 'before' (Backspace on a
* chip: the chip before it), 'here' (Delete on a chip: the chip
* now in its place), or null to leave it (Backspace in the draft).
*/
removeTag(i, focus = null) {
const [removed] = this.tags.splice(i, 1);
if (removed === undefined) return;
if (Object.prototype.hasOwnProperty.call(this.serverErrors, removed)) {
const rest = { ...this.serverErrors };
delete rest[removed];
this.serverErrors = rest;
}
if (!this.max || this.tags.length < this.max) this.limitShown = false;
this.announce(this.format(this.messages.removed, removed));
this.changed();
if (focus === null) return;
let target = null;
if (focus === 'before') target = this.tags.length === 0 ? null : Math.max(0, i - 1);
if (focus === 'here') target = i < this.tags.length ? i : null;
moving = true;
this.$nextTick(() => {
this.focusChip(target);
moving = false;
});
},
/** Focuses the remove button of chip `i`, or the draft when there is none. */
focusChip(i) {
const button = i === null ? null : rootEl.querySelectorAll('[data-slot="tags-input-remove"]')[i];
(button ?? rootEl.querySelector('[data-slot="tags-input-draft"]'))?.focus();
},
/** The logical direction of an arrow key: back (towards the first chip) or forward. */
direction(key) {
const rtl = getComputedStyle(rootEl).direction === 'rtl';
const back = rtl ? 'right' : 'left';
return key === back ? 'back' : 'forward';
},
onChipArrow(i, key) {
if (this.direction(key) === 'back') {
if (i > 0) this.focusChip(i - 1);
return;
}
this.focusChip(i + 1 < this.tags.length ? i + 1 : null);
},
onDraftArrow(event, key) {
const input = event.target;
if (this.direction(key) !== 'back' || this.tags.length === 0) return;
if (input.selectionStart !== 0 || input.selectionEnd !== 0) return;
event.preventDefault();
this.focusChip(this.tags.length - 1);
},
onBackspace() {
if (this.draft === '' && this.tags.length) {
this.removeTag(this.tags.length - 1);
}
},
onPaste(e) {
e.preventDefault();
const text = e.clipboardData?.getData('text') ?? '';
const sep = email
? /[,;\s]+/
: (this.separator === ',' ? /[,\n\t]/ : new RegExp('[' + this.separator + '\n\t]'));
const before = this.tags.length;
const refused = [];
// One announcement for the whole paste, not one per entry.
quiet = true;
text.split(sep).forEach((part) => {
const v = part.trim();
if (!v) return;
if (refused.length > 0 || !this.push(v)) refused.push(v);
});
quiet = false;
this.draft = refused.join(email ? ', ' : this.separator);
const invalid = this.tags.slice(before).filter((tag) => this.isInvalid(tag));
if (invalid.length > 0) this.announce(invalid.map((tag) => this.errorFor(tag)).join(' '));
else if (refused.length > 0 && this.max && this.tags.length >= this.max) this.announce(this.messages.max ?? '');
if (this.tags.length !== before) this.changed();
},
onSeparatorKey(event) {
if (event.isComposing) return;
const keys = email ? [this.separator, ',', ';', ' '] : [this.separator];
if (keys.includes(event.key)) {
event.preventDefault();
this.addTag();
}
},
onDraftBlur() {
if (config.addOnBlur) this.addTag();
},
/** Focus left the control: tell wire:model.blur and .live.blur. */
onFocusOut() {
if (moving) return;
clearTimeout(focusOutTimer);
focusOutTimer = setTimeout(() => {
if (rootEl.contains(document.activeElement)) return;
rootEl.dispatchEvent(new FocusEvent('blur'));
}, 0);
},
changed() {
rootEl.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: [...this.tags] } }));
},
/** Announces once through the polite status region. */
announce(message) {
if (quiet) return;
this.announcement = '';
if (!message) return;
this.$nextTick(() => {
this.announcement = message;
});
},
};
});
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.6.3 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0