{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tags-input","title":"Tags Input","description":"Type free-text tags into a field that shows each one as a removable tag before the text. Search terms: tag input, chips input, keywords, email list, multi value input.","dependencies":[],"registryDependencies":["@siteplane/base","@siteplane/input","@siteplane/input-group","@siteplane/tag-group"],"files":[{"path":"packages/ui/src/components/tags-input/tags-input.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\nimport {\n  type SiteplaneControlSize,\n  useResolvedControlSize,\n} from \"../../system/provider/siteplane-ui-provider\";\nimport { useSiteplaneMessages } from \"../../system/locale/messages-context\";\nimport type { FormControlVariant } from \"../../system/styles/surface\";\nimport { cn } from \"../../system/utils/cn\";\nimport { slotClass } from \"../../system/utils/slot-class\";\nimport { useFieldControlBridge } from \"../field/field-control-bridge\";\nimport { Input, unnamedInputRender } from \"../input/input\";\nimport { KitInput } from \"../input/input-internal\";\nimport { FieldStatus } from \"../input-group/field-status\";\nimport { Tag, TagGroup } from \"../tag-group\";\nimport {\n  acceptedTag,\n  addTags,\n  splitAtDelimiter,\n  tagEntries,\n  type TagRules,\n} from \"./tags-input-entries\";\n\nimport \"./tags-input.css\";\n\n/*\n * The tags of a field are the Tag sizes of its chips (the chip rule,\n * design-rework.css): sm in an sm field, md in an md or lg field and xl in an\n * xl field.\n */\nconst tagSizes = {\n  lg: \"md\",\n  md: \"md\",\n  sm: \"sm\",\n  xl: \"xl\",\n} as const satisfies Record<SiteplaneControlSize, SiteplaneControlSize>;\n\nexport type TagsInputProps = Omit<\n  React.ComponentProps<\"div\">,\n  | \"aria-describedby\"\n  | \"aria-invalid\"\n  | \"aria-label\"\n  | \"aria-labelledby\"\n  | \"children\"\n  | \"defaultValue\"\n  | \"id\"\n  | \"onChange\"\n> &\n  TagRules & {\n    /** The tags, controlled. */\n    value?: readonly string[] | undefined;\n    /** The tags the field starts with, uncontrolled. */\n    defaultValue?: readonly string[] | undefined;\n    /** Called with the new tags when a tag is added or removed. */\n    onValueChange?: ((tags: string[]) => void) | undefined;\n    /** What the field shows while it holds no tag. */\n    placeholder?: string | undefined;\n    /** The control size, as Input takes it; unset, the provider's. */\n    size?: SiteplaneControlSize | undefined;\n    /**\n     * The surface, as on Input and ComboboxChips: `default` rests inset on\n     * the field's light shade, `soft` lays a subtle fill under the same edge\n     * and takes the page background while the field has focus. Default:\n     * `default`.\n     */\n    variant?: FormControlVariant | undefined;\n    disabled?: boolean | undefined;\n    /**\n     * The name of the tags in the form data, one entry per tag; a Field's\n     * name comes first. The text being typed is not sent.\n     */\n    name?: string | undefined;\n    /** The id of the form that the tags belong to. */\n    form?: string | undefined;\n    /**\n     * Marks the field as required for assistive technology and for a Field's\n     * `requiredIndicator`. The field does not stop a form that is sent\n     * without a tag.\n     */\n    required?: boolean | undefined;\n    /**\n     * Typing this text, or pasting text with it, adds the text before it as a\n     * tag, as Enter does. `null` turns it off. Default: a comma.\n     */\n    delimiter?: string | null | undefined;\n    /**\n     * A help text behind a help icon at the end of the field, shown in a\n     * Tooltip on hover and keyboard focus, as Input's `tooltip`.\n     */\n    tooltip?: React.ReactNode;\n    /**\n     * While the field is invalid (`aria-invalid`, which an invalid Field\n     * sets), an alert icon stands at its end in the help icon's place.\n     * `false` turns it off. Default: `true`.\n     */\n    showInvalidIcon?: boolean | undefined;\n    /**\n     * The name of a tag's remove action. Default: the `chip.remove` message\n     * with the tag, \"Remove\" and the tag in English.\n     */\n    removeLabel?: ((tag: string) => string) | undefined;\n    /** The id of the text input, which a label's `htmlFor` names. */\n    id?: string | undefined;\n    \"aria-describedby\"?: string | undefined;\n    \"aria-invalid\"?: React.AriaAttributes[\"aria-invalid\"];\n    /** The name of the text input, and of the tags without a Field label. */\n    \"aria-label\"?: string | undefined;\n    \"aria-labelledby\"?: string | undefined;\n  };\n\n/**\n * A field that takes free text as tags: the tags stand in the field before\n * the text, Enter adds what was typed (and so does the delimiter, a comma by\n * default), and each tag's remove action takes it out. A new tag is trimmed,\n * and an empty one, a duplicate (unless `allowDuplicates`), one past\n * `maxTags` or one that `validate` rejects is not added.\n *\n * Backspace in the empty text, and ArrowLeft at its start, move to the last\n * tag; the arrows move between the tags, Backspace or Delete removes the\n * focused tag, and ArrowRight on the last tag returns to the text. Once the\n * last tag is removed, the text takes focus.\n *\n * The field is the chips field of the kit (design-rework.css) on the field\n * scale of its size; the tags are a TagGroup of Tags, and the text is the\n * kit's Input, which takes a Field's label, description, error and state.\n */\nexport function TagsInput({\n  allowDuplicates,\n  \"aria-describedby\": ariaDescribedBy,\n  \"aria-invalid\": ariaInvalid,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n  className,\n  defaultValue,\n  delimiter = \",\",\n  disabled,\n  form,\n  id,\n  maxTags,\n  name,\n  onMouseDown,\n  onValueChange,\n  placeholder,\n  removeLabel: removeLabelProp,\n  required,\n  showInvalidIcon = true,\n  size,\n  tooltip,\n  validate,\n  value,\n  variant = \"default\",\n  ...props\n}: TagsInputProps): React.ReactElement {\n  const messages = useSiteplaneMessages(\"tagsInput\");\n  const resolvedSize = useResolvedControlSize<SiteplaneControlSize>(size);\n  // The Field's name, label and disabled state reach the tags as they reach\n  // the text.\n  const field = useFieldControlBridge({\n    \"aria-labelledby\": ariaLabelledBy,\n    disabled,\n    name,\n  });\n  const [ownTags, setOwnTags] = React.useState<readonly string[]>(\n    () => defaultValue ?? [],\n  );\n  const tags = value ?? ownTags;\n  const entries = React.useMemo(() => tagEntries(tags), [tags]);\n  const [draft, setDraft] = React.useState(\"\");\n  const inputRef = React.useRef<HTMLInputElement | null>(null);\n  const tagsRef = React.useRef<HTMLDivElement | null>(null);\n  const focusTimer = React.useRef<ReturnType<typeof setTimeout>>(undefined);\n  React.useEffect(() => () => clearTimeout(focusTimer.current), []);\n  const rules: TagRules = { allowDuplicates, maxTags, validate };\n\n  function commit(next: string[]) {\n    if (value === undefined) setOwnTags(next);\n    onValueChange?.(next);\n  }\n\n  /** The tags that are in the collection, without one that fades out. */\n  function tagRows(): HTMLElement[] {\n    return [\n      ...(tagsRef.current?.querySelectorAll<HTMLElement>('[role=\"row\"]') ?? []),\n    ];\n  }\n\n  function remove(key: string) {\n    const remaining = entries.filter((entry) => entry.key !== key);\n    if (remaining.length === entries.length) return;\n    commit(remaining.map((entry) => entry.label));\n    // Without a tag to move to, focus returns to the text, after the tag\n    // group has moved its own.\n    if (remaining.length === 0) {\n      clearTimeout(focusTimer.current);\n      focusTimer.current = setTimeout(() => inputRef.current?.focus(), 0);\n    }\n  }\n\n  return (\n    <div\n      {...props}\n      className={cn(\n        slotClass(\"tags-input\", props),\n        // The field of its size: its height, padding and text (the field\n        // scale), and the size of its tags; a Field reads it for its hint.\n        \"sp-field-scale\",\n        className,\n      )}\n      data-disabled={field.disabled || undefined}\n      data-size={resolvedSize}\n      data-slot=\"tags-input\"\n      // The surface, from the field family's soft block (design-rework.css).\n      data-variant={variant}\n      // A press on the field outside its tags and the text puts the caret in\n      // the text.\n      onMouseDown={(event) => {\n        onMouseDown?.(event);\n        if (event.defaultPrevented || field.disabled) return;\n        if ((event.target as Element).closest('input, button, [role=\"row\"]'))\n          return;\n        event.preventDefault();\n        inputRef.current?.focus();\n      }}\n      role=\"group\"\n    >\n      <div\n        className=\"sp-tags-input-area\"\n        data-slot=\"tags-input-area\"\n        // ArrowRight on the last tag returns to the text. It runs before the\n        // tag group, which would move on to the first tag.\n        onKeyDownCapture={(event) => {\n          if (event.key !== \"ArrowRight\") return;\n          const last = tagRows().at(-1);\n          if (!last?.contains(event.target as Node)) return;\n          event.preventDefault();\n          event.stopPropagation();\n          inputRef.current?.focus();\n        }}\n      >\n        {/* The tags flow in the field's row with the text after them (the\n            group adds no box of its own). */}\n        <TagGroup\n          aria-label={\n            field.labelledBy ? undefined : (ariaLabel ?? messages.label)\n          }\n          aria-labelledby={field.labelledBy}\n          className=\"contents\"\n          ref={tagsRef}\n          size={tagSizes[resolvedSize]}\n        >\n          {entries.map((entry) => (\n            <Tag\n              disabled={field.disabled}\n              key={entry.key}\n              onRemove={() => remove(entry.key)}\n              // Without a removeLabel the Tag names its remove action, the\n              // chip.remove message with the tag.\n              removeLabel={removeLabelProp?.(entry.label)}\n            >\n              {entry.label}\n            </Tag>\n          ))}\n        </TagGroup>\n        <KitInput\n          aria-describedby={ariaDescribedBy}\n          aria-invalid={ariaInvalid}\n          aria-label={ariaLabel}\n          aria-labelledby={ariaLabelledBy}\n          aria-required={required || undefined}\n          disabled={field.disabled}\n          id={id}\n          onChange={(event) => {\n            const text = event.currentTarget.value;\n            const { parts, rest } = splitAtDelimiter(text, delimiter);\n            if (parts.length === 0) {\n              setDraft(text);\n              return;\n            }\n            const { added, tags: next } = addTags(tags, parts, rules);\n            if (added.length > 0) commit(next);\n            // A text the field does not take stays in it, without the\n            // delimiter, as it does on Enter.\n            setDraft(\n              added.length === 0 && parts.length === 1 ? parts[0] + rest : rest,\n            );\n          }}\n          onKeyDown={(event) => {\n            if (event.nativeEvent.isComposing) return;\n            const input = event.currentTarget;\n            const atStart =\n              input.selectionStart === 0 && input.selectionEnd === 0;\n            if (event.key === \"Enter\") {\n              // Enter adds the text and never sends the form.\n              event.preventDefault();\n              const tag = acceptedTag(tags, draft, rules);\n              if (tag === null) return;\n              commit([...tags, tag]);\n              setDraft(\"\");\n              return;\n            }\n            const toTags =\n              (event.key === \"Backspace\" && draft === \"\") ||\n              event.key === \"ArrowLeft\";\n            if (!toTags || !atStart) return;\n            const last = tagRows().at(-1);\n            if (!last) return;\n            event.preventDefault();\n            last.focus();\n          }}\n          placeholder={entries.length === 0 ? placeholder : undefined}\n          profile=\"input-group\"\n          ref={inputRef}\n          // Base UI writes the Field's name on its input. The text is not\n          // the value, the tags are, so it renders without a name.\n          render={unnamedInputRender}\n          showInvalidIcon={false}\n          size={resolvedSize}\n          unstyled\n          value={draft}\n        />\n      </div>\n      <FieldStatus alert={showInvalidIcon} tooltip={tooltip} />\n      {field.name\n        ? entries.map((entry) => (\n            <Input\n              disabled={field.disabled}\n              form={form}\n              key={entry.key}\n              name={field.name}\n              type=\"hidden\"\n              value={entry.label}\n            />\n          ))\n        : null}\n    </div>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/tags-input/tags-input.tsx"},{"path":"packages/ui/src/components/tags-input/tags-input-entries.ts","content":"/*\n * The values of a Tags Input, apart from its markup, so they can be tested on\n * their own: the keys of its tags, the check a new tag must pass and the\n * split of typed or pasted text at the delimiter.\n */\n\n/** A tag as the field renders it: its text and a key that stays its own. */\nexport type TagsInputEntry = { key: string; label: string };\n\n/**\n * The tags with their keys. A key is made of the text and how often the same\n * text came before it, so the second \"react\" is always \"1:react\": the keys\n * stay stable from render to render, also with duplicates allowed, and a tag\n * that is removed takes its own key with it.\n */\nexport function tagEntries(tags: readonly string[]): TagsInputEntry[] {\n  const seen = new Map<string, number>();\n  return tags.map((label) => {\n    const count = seen.get(label) ?? 0;\n    seen.set(label, count + 1);\n    return { key: `${count}:${label}`, label };\n  });\n}\n\nexport type TagRules = {\n  /** Whether a text that is a tag already may be added again. */\n  allowDuplicates?: boolean | undefined;\n  /** The most tags the field holds. */\n  maxTags?: number | undefined;\n  /** Accepts or rejects a new tag; it gets the trimmed text. */\n  validate?: ((tag: string) => boolean) | undefined;\n};\n\n/**\n * The tag that `text` adds to `tags`, trimmed, or null when it adds none:\n * when it is empty, when it is a tag already and duplicates are not allowed,\n * when the field holds its most tags, or when `validate` rejects it.\n */\nexport function acceptedTag(\n  tags: readonly string[],\n  text: string,\n  { allowDuplicates = false, maxTags, validate }: TagRules,\n): string | null {\n  const tag = text.trim();\n  if (!tag) return null;\n  if (!allowDuplicates && tags.includes(tag)) return null;\n  if (maxTags !== undefined && tags.length >= maxTags) return null;\n  if (validate && !validate(tag)) return null;\n  return tag;\n}\n\n/**\n * Typed or pasted text split at the delimiter: every part before the last\n * delimiter is a tag to add, the rest stays in the field. Without a delimiter\n * in the text, or without a delimiter at all, nothing is split.\n */\nexport function splitAtDelimiter(\n  text: string,\n  delimiter: string | null | undefined,\n): { parts: string[]; rest: string } {\n  if (!delimiter || !text.includes(delimiter)) return { parts: [], rest: text };\n  const parts = text.split(delimiter);\n  const rest = parts.pop() ?? \"\";\n  return { parts, rest: rest.trimStart() };\n}\n\n/**\n * The tags after adding each of `parts` that passes the rules, in order, and\n * the tags that were added.\n */\nexport function addTags(\n  tags: readonly string[],\n  parts: readonly string[],\n  rules: TagRules,\n): { added: string[]; tags: string[] } {\n  const next = [...tags];\n  const added: string[] = [];\n  for (const part of parts) {\n    const tag = acceptedTag(next, part, rules);\n    if (tag === null) continue;\n    next.push(tag);\n    added.push(tag);\n  }\n  return { added, tags: next };\n}\n","type":"registry:ui","target":"~/src/siteplane/components/tags-input/tags-input-entries.ts"},{"path":"packages/ui/src/components/tags-input/tags-input.css","content":"@layer theme, base, components, siteplane, utilities;\n\n@layer siteplane {\n  /* The field is the kit's chips field (design-rework.css, beside the\n   * Combobox chips): its surface, focus, invalid and disabled states, its\n   * height on the field scale, the padding that centres a row of tags and the\n   * chip gap, 6 px between the tags. These defaults lay it out: one row of\n   * tags and text, which wraps, then the status of the field at its end,\n   * centred on the rows, 4 px after them, so the text ends 8 px before its\n   * glyph (I3). A press on it puts the caret in the text. */\n  :where(.sp-tags-input) {\n    position: relative;\n    display: flex;\n    width: 100%;\n    min-width: 0;\n    align-items: center;\n    gap: var(--sp-rw-space-1);\n    cursor: text;\n  }\n\n  /* The tags and the text stand the field's chip gap apart, and the text is\n   * as high as a tag of the field (the input-group profile of Input reads the\n   * composite context). It starts 8 px after the last tag, as the text of\n   * Combobox chips does, and at the field's inline padding, 12 px from the\n   * edge, in an empty field, where the empty tag list keeps no gap. */\n  :where(.sp-tags-input-area) {\n    --sp-composite-control-height: var(--sp-pill-height, var(--sp-rw-space-6));\n    --sp-composite-control-padding-inline-start: var(--sp-rw-space-0-5);\n    --sp-composite-control-padding-inline-end: 0px;\n    position: relative;\n    display: flex;\n    min-width: 0;\n    flex: 1 1 0%;\n    flex-wrap: wrap;\n    align-items: center;\n    gap: var(--sp-chip-gap);\n  }\n\n  .sp-tags-input-area:not(:has(.sp-tag)) {\n    --sp-composite-control-padding-inline-start: var(--sp-rw-space-1);\n    column-gap: 0;\n  }\n\n  /* The text keeps 48 px, the least width of the text of Combobox chips, and\n   * moves to a row of its own where less is left beside the tags. */\n  .sp-tags-input-area > .sp-base-input[data-profile=\"input-group\"] .sp-input {\n    flex: 1 1 var(--sp-rw-space-12);\n  }\n\n  :where(.sp-tags-input .sp-tag) {\n    cursor: default;\n  }\n\n  /* A field that fades as disabled fades its tags with it: a disabled tag in\n   * it keeps its full opacity instead of fading a second time, as the\n   * disabled parts of every field do (design-rework.css). */\n  .sp-tags-input:has(.sp-input:disabled) .sp-tag {\n    --sp-rw-disabled-opacity-action: 1;\n  }\n\n  /* The status of the field (FieldStatus, input-group.css): the help icon of\n   * `tooltip`, and the alert icon in its place while the text is invalid.\n   * Its 24 px box keeps 4 px around the 16 px glyph, so the glyph ends at the\n   * field's inline padding and the text ends 8 px before it, with the gap. */\n  .sp-tags-input > .sp-field-status {\n    margin-inline-end: calc((var(--sp-rw-space-6) - var(--sp-rw-space-4)) / -2);\n  }\n\n  .sp-tags-input:has([aria-invalid=\"true\"]) > .sp-field-status[data-alert] {\n    display: flex;\n  }\n\n  .sp-tags-input:has([aria-invalid=\"true\"])\n    > .sp-field-status[data-alert]\n    > .sp-input-invalid-icon {\n    display: block;\n  }\n\n  .sp-tags-input:has([aria-invalid=\"true\"])\n    > .sp-field-status[data-alert]\n    > .sp-input-group-tooltip {\n    display: none;\n  }\n}\n","type":"registry:ui","target":"~/src/siteplane/components/tags-input/tags-input.css"},{"path":"packages/ui/src/components/tags-input/index.ts","content":"export * from \"./tags-input\";\n","type":"registry:ui","target":"~/src/siteplane/components/tags-input/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/tags-input.md","categories":["Forms & selection"],"meta":{"avoidWhen":["Avoid when every value comes from a known list; use Combobox with chips.","Avoid for showing or selecting tags outside a field; use Tag Group."],"description":"Type free-text tags into a field that shows each one as a removable tag before the text.","knownLimitations":["required marks the field for assistive technology and the required indicator, but it does not stop a form sent without a tag; check the list in the submit handler.","The tags do not reset with their form; control them and restore them in onReset.","A floating label is not available, the Left and Right arrows between the text and the tags are not mirrored in right-to-left pages, and the height changes at once when a row of tags comes or goes."],"providerNotes":[],"releaseStatus":"preview","searchTerms":["tags input","tag input","chips input","keywords","email list","multi value input"],"useWhen":["Use for values that people type themselves, such as labels, keywords or a list of email addresses.","Use with Field for a label, a description of what the field takes and its error."]}}