{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chip-select",
  "type": "registry:ui",
  "title": "Chip Select",
  "description": "Filter and interest chips with a springy check-in, select all / clear, a max with feedback and a +N more collapse.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/chip-select.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useAnimate } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport interface ChipOption {\n  value: string;\n  label?: string;\n  /** Optional count shown after the label, e.g. number of matching results. */\n  count?: number;\n}\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface ChipSelectProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"defaultValue\" | \"children\"> {\n  options: ChipOption[];\n  /** Controlled selection (values in option order). */\n  value?: string[];\n  /** Initial selection when uncontrolled. */\n  defaultValue?: string[];\n  onChange?: (value: string[]) => void;\n  /** Visible group label. */\n  label?: React.ReactNode;\n  /** Helper text linked with aria-describedby. */\n  hint?: React.ReactNode;\n  /** Multi-select chips (checkboxes) or single-select chips (radios). */\n  multiple?: boolean;\n  /** Maximum selections in multi mode; 0 means no limit. */\n  max?: number;\n  /** Show this many chips and collapse the rest behind \"+N more\"; 0 shows all. */\n  collapseAfter?: number;\n  /** Show option counts. */\n  showCounts?: boolean;\n  /** Show \"Select all\" and \"Clear\" (multi mode). */\n  showActions?: boolean;\n  size?: \"sm\" | \"md\";\n  /** Spring for the check-in animation. */\n  spring?: Spring;\n  /** Selected fill, focus ring and limit feedback colour. */\n  accent?: string;\n  /** Adds one hidden input per selected value (repeated name) for native forms. */\n  name?: string;\n  disabled?: boolean;\n}\n\nconst DEFAULT_SPRING: Spring = { stiffness: 600, damping: 30, mass: 0.7 };\n\n/**\n * Filter / interest chips. In multi mode each chip is a role=\"checkbox\" inside a labelled group;\n * in single mode they are radios in a radiogroup. Either way the set is one tab stop with arrow\n * key navigation (roving tabindex), and Space or Enter toggles the focused chip.\n */\nexport function ChipSelect({\n  options,\n  value,\n  defaultValue,\n  onChange,\n  label = \"Filters\",\n  hint,\n  multiple = true,\n  max = 0,\n  collapseAfter = 8,\n  showCounts = true,\n  showActions = true,\n  size = \"md\",\n  spring = DEFAULT_SPRING,\n  accent = \"#FF7448\",\n  name,\n  disabled = false,\n  className = \"\",\n  style,\n  ...props\n}: ChipSelectProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId();\n  const labelId = `${uid}-label`;\n  const hintId = `${uid}-hint`;\n  const statusId = `${uid}-status`;\n\n  const [inner, setInner] = React.useState<string[]>(() => defaultValue ?? []);\n  const selected = value ?? inner;\n  const [expanded, setExpanded] = React.useState(false);\n  // The group's tab stop: starts on the first selected chip, then follows focus.\n  const [activeIndex, setActiveIndex] = React.useState(() => Math.max(0, options.findIndex((o) => selected.includes(o.value))));\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  const [limitHit, setLimitHit] = React.useState(false);\n  const chipRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const pendingFocus = React.useRef<number | null>(null);\n  const limitTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  const [scope, animate] = useAnimate<HTMLDivElement>();\n\n  React.useEffect(() => () => clearTimeout(limitTimer.current), []);\n\n  const limit = multiple && max > 0 ? max : Infinity;\n  const atLimit = selected.length >= limit;\n  const collapsible = collapseAfter > 0 && options.length > collapseAfter;\n  const visibleCount = collapsible && !expanded ? collapseAfter : options.length;\n  const hidden = options.slice(visibleCount);\n  const hiddenSelected = hidden.filter((o) => selected.includes(o.value)).length;\n  const active = Math.min(activeIndex, visibleCount - 1);\n  const labelOf = (o: ChipOption) => o.label ?? o.value;\n\n  // Focus the first revealed chip after \"+N more\" expands the list.\n  React.useEffect(() => {\n    if (pendingFocus.current === null) return;\n    chipRefs.current[pendingFocus.current]?.focus();\n    pendingFocus.current = null;\n  }, [expanded]);\n\n  const announce = (message: string) => setAnnouncement((prev) => (prev === message ? `${message} ` : message));\n\n  const commit = (next: string[]) => {\n    // Keep values in option order so the form value is stable.\n    const ordered = options.map((o) => o.value).filter((v) => next.includes(v));\n    if (value === undefined) setInner(ordered);\n    onChange?.(ordered);\n  };\n\n  const flagLimit = (index: number) => {\n    setLimitHit(true);\n    clearTimeout(limitTimer.current);\n    limitTimer.current = setTimeout(() => setLimitHit(false), 1600);\n    announce(`You can choose up to ${limit}. Deselect one to pick another.`);\n    const chip = chipRefs.current[index];\n    if (chip && !reduceMotion) animate(chip, { x: [0, -5, 5, -3, 2, 0] }, { duration: 0.35, ease: \"easeOut\" });\n  };\n\n  const toggle = (index: number) => {\n    const option = options[index];\n    if (!option || disabled) return;\n    const isOn = selected.includes(option.value);\n    if (!multiple) {\n      if (isOn) return;\n      commit([option.value]);\n      announce(`${labelOf(option)} selected.`);\n      return;\n    }\n    if (isOn) {\n      commit(selected.filter((v) => v !== option.value));\n      announce(`${labelOf(option)} removed. ${selected.length - 1} selected.`);\n    } else if (atLimit) {\n      flagLimit(index);\n    } else {\n      commit([...selected, option.value]);\n      announce(`${labelOf(option)} added. ${selected.length + 1} selected.`);\n    }\n  };\n\n  const focusChip = (index: number) => {\n    const i = Math.max(0, Math.min(visibleCount - 1, index));\n    setActiveIndex(i);\n    chipRefs.current[i]?.focus();\n    // Radios follow focus, as in a native radio group.\n    if (!multiple) toggle(i);\n  };\n\n  const handleKeyDown = (index: number, event: React.KeyboardEvent<HTMLButtonElement>) => {\n    switch (event.key) {\n      case \"ArrowRight\":\n      case \"ArrowDown\":\n        event.preventDefault();\n        focusChip(index + 1 >= visibleCount ? 0 : index + 1);\n        break;\n      case \"ArrowLeft\":\n      case \"ArrowUp\":\n        event.preventDefault();\n        focusChip(index - 1 < 0 ? visibleCount - 1 : index - 1);\n        break;\n      case \"Home\":\n        event.preventDefault();\n        focusChip(0);\n        break;\n      case \"End\":\n        event.preventDefault();\n        focusChip(visibleCount - 1);\n        break;\n      case \" \":\n      case \"Enter\":\n        event.preventDefault();\n        toggle(index);\n        break;\n    }\n  };\n\n  const selectAll = () => {\n    commit(options.map((o) => o.value));\n    announce(`All ${options.length} selected.`);\n  };\n  const clear = () => {\n    commit([]);\n    announce(\"Selection cleared.\");\n  };\n\n  const pad = size === \"sm\" ? \"h-8 px-3 text-[12px]\" : \"h-9 px-3.5 text-[13px]\";\n  const chipTransition = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, ...spring };\n  const canSelectAll = multiple && limit >= options.length;\n\n  const actionClass =\n    \"rounded-md px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 outline-hidden transition-colors hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--cs-accent)] disabled:pointer-events-none disabled:opacity-40 dark:text-zinc-400 dark:hover:text-white\";\n\n  return (\n    <div\n      className={`flex w-full flex-col gap-3 ${disabled ? \"opacity-50\" : \"\"} ${className}`}\n      style={{ [\"--cs-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <div className=\"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1\">\n        <div className=\"flex items-baseline gap-2\">\n          <span id={labelId} className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n            {label}\n          </span>\n          {multiple && (\n            <span\n              id={statusId}\n              className={`font-mono text-[10px] tabular-nums tracking-[0.14em] transition-colors duration-200 ${\n                limitHit ? \"text-[color-mix(in_srgb,var(--cs-accent)_72%,black)] dark:text-[var(--cs-accent)]\" : \"text-zinc-400 dark:text-zinc-500\"\n              }`}\n            >\n              {selected.length}\n              {Number.isFinite(limit) ? `/${limit}` : \"\"} selected\n            </span>\n          )}\n        </div>\n        {showActions && multiple && (\n          <div className=\"flex items-center gap-1\">\n            {canSelectAll && (\n              <button type=\"button\" className={actionClass} disabled={disabled || selected.length === options.length} onClick={selectAll}>\n                Select all\n              </button>\n            )}\n            <button type=\"button\" className={actionClass} disabled={disabled || selected.length === 0} onClick={clear}>\n              Clear\n            </button>\n          </div>\n        )}\n      </div>\n\n      <div\n        ref={scope}\n        role={multiple ? \"group\" : \"radiogroup\"}\n        aria-labelledby={labelId}\n        aria-describedby={[multiple ? statusId : null, hint ? hintId : null].filter(Boolean).join(\" \") || undefined}\n        aria-disabled={disabled || undefined}\n        className=\"flex flex-wrap items-center gap-2\"\n      >\n        {options.slice(0, visibleCount).map((option, i) => {\n          const on = selected.includes(option.value);\n          const blocked = !on && atLimit;\n          return (\n            <motion.button\n              key={option.value}\n              ref={(el: HTMLButtonElement | null) => {\n                chipRefs.current[i] = el;\n              }}\n              layout={reduceMotion ? false : \"position\"}\n              transition={chipTransition}\n              type=\"button\"\n              role={multiple ? \"checkbox\" : \"radio\"}\n              aria-checked={on}\n              disabled={disabled}\n              tabIndex={i === active ? 0 : -1}\n              onFocus={() => setActiveIndex(i)}\n              onClick={() => {\n                setActiveIndex(i);\n                toggle(i);\n              }}\n              onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) => handleKeyDown(i, e)}\n              whileTap={reduceMotion || disabled ? undefined : { scale: 0.95 }}\n              className={`inline-flex select-none items-center rounded-full border font-medium tracking-tight outline-hidden transition-[background-color,border-color,color] duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cs-accent)] motion-reduce:transition-none ${pad} ${\n                on\n                  ? \"border-[var(--cs-accent)] bg-[color-mix(in_srgb,var(--cs-accent)_14%,transparent)] text-zinc-950 dark:text-white\"\n                  : blocked\n                    ? \"border-zinc-900/10 text-zinc-400 dark:border-white/10 dark:text-zinc-500\"\n                    : \"border-zinc-900/12 bg-white text-zinc-700 hover:border-zinc-900/30 hover:text-zinc-950 dark:border-white/12 dark:bg-white/[0.03] dark:text-zinc-300 dark:hover:border-white/30 dark:hover:text-white\"\n              }`}\n            >\n              <motion.span\n                aria-hidden=\"true\"\n                initial={false}\n                animate={{ width: on ? 16 : 0, opacity: on ? 1 : 0 }}\n                transition={chipTransition}\n                className=\"inline-flex shrink-0 items-center overflow-hidden\"\n              >\n                <svg viewBox=\"0 0 12 12\" className=\"size-3 shrink-0 text-[var(--cs-accent)]\" fill=\"none\">\n                  <motion.path\n                    d=\"M2.5 6.4l2.3 2.3 4.8-5.2\"\n                    stroke=\"currentColor\"\n                    strokeWidth=\"1.8\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    initial={false}\n                    animate={{ pathLength: on ? 1 : 0 }}\n                    transition={reduceMotion ? { duration: 0 } : { duration: 0.22, delay: on ? 0.06 : 0, ease: \"easeOut\" }}\n                  />\n                </svg>\n              </motion.span>\n              <span>{labelOf(option)}</span>\n              {showCounts && option.count !== undefined && (\n                <span\n                  className={`ml-1.5 font-mono text-[10px] tabular-nums tracking-normal ${\n                    on ? \"text-zinc-600 dark:text-zinc-300\" : \"text-zinc-400 dark:text-zinc-500\"\n                  }`}\n                >\n                  {option.count}\n                  <span className=\"sr-only\"> results</span>\n                </span>\n              )}\n            </motion.button>\n          );\n        })}\n\n        {collapsible && (\n          <button\n            type=\"button\"\n            aria-expanded={expanded}\n            onClick={() => {\n              if (!expanded) pendingFocus.current = collapseAfter;\n              setExpanded(!expanded);\n              if (expanded && active >= collapseAfter) setActiveIndex(0);\n            }}\n            className={`inline-flex items-center gap-1.5 rounded-full border border-dashed border-zinc-900/20 font-mono uppercase tracking-[0.12em] text-zinc-500 outline-hidden transition-colors hover:border-zinc-900/40 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cs-accent)] dark:border-white/20 dark:text-zinc-400 dark:hover:border-white/40 dark:hover:text-white ${\n              size === \"sm\" ? \"h-8 px-3 text-[10px]\" : \"h-9 px-3.5 text-[10px]\"\n            }`}\n          >\n            {expanded ? (\n              \"Show less\"\n            ) : (\n              <>\n                +{hidden.length} more\n                {hiddenSelected > 0 && (\n                  <span className=\"rounded-full bg-[var(--cs-accent)] px-1.5 py-px text-[9px] tracking-normal text-zinc-950\">\n                    {hiddenSelected}\n                    <span className=\"sr-only\"> selected</span>\n                  </span>\n                )}\n              </>\n            )}\n          </button>\n        )}\n      </div>\n\n      {(hint || (multiple && Number.isFinite(limit))) && (\n        <p id={hint ? hintId : undefined} className=\"text-[12px] leading-snug text-zinc-500 dark:text-zinc-400\">\n          {atLimit && Number.isFinite(limit) ? (\n            <span className={limitHit ? \"font-medium text-[color-mix(in_srgb,var(--cs-accent)_72%,black)] dark:text-[var(--cs-accent)]\" : \"\"}>\n              Limit reached. Deselect one to choose another.\n            </span>\n          ) : (\n            hint\n          )}\n        </p>\n      )}\n\n      <span className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {announcement}\n      </span>\n      {name && selected.map((v) => <input key={v} type=\"hidden\" name={name} value={v} />)}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/chip-select"
}
