{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tag-input",
  "title": "Tag Input",
  "description": "Tag Input from the Get IT Done design system.",
  "dependencies": [],
  "registryDependencies": [
    "https://zero-design.itdone.de/r/chip.json",
    "https://zero-design.itdone.de/r/labels-context.json",
    "https://zero-design.itdone.de/r/recipes.json",
    "https://zero-design.itdone.de/r/utils.json",
    "https://zero-design.itdone.de/r/theme.json"
  ],
  "files": [
    {
      "path": "registry/ui/tag-input.tsx",
      "content": "'use client';\n\nimport * as React from 'react';\n\nimport { invalidRing, overlaySurface, transitionBase } from '@/lib/recipes';\nimport { cn } from '@/lib/utils';\nimport { Chip } from '@/components/ui/chip';\nimport { useLabels } from '@/components/ui/labels-context';\n\nexport interface TagSuggestion {\n  // Value that becomes the tag.\n  value: string;\n  // Text shown in the list and on the chip. Defaults to the value.\n  label?: string;\n  // Groups the entry under a heading. Entries without a category come first, ungrouped.\n  category?: string;\n}\n\nexport interface TagInputProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'defaultValue'> {\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n  placeholder?: string;\n  // Marks a tag invalid (shown with the invalid chip look and announced). Invalid tags are kept so the user can fix them.\n  validate?: (tag: string) => boolean;\n  maxTags?: number;\n  // Characters that commit the draft besides Enter. Pasted text is split on these and on line breaks.\n  delimiters?: string[];\n  disabled?: boolean;\n  readOnly?: boolean;\n  invalid?: boolean;\n  // `multiline` is the taller, textarea-like field (Figma InputTextArea Type=Tags).\n  multiline?: boolean;\n  // Accessible name of the text field.\n  inputLabel?: string;\n  name?: string;\n  // Values offered while typing. The field becomes a combobox: ArrowDown and ArrowUp move through the list, Enter picks, Escape closes. Typed text that is not a suggestion can still be added.\n  // Entries may be plain strings or `{ value, label, category }`. Categories render as labelled groups.\n  suggestions?: Array<string | TagSuggestion>;\n}\n\n// Field that turns typed or pasted values into removable chips (Figma Input Variant=Chips, InputTextArea Type=Tags).\nconst TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(\n  (\n    {\n      className,\n      value,\n      defaultValue = [],\n      onValueChange,\n      placeholder,\n      validate,\n      maxTags,\n      delimiters = [',', ';'],\n      disabled = false,\n      readOnly = false,\n      invalid = false,\n      multiline = false,\n      inputLabel: inputLabelProp,\n      name,\n      suggestions,\n      ...props\n    },\n    ref,\n  ) => {\n    const l = useLabels();\n    const inputLabel = inputLabelProp ?? l.addTag;\n    const [inner, setInner] = React.useState(defaultValue);\n    const tags = value ?? inner;\n    const [draft, setDraft] = React.useState('');\n    const [announcement, setAnnouncement] = React.useState('');\n    const inputRef = React.useRef<HTMLInputElement | null>(null);\n    const listId = React.useId();\n    const [open, setOpen] = React.useState(false);\n    const [active, setActive] = React.useState(-1);\n    const query = draft.trim().toLowerCase();\n    const entries: TagSuggestion[] = React.useMemo(() => (suggestions ?? []).map((entry) => (typeof entry === 'string' ? { value: entry } : entry)), [suggestions]);\n    const labelOf = (entry: TagSuggestion) => entry.label ?? entry.value;\n    const filtered = entries.filter((entry) => !tags.includes(entry.value) && (labelOf(entry).toLowerCase().includes(query) || entry.value.toLowerCase().includes(query)));\n    // Ungrouped entries first, then each category in order of first appearance, so the visual order matches the keyboard order.\n    const categories = Array.from(new Set(filtered.map((entry) => entry.category).filter((category): category is string => Boolean(category))));\n    const options = [...filtered.filter((entry) => !entry.category), ...categories.flatMap((category) => filtered.filter((entry) => entry.category === category))];\n    const chipLabel = (tag: string) => {\n      const match = entries.find((entry) => entry.value === tag);\n      return match ? labelOf(match) : tag;\n    };\n    const expanded = open && options.length > 0 && !readOnly && !disabled;\n    const optionId = (index: number) => `${listId}-option-${index}`;\n\n    const commitTags = (next: string[]) => {\n      setInner(next);\n      onValueChange?.(next);\n    };\n\n    const add = (raw: string[]) => {\n      const cleaned = raw.map((entry) => entry.trim()).filter(Boolean);\n      const fresh = cleaned.filter((entry, index) => !tags.includes(entry) && cleaned.indexOf(entry) === index);\n      const room = maxTags == null ? fresh : fresh.slice(0, Math.max(maxTags - tags.length, 0));\n      if (room.length === 0) return;\n      commitTags([...tags, ...room]);\n      setAnnouncement(l.tagsAdded(room.join(', ')));\n    };\n\n    const remove = (tag: string) => {\n      commitTags(tags.filter((entry) => entry !== tag));\n      setAnnouncement(l.tagRemoved(tag));\n      inputRef.current?.focus();\n    };\n\n    const pick = (entry: string) => {\n      add([entry]);\n      setDraft('');\n      setOpen(false);\n      setActive(-1);\n      inputRef.current?.focus();\n    };\n\n    const splitter = new RegExp(`[\\\\n${delimiters.map((d) => d.replace(/[\\\\^\\]-]/g, '\\\\$&')).join('')}]`);\n    const atLimit = maxTags != null && tags.length >= maxTags;\n\n    return (\n      <div\n        data-invalid={invalid || undefined}\n        data-disabled={disabled || undefined}\n        className={cn(\n          suggestions && 'relative',\n          'flex w-full flex-wrap items-center gap-2 rounded-control border border-solid border-input bg-background px-3 py-2',\n          multiline && 'min-h-textarea items-start',\n          transitionBase,\n          'hover:border-border-bold hover:shadow-field-hover',\n          'focus-within:border-ring focus-within:ring-focus focus-within:ring-offset-focus focus-within:ring-offset-background focus-within:ring-ring-default',\n          'data-[invalid=true]:border-destructive data-[invalid=true]:ring-destructive/20',\n          'data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-disabled',\n          readOnly && 'bg-muted hover:border-input hover:shadow-none',\n          className,\n        )}\n        onClick={() => inputRef.current?.focus()}\n        {...props}\n      >\n        {tags.map((tag) => {\n          const bad = validate ? !validate(tag) : false;\n          return (\n            <Chip\n              key={tag}\n              variant=\"outline\"\n              size=\"control\"\n              label={chipLabel(tag)}\n              invalid={bad}\n              disabled={disabled}\n              onRemove={readOnly || disabled ? undefined : () => remove(tag)}\n              removeLabel={l.remove(tag)}\n            />\n          );\n        })}\n        {readOnly || atLimit ? null : (\n          <input\n            ref={(node) => {\n              inputRef.current = node;\n              if (typeof ref === 'function') ref(node);\n              else if (ref) ref.current = node;\n            }}\n            type=\"text\"\n            value={draft}\n            disabled={disabled}\n            placeholder={tags.length === 0 ? placeholder : undefined}\n            aria-label={inputLabel}\n            aria-invalid={invalid || undefined}\n            role={suggestions ? 'combobox' : undefined}\n            aria-autocomplete={suggestions ? 'list' : undefined}\n            aria-expanded={suggestions ? expanded : undefined}\n            aria-controls={suggestions && expanded ? listId : undefined}\n            aria-activedescendant={expanded && active >= 0 ? optionId(active) : undefined}\n            autoComplete=\"off\"\n            className={cn(\n              'basis-24 grow bg-transparent py-1 typography-input-text text-foreground outline-none placeholder:text-text-subtle',\n              // Below sm the field text is at least 16px so iOS Safari does not zoom on focus.\n              'max-sm:text-[max(16px,var(--typo-input-text-size,1rem))]',\n              invalidRing,\n            )}\n            onChange={(event) => {\n              const text = event.target.value;\n              setOpen(true);\n              setActive(-1);\n              if (splitter.test(text)) {\n                const parts = text.split(splitter);\n                add(parts.slice(0, -1));\n                setDraft(parts[parts.length - 1]);\n              } else {\n                setDraft(text);\n              }\n            }}\n            onKeyDown={(event) => {\n              if (suggestions && event.key === 'ArrowDown') {\n                event.preventDefault();\n                if (!expanded) setOpen(true);\n                setActive((index) => (options.length === 0 ? -1 : (index + 1) % options.length));\n              } else if (suggestions && event.key === 'ArrowUp' && expanded) {\n                event.preventDefault();\n                setActive((index) => (index <= 0 ? options.length - 1 : index - 1));\n              } else if (suggestions && event.key === 'Escape' && expanded) {\n                event.preventDefault();\n                event.stopPropagation();\n                setOpen(false);\n                setActive(-1);\n              } else if (event.key === 'Enter' && expanded && active >= 0) {\n                event.preventDefault();\n                pick(options[active].value);\n              } else if (event.key === 'Enter') {\n                event.preventDefault();\n                add([draft]);\n                setDraft('');\n                setOpen(false);\n              } else if (event.key === 'Backspace' && draft === '' && tags.length > 0) {\n                remove(tags[tags.length - 1]);\n              }\n            }}\n            onBlur={() => {\n              setOpen(false);\n              setActive(-1);\n              if (draft.trim()) {\n                add([draft]);\n                setDraft('');\n              }\n            }}\n            onPaste={(event) => {\n              const text = event.clipboardData.getData('text');\n              if (splitter.test(text)) {\n                event.preventDefault();\n                add(text.split(splitter));\n                setDraft('');\n              }\n            }}\n          />\n        )}\n        {suggestions && expanded ? (\n          <div\n            id={listId}\n            role=\"listbox\"\n            aria-label={l.tagSuggestions}\n            className={cn('absolute left-0 top-full z-dropdown mt-1 flex max-h-60 w-full flex-col gap-1 overflow-auto p-1', overlaySurface)}\n            onMouseDown={(event) => event.preventDefault()}\n          >\n            {[undefined, ...categories].map((category) => {\n              const group = options.filter((entry) => entry.category === category || (!category && !entry.category));\n              if (group.length === 0) return null;\n              const rows = group.map((entry) => {\n                const index = options.indexOf(entry);\n                return (\n                  <div\n                    key={entry.value}\n                    id={optionId(index)}\n                    role=\"option\"\n                    aria-selected={index === active}\n                    data-active={index === active || undefined}\n                    className={cn(\n                      'cursor-pointer rounded-item px-3 py-2 typography-ui text-text-default hover:bg-muted active:bg-muted data-[active]:bg-muted',\n                      transitionBase,\n                    )}\n                    onMouseEnter={() => setActive(index)}\n                    onClick={(event) => {\n                      event.stopPropagation();\n                      pick(entry.value);\n                    }}\n                  >\n                    {labelOf(entry)}\n                  </div>\n                );\n              });\n              if (!category) return <React.Fragment key=\"__none\">{rows}</React.Fragment>;\n              const headingId = `${listId}-group-${categories.indexOf(category)}`;\n              return (\n                <div key={category} role=\"group\" aria-labelledby={headingId} className=\"flex flex-col gap-1\">\n                  <div id={headingId} role=\"presentation\" className=\"px-3 pb-1 pt-2 typography-ui-semibold text-text-subtle\">\n                    {category}\n                  </div>\n                  {rows}\n                </div>\n              );\n            })}\n          </div>\n        ) : null}\n        {name ? <input type=\"hidden\" name={name} value={tags.join(',')} /> : null}\n        <span role=\"status\" className=\"sr-only\">\n          {announcement || (suggestions && expanded ? l.tagSuggestionsCount(options.length) : '')}\n        </span>\n      </div>\n    );\n  },\n);\nTagInput.displayName = 'TagInput';\n\nexport { TagInput };\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}