{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "roster",
  "type": "registry:ui",
  "title": "Roster",
  "description": "A tag / multi-select input over a real <input>. Type and press Enter or comma to pop in a removable chip; an optional suggestion pool filters as you type into a keyboard-navigable listbox, with a duplicate guard and a live-region announcing every add and remove. Backspace on an empty field peels the last chip; chips are focusable and removable with Delete. Neutral with one cool accent; reduced motion adds and removes instantly.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/inputs/roster/roster.tsx",
      "type": "registry:ui",
      "target": "components/crucible/roster.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\nexport interface RosterProps {\n  /** Controlled list of tags. Omit for uncontrolled use (see `defaultValue`). */\n  value?: string[];\n  /** Initial tags when uncontrolled. @default [] */\n  defaultValue?: string[];\n  /** Fires whenever the tag set changes, with the tags in order. */\n  onChange?: (tags: string[]) => void;\n  /**\n   * Optional suggestion pool, filtered against the query as you type. Selecting\n   * one (click / Enter on the highlighted option) adds it. @default []\n   */\n  suggestions?: string[];\n  /** Maximum number of tags. Adds are refused (and announced) at the cap. @default undefined — no limit */\n  max?: number;\n  /** Placeholder shown in the empty input. @default \"Add tags…\" */\n  placeholder?: string;\n  /**\n   * Case-insensitive duplicate guard when false — \"React\" and \"react\" collide.\n   * @default false\n   */\n  caseSensitive?: boolean;\n  /** Accessible name for the field. @default \"Tags\" */\n  label?: string;\n  /** The single cool accent for the field + chip focus rings and highlighted options. @default sky */\n  accent?: string;\n  /** Disable the whole control. @default false */\n  disabled?: boolean;\n  /** Freeze the chip pop / removal motion (static resting pose). @default false */\n  paused?: boolean;\n  className?: string;\n}\n\nconst DEFAULT_ACCENT = \"#38bdf8\";\n\n/** Normalise a tag for comparison under the case-sensitivity rule. */\nfunction normalize(tag: string, caseSensitive: boolean): string {\n  const t = tag.trim();\n  return caseSensitive ? t : t.toLowerCase();\n}\n\n/**\n * Roster — a tag / multi-select input backed by a real `<input>`. Type and press\n * Enter (or comma) to commit a chip; each chip pops in with a soft spring and is\n * removable by its × button, by focusing it and pressing Delete/Backspace, or by\n * pressing Backspace in an empty field (which peels off the last chip). An\n * optional suggestion pool filters as you type into a keyboard-navigable listbox\n * (↑/↓ to move, Enter to pick, Esc to dismiss); duplicates are refused. Every\n * add and remove is announced through a polite live region, chips are focusable,\n * and the field carries full combobox semantics. One cool accent lifts focus and\n * the chips; everything else is neutral. Reduced motion adds and removes chips\n * instantly with no spring.\n */\nexport function Roster({\n  value,\n  defaultValue = [],\n  onChange,\n  suggestions = [],\n  max,\n  placeholder = \"Add tags…\",\n  caseSensitive = false,\n  label = \"Tags\",\n  accent = DEFAULT_ACCENT,\n  disabled = false,\n  paused = false,\n  className,\n}: RosterProps) {\n  const reducedMotion = useReducedMotion();\n  const still = reducedMotion || paused;\n\n  const listboxId = React.useId();\n  const optionBaseId = React.useId();\n\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const chipRefs = React.useRef(new Map<string, HTMLButtonElement>());\n  const pendingFocus = React.useRef<string | \"input\" | null>(null);\n  const blurTimer = React.useRef<number | undefined>(undefined);\n\n  // Clear the delayed list-close on unmount — no dangling timer.\n  React.useEffect(() => () => window.clearTimeout(blurTimer.current), []);\n\n  const [internal, setInternal] = React.useState<string[]>(defaultValue);\n  const [query, setQuery] = React.useState(\"\");\n  const [focused, setFocused] = React.useState(false);\n  const [open, setOpen] = React.useState(false);\n  const [active, setActive] = React.useState(-1); // highlighted suggestion index\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  const isControlled = value !== undefined;\n  const tags = isControlled ? value : internal;\n\n  const atCapacity = max != null && tags.length >= max;\n\n  const commit = React.useCallback(\n    (next: string[]) => {\n      if (!isControlled) setInternal(next);\n      onChange?.(next);\n    },\n    [isControlled, onChange]\n  );\n\n  // Suggestions matching the query and not already chosen. Empty query with a\n  // focused field surfaces the remaining pool so the list can be browsed.\n  const matches = React.useMemo(() => {\n    if (!suggestions.length) return [] as string[];\n    const q = normalize(query, caseSensitive);\n    const chosen = new Set(tags.map((t) => normalize(t, caseSensitive)));\n    return suggestions.filter((s) => {\n      const n = normalize(s, caseSensitive);\n      if (chosen.has(n)) return false;\n      if (!q) return true;\n      return caseSensitive ? s.includes(query.trim()) : n.includes(q);\n    });\n  }, [suggestions, query, tags, caseSensitive]);\n\n  const showList = open && !atCapacity && matches.length > 0;\n\n  /** Add one or more tags in a single commit; returns true if any were added. */\n  const addTags = React.useCallback(\n    (raws: string[]): boolean => {\n      const next = [...tags];\n      const added: string[] = [];\n      let refusal = \"\";\n      for (const raw of raws) {\n        const trimmed = raw.trim();\n        if (!trimmed) continue;\n        if (max != null && next.length >= max) {\n          refusal = `Maximum of ${max} tags reached`;\n          break;\n        }\n        const n = normalize(trimmed, caseSensitive);\n        if (next.some((t) => normalize(t, caseSensitive) === n)) {\n          refusal = `${trimmed} is already added`;\n          continue;\n        }\n        next.push(trimmed);\n        added.push(trimmed);\n      }\n      if (added.length > 0) commit(next);\n      const parts = [added.length ? `Added ${added.join(\", \")}` : \"\", refusal].filter(Boolean);\n      if (parts.length) setAnnouncement(parts.join(\". \"));\n      return added.length > 0;\n    },\n    [caseSensitive, commit, max, tags]\n  );\n\n  const addTag = React.useCallback((raw: string) => addTags([raw]), [addTags]);\n\n  const removeAt = React.useCallback(\n    (index: number, restoreFocus: \"input\" | \"adjacent\" = \"adjacent\") => {\n      const target = tags[index];\n      if (target === undefined) return;\n      if (restoreFocus === \"adjacent\") {\n        // Land focus on the next chip, else the previous, else the input.\n        pendingFocus.current = tags[index + 1] ?? tags[index - 1] ?? \"input\";\n      } else {\n        pendingFocus.current = \"input\";\n      }\n      chipRefs.current.delete(target);\n      commit(tags.filter((_, i) => i !== index));\n      setAnnouncement(`Removed ${target}`);\n    },\n    [commit, tags]\n  );\n\n  // After a removal, move focus to the adjacent chip (or back to the input).\n  React.useEffect(() => {\n    const target = pendingFocus.current;\n    if (!target) return;\n    pendingFocus.current = null;\n    if (target === \"input\") inputRef.current?.focus({ preventScroll: true });\n    else chipRefs.current.get(target)?.focus({ preventScroll: true });\n  }, [tags]);\n\n  // Reset the highlight whenever the visible option set changes.\n  React.useEffect(() => {\n    setActive((a) => (a >= matches.length ? matches.length - 1 : a));\n  }, [matches.length]);\n\n  const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (disabled) return;\n\n    // Commit on Enter or comma.\n    if (e.key === \"Enter\" || e.key === \",\") {\n      e.preventDefault();\n      const picked = showList && active >= 0 ? matches[active] : query;\n      if (addTag(picked)) {\n        setQuery(\"\");\n        setActive(-1);\n        setOpen(false);\n      }\n      return;\n    }\n\n    if (e.key === \"Backspace\" && query === \"\" && tags.length > 0) {\n      // Peel the last chip when the field is empty.\n      e.preventDefault();\n      removeAt(tags.length - 1, \"input\");\n      return;\n    }\n\n    if (!showList) {\n      if (e.key === \"ArrowDown\" && matches.length > 0 && !atCapacity) {\n        e.preventDefault();\n        setOpen(true);\n        setActive(0);\n      }\n      return;\n    }\n\n    if (e.key === \"ArrowDown\") {\n      e.preventDefault();\n      setActive((a) => (a + 1) % matches.length);\n    } else if (e.key === \"ArrowUp\") {\n      e.preventDefault();\n      setActive((a) => (a - 1 + matches.length) % matches.length);\n    } else if (e.key === \"Escape\") {\n      e.preventDefault();\n      setOpen(false);\n      setActive(-1);\n    } else if (e.key === \"Home\") {\n      e.preventDefault();\n      setActive(0);\n    } else if (e.key === \"End\") {\n      e.preventDefault();\n      setActive(matches.length - 1);\n    }\n  };\n\n  const handleChipKeyDown = (\n    e: React.KeyboardEvent<HTMLButtonElement>,\n    index: number\n  ) => {\n    if (disabled) return;\n    if (e.key === \"Delete\" || e.key === \"Backspace\") {\n      e.preventDefault();\n      removeAt(index, \"adjacent\");\n    } else if (e.key === \"ArrowRight\") {\n      e.preventDefault();\n      const next = tags[index + 1];\n      if (next) chipRefs.current.get(next)?.focus({ preventScroll: true });\n      else inputRef.current?.focus({ preventScroll: true });\n    } else if (e.key === \"ArrowLeft\") {\n      e.preventDefault();\n      const prev = tags[index - 1];\n      if (prev) chipRefs.current.get(prev)?.focus({ preventScroll: true });\n    }\n  };\n\n  const chipTransition = still\n    ? { duration: 0 }\n    : ({ type: \"spring\", stiffness: 620, damping: 26 } as const);\n\n  return (\n    <div\n      data-crucible=\"roster\"\n      className={cn(\"w-full max-w-md\", disabled && \"opacity-50\", className)}\n    >\n      {/* Polite live region — every add / remove / refusal is announced. */}\n      <div aria-live=\"polite\" role=\"status\" className=\"sr-only\">\n        {announcement}\n      </div>\n\n      <div\n        onClick={() => inputRef.current?.focus({ preventScroll: true })}\n        className={cn(\n          \"relative flex flex-wrap items-center gap-1.5 rounded-xl border px-2 py-2\",\n          \"border-neutral-700 bg-neutral-900/40 transition-colors duration-200\",\n          !disabled && \"cursor-text\",\n          focused ? \"border-transparent\" : \"hover:border-neutral-600\"\n        )}\n      >\n        {/* Focus ring drawn manually so it adopts the accent hue. */}\n        <span\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute inset-0 rounded-xl transition-opacity duration-200\",\n            focused ? \"opacity-100\" : \"opacity-0\"\n          )}\n          style={{ boxShadow: `0 0 0 1.5px ${accent}, 0 0 18px -6px ${accent}` }}\n        />\n\n        <ul className=\"contents\" aria-label={`${label}, ${tags.length} selected`}>\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {tags.map((tag, index) => (\n              <motion.li\n                key={tag}\n                layout={still ? false : true}\n                initial={still ? false : { opacity: 0, scale: 0.6 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={still ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                transition={chipTransition}\n                className=\"relative z-10 flex\"\n              >\n                <button\n                  type=\"button\"\n                  ref={(el) => {\n                    if (el) chipRefs.current.set(tag, el);\n                    else chipRefs.current.delete(tag);\n                  }}\n                  disabled={disabled}\n                  aria-label={`Remove ${tag}`}\n                  onKeyDown={(e) => handleChipKeyDown(e, index)}\n                  onClick={() => removeAt(index, \"adjacent\")}\n                  className={cn(\n                    \"group relative inline-flex items-center gap-1 rounded-lg py-1 pl-2.5 pr-1.5 text-sm\",\n                    \"border border-neutral-700 bg-neutral-800/70 text-neutral-100\",\n                    \"outline-none transition-colors duration-150\",\n                    \"hover:border-neutral-600 focus-visible:border-transparent\"\n                  )}\n                  style={{ [\"--tw-ring-color\" as string]: accent }}\n                >\n                  {/* Accent focus outline for the chip. */}\n                  <span\n                    aria-hidden\n                    className=\"pointer-events-none absolute inset-0 rounded-lg opacity-0 transition-opacity group-focus-visible:opacity-100\"\n                    style={{ boxShadow: `0 0 0 1.5px ${accent}` }}\n                  />\n                  <span className=\"truncate\">{tag}</span>\n                  <span\n                    aria-hidden\n                    className={cn(\n                      \"flex h-4 w-4 shrink-0 items-center justify-center rounded\",\n                      \"text-neutral-400 transition-colors\",\n                      \"group-hover:text-neutral-100 group-focus-visible:text-neutral-100\"\n                    )}\n                  >\n                    <svg\n                      viewBox=\"0 0 24 24\"\n                      fill=\"none\"\n                      stroke=\"currentColor\"\n                      strokeWidth=\"2.2\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      className=\"h-3 w-3\"\n                    >\n                      <path d=\"M18 6 6 18\" />\n                      <path d=\"m6 6 12 12\" />\n                    </svg>\n                  </span>\n                </button>\n              </motion.li>\n            ))}\n          </AnimatePresence>\n        </ul>\n\n        <input\n          ref={inputRef}\n          type=\"text\"\n          role=\"combobox\"\n          aria-label={label}\n          aria-expanded={showList}\n          aria-controls={showList ? listboxId : undefined}\n          aria-autocomplete=\"list\"\n          aria-activedescendant={\n            showList && active >= 0 ? `${optionBaseId}-${active}` : undefined\n          }\n          aria-disabled={atCapacity || undefined}\n          value={query}\n          disabled={disabled}\n          placeholder={tags.length === 0 || focused ? placeholder : \"\"}\n          onChange={(e) => {\n            const v = e.target.value;\n            // Mobile keyboards (no reliable \",\" keydown) and pastes like\n            // \"a, b, c\" deliver commas here: commit every finished segment and\n            // keep the trailing remainder as the live query.\n            if (v.includes(\",\")) {\n              const parts = v.split(\",\");\n              const rest = parts.pop() ?? \"\";\n              addTags(parts);\n              setQuery(rest);\n              setActive(-1);\n            } else {\n              setQuery(v);\n            }\n            setOpen(true);\n          }}\n          onKeyDown={handleInputKeyDown}\n          onFocus={() => {\n            window.clearTimeout(blurTimer.current);\n            setFocused(true);\n            setOpen(true);\n          }}\n          onBlur={() => {\n            setFocused(false);\n            // Delay close so option mousedown can register.\n            window.clearTimeout(blurTimer.current);\n            blurTimer.current = window.setTimeout(() => setOpen(false), 120);\n          }}\n          className={cn(\n            \"relative z-10 min-w-24 flex-1 appearance-none bg-transparent px-1 py-1 text-sm\",\n            \"text-neutral-100 outline-none placeholder:text-neutral-500\",\n            \"disabled:cursor-not-allowed\"\n          )}\n        />\n\n        {/* Suggestion listbox. */}\n        <AnimatePresence>\n          {showList && (\n            <motion.ul\n              id={listboxId}\n              role=\"listbox\"\n              aria-label={`${label} suggestions`}\n              initial={still ? { opacity: 0 } : { opacity: 0, y: -4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={still ? { opacity: 0 } : { opacity: 0, y: -4 }}\n              transition={still ? { duration: 0 } : { duration: 0.14, ease: \"easeOut\" }}\n              className={cn(\n                \"absolute left-0 right-0 top-full z-20 mt-2 max-h-56 overflow-auto rounded-xl p-1\",\n                \"border border-neutral-700 bg-neutral-900 shadow-2xl shadow-black/60\"\n              )}\n            >\n              {matches.map((option, i) => {\n                const isActive = i === active;\n                return (\n                  <li\n                    key={option}\n                    id={`${optionBaseId}-${i}`}\n                    role=\"option\"\n                    aria-selected={isActive}\n                    onMouseDown={(e) => {\n                      // Keep focus on the input; add on click.\n                      e.preventDefault();\n                      if (addTag(option)) {\n                        setQuery(\"\");\n                        setActive(-1);\n                        setOpen(false);\n                        inputRef.current?.focus({ preventScroll: true });\n                      }\n                    }}\n                    onMouseMove={() => setActive(i)}\n                    className={cn(\n                      \"flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm\",\n                      isActive ? \"text-neutral-50\" : \"text-neutral-300\"\n                    )}\n                    style={isActive ? { backgroundColor: `${accent}1f` } : undefined}\n                  >\n                    <span\n                      aria-hidden\n                      className=\"h-1.5 w-1.5 shrink-0 rounded-full\"\n                      style={{\n                        backgroundColor: isActive ? accent : \"#525252\",\n                      }}\n                    />\n                    <span className=\"truncate\">{option}</span>\n                  </li>\n                );\n              })}\n            </motion.ul>\n          )}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n"
    }
  ]
}