{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tag-input","type":"registry:ui","title":"Tag Input","description":"Type free-text tags; Enter or a comma adds one, and pasting a list adds them all.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/field.json"],"files":[{"path":"registry/dashral/tag-input.tsx","type":"registry:file","target":"components/dashral/tag-input.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { XIcon } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\nimport { FieldError, FieldWrapper, splitFieldProps, useFieldControl, type FieldClassNames, type FieldOwnProps } from \"./field\"\n\ntype TagInputControlProps = Omit<React.ComponentProps<\"input\">, \"value\" | \"defaultValue\" | \"onChange\"> & {\n  /** Controlled value. Omit it (and use defaultValue) for an uncontrolled field. */\n  value?: string[]\n  defaultValue?: string[]\n  onValueChange?: (tags: string[]) => void\n  /** At most this many tags; the field stops taking more once it's reached. */\n  maxTags?: number\n  /** Return an error message to refuse a tag (shown below the field), or null to take it. */\n  validate?: (tag: string) => string | null\n  /** Characters that finish a tag as you type, besides Enter. Pasted text is split on them too. */\n  separators?: string[]\n}\n\ntype TagInputProps = TagInputControlProps &\n  FieldOwnProps & {\n    classNames?: FieldClassNames & { box?: string }\n  }\n\n/**\n * Free-text tags: type and press Enter or a comma to add one, Backspace in the empty field to take the last one back.\n * Duplicates are ignored, and pasting \"a, b, c\" adds three.\n */\nfunction TagInput(props: TagInputProps) {\n  const [field, control] = splitFieldProps(props)\n  return (\n    <FieldWrapper {...field}>\n      <TagInputControl {...control} />\n    </FieldWrapper>\n  )\n}\n\nfunction TagInputControl({ classNames, ...controlProps }: TagInputControlProps & { classNames?: TagInputProps[\"classNames\"] }) {\n  const {\n    value,\n    defaultValue = [],\n    onValueChange,\n    maxTags,\n    validate,\n    separators = [\",\"],\n    placeholder: placeholderProp,\n    className,\n    disabled,\n    id,\n    \"aria-describedby\": describedBy,\n    \"aria-invalid\": invalid,\n    ...props\n  } = useFieldControl(controlProps)\n  const { t } = useLocale()\n  const placeholder = placeholderProp ?? t(\"addTag\")\n  const [internal, setInternal] = React.useState(defaultValue)\n  const tags = value ?? internal\n  const [draft, setDraft] = React.useState(\"\")\n  const [error, setError] = React.useState<string | null>(null)\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const errorId = React.useId()\n  const full = maxTags !== undefined && tags.length >= maxTags\n\n  function set(next: string[]) {\n    if (value === undefined) setInternal(next)\n    onValueChange?.(next)\n  }\n\n  /** Adds what's been typed (or pasted); returns whether everything was taken. */\n  function add(text: string) {\n    const split = new RegExp(`[${separators.map((s) => s.replace(/[\\\\\\]^-]/g, \"\\\\$&\")).join(\"\")}\\\\n]`)\n    let next = [...tags]\n    for (const raw of text.split(split)) {\n      const tag = raw.trim()\n      if (!tag || next.some((t) => t.toLowerCase() === tag.toLowerCase())) continue\n      if (maxTags !== undefined && next.length >= maxTags) {\n        setError(t(\"tagLimit\", { count: maxTags }))\n        break\n      }\n      const problem = validate?.(tag) ?? null\n      if (problem) {\n        setError(problem)\n        set(next)\n        return false\n      }\n      next = [...next, tag]\n    }\n    set(next)\n    return true\n  }\n\n  return (\n    <div className=\"grid gap-1.5\">\n      <div\n        data-slot=\"tag-input\"\n        data-disabled={disabled || undefined}\n        className={cn(\n          \"flex min-h-9 w-full cursor-text flex-wrap items-center gap-1 rounded-md border border-input bg-transparent px-1.5 py-1 text-sm shadow-xs transition-[color,box-shadow]\",\n          \"focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/15 data-disabled:cursor-not-allowed data-disabled:opacity-50\",\n          \"has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/15\",\n          className,\n          classNames?.box\n        )}\n        onClick={() => inputRef.current?.focus()}\n      >\n        {tags.length > 0 && (\n          <ul aria-label={t(\"tags\")} className=\"contents\">\n            {tags.map((tag) => (\n              <li key={tag} className=\"inline-flex h-6 items-center gap-0.5 rounded bg-secondary pe-0.5 ps-2 text-xs font-medium text-secondary-foreground\">\n                {tag}\n                <button\n                  type=\"button\"\n                  aria-label={t(\"remove\", { name: tag })}\n                  disabled={disabled}\n                  className=\"flex size-5 items-center justify-center rounded-sm text-muted-foreground outline-none hover:bg-background/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/15\"\n                  onClick={(event) => {\n                    event.stopPropagation()\n                    set(tags.filter((t) => t !== tag))\n                    inputRef.current?.focus()\n                  }}\n                >\n                  <XIcon className=\"size-3\" aria-hidden />\n                </button>\n              </li>\n            ))}\n          </ul>\n        )}\n        <input\n          ref={inputRef}\n          id={id}\n          type=\"text\"\n          value={draft}\n          disabled={disabled || (full && !draft)}\n          placeholder={full ? \"\" : placeholder}\n          aria-invalid={error ? true : invalid}\n          aria-describedby={[describedBy, error ? errorId : null].filter(Boolean).join(\" \") || undefined}\n          className=\"h-7 min-w-24 flex-1 bg-transparent px-1.5 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed\"\n          onChange={(event) => {\n            setError(null)\n            const text = event.target.value\n            if (separators.some((s) => text.endsWith(s))) {\n              if (add(text)) setDraft(\"\")\n            } else setDraft(text)\n          }}\n          onKeyDown={(event) => {\n            if (event.key === \"Enter\" && draft.trim()) {\n              event.preventDefault()\n              if (add(draft)) setDraft(\"\")\n            } else if (event.key === \"Backspace\" && !draft && tags.length > 0) {\n              event.preventDefault()\n              // Put the last tag back in the field to fix it, rather than losing it.\n              setDraft(tags.at(-1)!)\n              set(tags.slice(0, -1))\n            }\n          }}\n          onPaste={(event) => {\n            const text = event.clipboardData.getData(\"text\")\n            if (!/[\\n,]/.test(text) && !separators.some((s) => text.includes(s))) return\n            event.preventDefault()\n            if (add(draft + text)) setDraft(\"\")\n          }}\n          onBlur={() => {\n            if (draft.trim() && add(draft)) setDraft(\"\")\n          }}\n          {...props}\n        />\n      </div>\n      {error && (\n        <FieldError id={errorId} role=\"alert\">\n          {error}\n        </FieldError>\n      )}\n    </div>\n  )\n}\n\nexport { TagInput }\n"}]}