{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "tags-input",
  "title": "Tags Input",
  "type": "registry:ui",
  "description": "Multi-tag input — type a value, hit Enter, get a Chip. Backspace removes the last tag. Use for email recipient lists, tag sets, and free-form keyword inputs.",
  "categories": [
    "form"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/tags-input/tags-input.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport { live } from 'lit/directives/live.js'\nimport { X } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\n\nconst stringListConverter = {\n  fromAttribute: (v: string | null): string[] => {\n    if (!v) return []\n    try {\n      const parsed = JSON.parse(v)\n      if (Array.isArray(parsed)) return parsed.map(String)\n    } catch {\n      // split by comma\n    }\n    return v.split(',').map((s) => s.trim()).filter(Boolean)\n  },\n  toAttribute: (v: string[] | undefined): string | null => (v ? JSON.stringify(v) : null),\n}\n\nconst trueUnlessFalse = {\n  fromAttribute: (v: string | null) => v !== 'false',\n}\n\n/**\n * <uip-tags-input> — the registry TagsInput as a web component.\n *\n * Form-associated: submits `name[]=val` per tag with its <form>.\n */\nexport class UipTagsInput extends LitElement {\n  static formAssociated = true\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    value: { converter: stringListConverter },\n    defaultValue: { attribute: 'default-value', converter: stringListConverter },\n    placeholder: {},\n    disabled: { type: Boolean, reflect: true },\n    addOnKeys: { attribute: 'add-on-keys', converter: stringListConverter },\n    addOnPaste: { type: Boolean, attribute: 'add-on-paste' },\n    delimiter: {},\n    unique: { converter: trueUnlessFalse },\n    max: { type: Number },\n    name: { reflect: true },\n    draft: { state: true },\n  }\n\n  value: string[] = []\n  defaultValue?: string[]\n  placeholder = 'Add a tag...'\n  disabled = false\n  addOnKeys: string[] = ['Enter', ',']\n  addOnPaste = false\n  delimiter?: string\n  unique = true\n  max?: number\n  name?: string\n  private draft = ''\n\n  private initialValue: string[] = []\n  private internals = this.attachInternals()\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'tags-input')\n    if (this.defaultValue !== undefined && this.getAttribute('value') === null) {\n      this.value = [...this.defaultValue]\n    }\n    this.initialValue = [...this.value]\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (changed.has('value') || changed.has('name')) {\n      if (!this.name) {\n        this.internals.setFormValue(null)\n      } else {\n        const fd = new FormData()\n        this.value.forEach((v) => fd.append(`${this.name}[]`, v))\n        this.internals.setFormValue(fd)\n      }\n    }\n  }\n\n  formResetCallback() {\n    this.commit([...this.initialValue])\n  }\n\n  formDisabledCallback(disabled: boolean) {\n    this.disabled = disabled\n  }\n\n  private get tags(): string[] {\n    return Array.isArray(this.value) ? this.value : []\n  }\n\n  private get atMax(): boolean {\n    return this.max !== undefined && this.tags.length >= this.max\n  }\n\n  private commit(next: string[]) {\n    const capped = this.max !== undefined ? next.slice(0, this.max) : next\n    this.value = capped\n    this.dispatchEvent(new Event('input', { bubbles: true, composed: true }))\n    this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n    this.dispatchEvent(new CustomEvent('value-change', { detail: { value: capped }, bubbles: true, composed: true }))\n  }\n\n  private addTag(raw: string) {\n    const trimmed = raw.trim()\n    if (!trimmed) return\n    if (this.atMax) {\n      this.draft = ''\n      return\n    }\n    if (this.unique && this.tags.includes(trimmed)) {\n      this.draft = ''\n      return\n    }\n    this.commit([...this.tags, trimmed])\n    this.draft = ''\n  }\n\n  private removeAt(index: number) {\n    if (this.disabled) return\n    this.commit(this.tags.filter((_, i) => i !== index))\n  }\n\n  private handleKeyDown(e: KeyboardEvent) {\n    const commitKeys = this.delimiter ? [this.delimiter] : this.addOnKeys ?? ['Enter', ',']\n    if (commitKeys.includes(e.key)) {\n      e.preventDefault()\n      this.addTag(this.draft)\n    } else if (e.key === 'Backspace' && this.draft === '' && this.tags.length > 0) {\n      this.removeAt(this.tags.length - 1)\n    }\n  }\n\n  private handlePaste(e: ClipboardEvent) {\n    if (!this.addOnPaste) return\n    const text = e.clipboardData?.getData('text') ?? ''\n    if (!text.trim()) return\n    e.preventDefault()\n    const tokens = text.split(/\\s+/).map((t) => t.trim()).filter(Boolean)\n    let next = [...this.tags]\n    for (const token of tokens) {\n      if (this.max !== undefined && next.length >= this.max) break\n      if (this.unique && next.includes(token)) continue\n      next.push(token)\n    }\n    this.commit(next)\n    this.draft = ''\n  }\n\n  private focusInput() {\n    if (!this.disabled) {\n      this.renderRoot?.querySelector<HTMLInputElement>('input')?.focus()\n    }\n  }\n\n  render() {\n    const tags = this.tags\n\n    return html`\n      <div\n        part=\"base\"\n        data-uipkge=\"\"\n        data-slot=\"tags-input\"\n        class=${cn(\n          'border-input bg-background flex flex-wrap items-center gap-2 rounded-md border px-2 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none',\n          'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',\n          'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',\n          this.disabled && 'pointer-events-none opacity-50',\n        )}\n        @click=${this.focusInput}\n      >\n        ${tags.map(\n          (tag, i) => html`\n            <span\n              key=${`${tag}-${i}`}\n              data-uipkge=\"\"\n              data-slot=\"tags-input-item\"\n              class=\"bg-secondary data-[state=active]:ring-ring ring-offset-background flex h-5 items-center rounded-md data-[state=active]:ring-2 data-[state=active]:ring-offset-2\"\n            >\n              <span data-slot=\"tags-input-item-text\" class=\"rounded bg-transparent px-2 py-0.5 text-sm\">\n                ${tag}\n              </span>\n              <button\n                type=\"button\"\n                aria-label=${`Remove ${tag}`}\n                ?disabled=${this.disabled}\n                data-slot=\"tags-input-item-delete\"\n                class=\"hover:text-foreground focus-visible:ring-ring mr-1 flex rounded bg-transparent focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none\"\n                @mousedown=${(e: MouseEvent) => e.preventDefault()}\n                @click=${(e: MouseEvent) => {\n                  e.stopPropagation()\n                  this.removeAt(i)\n                }}\n              >\n                ${icon(X, 'x', 'h-4 w-4')}\n              </button>\n            </span>\n          `,\n        )}\n\n        ${!this.disabled\n          ? html`\n              <input\n                data-slot=\"tags-input-input\"\n                .value=${live(this.draft)}\n                placeholder=${this.atMax ? nothing : this.placeholder}\n                ?disabled=${this.atMax}\n                class=\"min-h-5 flex-1 bg-transparent px-1 text-sm focus:outline-none disabled:cursor-default\"\n                @input=${(e: Event) => (this.draft = (e.target as HTMLInputElement).value)}\n                @keydown=${this.handleKeyDown}\n                @paste=${this.handlePaste}\n                @blur=${() => this.addTag(this.draft)}\n              />\n            `\n          : nothing}\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-tags-input') || customElements.define('uip-tags-input', UipTagsInput)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-tags-input': UipTagsInput\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/tags-input/tags-input.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
