{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "facets",
  "type": "registry:component",
  "title": "Facets",
  "description": "Filters tucked into collapsible checkbox groups and echoed above as chips you can pop off one by one, with search, Clear all and full arrow-key control.",
  "categories": [
    "component",
    "Forms"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/checkbox.json"
  ],
  "files": [
    {
      "path": "registry/devl/facets.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { ChevronDownIcon, ListFilterIcon, SearchIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, type ReactNode, useId, useMemo, useRef, useState } from \"react\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface FacetOption {\n  value: string;\n  label: string;\n  count?: number;\n  icon?: ReactNode;\n}\n\nexport interface FacetGroup {\n  id: string;\n  label: string;\n  options: FacetOption[];\n  defaultOpen?: boolean;\n}\n\nexport type FacetValue = Record<string, string[]>;\n\nexport interface FacetsProps {\n  groups: FacetGroup[];\n  value?: FacetValue;\n  defaultValue?: FacetValue;\n  onValueChange?: (value: FacetValue) => void;\n  summary?: (value: FacetValue) => ReactNode;\n  className?: string;\n}\n\nexport function Facets({ groups, value: valueProp, defaultValue = {}, onValueChange, summary, className }: FacetsProps) {\n  const [inner, setInner] = useState<FacetValue>(defaultValue);\n  const value = valueProp ?? inner;\n  const [open, setOpen] = useState<Record<string, boolean>>(() =>\n    Object.fromEntries(groups.map((g, i) => [g.id, g.defaultOpen ?? i === 0])),\n  );\n  const [query, setQuery] = useState(\"\");\n  const reduce = useReducedMotion();\n  const baseId = useId();\n  const panelRef = useRef<HTMLDivElement>(null);\n\n  const commit = (next: FacetValue) => {\n    if (valueProp === undefined) setInner(next);\n    onValueChange?.(next);\n  };\n\n  const toggle = (group: string, option: string, on: boolean) => {\n    const current = value[group] ?? [];\n    commit({ ...value, [group]: on ? [...current, option] : current.filter((v) => v !== option) });\n  };\n\n  const chips = groups.flatMap((g) =>\n    (value[g.id] ?? []).map((v) => ({ group: g, option: g.options.find((o) => o.value === v) })),\n  );\n  const total = chips.length;\n\n  const q = query.trim().toLowerCase();\n  const visible = useMemo(\n    () =>\n      groups\n        .map((g) => ({ ...g, options: q ? g.options.filter((o) => o.label.toLowerCase().includes(q)) : g.options }))\n        .filter((g) => g.options.length > 0),\n    [groups, q],\n  );\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (![\"ArrowDown\", \"ArrowUp\", \"Home\", \"End\"].includes(event.key)) return;\n    const rows = [...(panelRef.current?.querySelectorAll<HTMLElement>(\"[data-filter-row]\") ?? [])];\n    const index = rows.indexOf(document.activeElement as HTMLElement);\n    if (index === -1 && event.key !== \"ArrowDown\") return;\n    event.preventDefault();\n    const next =\n      event.key === \"Home\" ? 0 : event.key === \"End\" ? rows.length - 1 : event.key === \"ArrowDown\" ? Math.min(index + 1, rows.length - 1) : Math.max(index - 1, 0);\n    rows[next]?.focus();\n  };\n\n  const spring = reduce ? { duration: 0 } : { type: \"spring\" as const, stiffness: 420, damping: 34 };\n\n  return (\n    <div className={cn(\"grid w-full max-w-sm gap-3\", className)}>\n      <div className=\"flex min-h-8 flex-wrap items-center gap-1.5\">\n        <span className=\"me-1 inline-flex items-center gap-1.5 font-medium text-sm\">\n          <ListFilterIcon className=\"size-4 text-muted-foreground\" />\n          Filters\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              animate={{ scale: 1, opacity: 1 }}\n              className={cn(\n                \"inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 font-mono text-[11px] tabular-nums\",\n                total ? \"bg-primary text-primary-foreground\" : \"bg-muted text-muted-foreground\",\n              )}\n              exit={{ scale: 0.8, opacity: 0, transition: { duration: 0.1 } }}\n              initial={reduce ? false : { scale: 0.8, opacity: 0 }}\n              key={total}\n              transition={{ duration: 0.16, ease: [0.23, 1, 0.32, 1] }}\n            >\n              {total}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {chips.map(({ group, option }) =>\n            option ? (\n              <motion.span\n                animate={{ opacity: 1, scale: 1 }}\n                className=\"inline-flex h-7 items-center gap-1 rounded-full border bg-card ps-2.5 pe-1 text-xs shadow-xs/5\"\n                exit={{ opacity: 0, scale: 0.7 }}\n                initial={reduce ? false : { opacity: 0, scale: 0.7 }}\n                key={`${group.id}:${option.value}`}\n                layout={!reduce}\n                transition={spring}\n              >\n                <span className=\"text-muted-foreground\">{group.label}:</span>\n                <span className=\"font-medium\">{option.label}</span>\n                <button\n                  aria-label={`Remove ${group.label} ${option.label}`}\n                  className=\"relative ms-0.5 flex size-5 cursor-pointer items-center justify-center rounded-full text-muted-foreground outline-none after:absolute after:-inset-1.5 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n                  onClick={() => toggle(group.id, option.value, false)}\n                  type=\"button\"\n                >\n                  <XIcon className=\"size-3\" />\n                </button>\n              </motion.span>\n            ) : null,\n          )}\n          {total > 0 && (\n            <motion.button\n              animate={{ opacity: 1 }}\n              className=\"h-7 cursor-pointer rounded-full px-2 font-medium text-muted-foreground text-xs outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n              exit={{ opacity: 0 }}\n              initial={{ opacity: 0 }}\n              key=\"clear\"\n              layout={!reduce}\n              onClick={() => commit({})}\n              transition={spring}\n              type=\"button\"\n            >\n              Clear all\n            </motion.button>\n          )}\n        </AnimatePresence>\n      </div>\n      {summary && <div className=\"-mt-1 text-muted-foreground text-xs\">{summary(value)}</div>}\n\n      <div className=\"overflow-hidden rounded-2xl border bg-card shadow-xs/5\" onKeyDown={onKeyDown} ref={panelRef}>\n        <label className=\"flex items-center gap-2 border-b px-3\">\n          <SearchIcon className=\"size-4 shrink-0 text-muted-foreground\" />\n          <input\n            aria-label=\"Search filters\"\n            className=\"h-10 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/72\"\n            data-filter-row\n            onChange={(e) => setQuery(e.target.value)}\n            placeholder=\"Search filters…\"\n            value={query}\n          />\n        </label>\n        <div className=\"max-h-80 overflow-y-auto p-1.5\">\n          {visible.length === 0 && <p className=\"px-3 py-6 text-center text-muted-foreground text-sm\">No filters match “{query}”.</p>}\n          {visible.map((group) => {\n            const expanded = q ? true : open[group.id];\n            const selected = value[group.id]?.length ?? 0;\n            const panelId = `${baseId}-${group.id}`;\n            return (\n              <div className=\"not-first:mt-0.5\" key={group.id}>\n                <button\n                  aria-controls={panelId}\n                  aria-expanded={expanded}\n                  className=\"flex h-9 w-full cursor-pointer items-center gap-2 rounded-lg px-2 text-start font-medium text-sm outline-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-ring\"\n                  data-filter-row\n                  onClick={() => setOpen((o) => ({ ...o, [group.id]: !expanded }))}\n                  type=\"button\"\n                >\n                  <motion.span animate={{ rotate: expanded ? 0 : -90 }} className=\"flex text-muted-foreground\" transition={spring}>\n                    <ChevronDownIcon className=\"size-4\" />\n                  </motion.span>\n                  <span className=\"flex-1\">{group.label}</span>\n                  {selected > 0 && (\n                    <span className=\"rounded-full bg-accent px-1.5 font-mono text-[11px] text-muted-foreground tabular-nums\">\n                      {selected}\n                    </span>\n                  )}\n                </button>\n                <AnimatePresence initial={false}>\n                  {expanded && (\n                    <motion.div\n                      animate={{ height: \"auto\", opacity: 1 }}\n                      className=\"overflow-hidden\"\n                      exit={{ height: 0, opacity: 0 }}\n                      id={panelId}\n                      initial={reduce ? false : { height: 0, opacity: 0 }}\n                      role=\"group\"\n                      aria-label={group.label}\n                      transition={spring}\n                    >\n                      <div className=\"grid gap-px pb-1 ps-6\">\n                        {group.options.map((option) => {\n                          const checked = value[group.id]?.includes(option.value) ?? false;\n                          return (\n                            <label\n                              className=\"flex h-8 cursor-pointer items-center gap-2.5 rounded-lg px-2 text-sm hover:bg-accent has-focus-visible:bg-accent\"\n                              key={option.value}\n                            >\n                              <Checkbox\n                                checked={checked}\n                                data-filter-row\n                                onCheckedChange={(on) => toggle(group.id, option.value, on)}\n                              />\n                              {option.icon && <span className=\"flex shrink-0\">{option.icon}</span>}\n                              <span className={cn(\"flex-1 truncate\", checked && \"font-medium\")}>{option.label}</span>\n                              {option.count !== undefined && (\n                                <span className=\"text-muted-foreground text-xs tabular-nums\">{option.count}</span>\n                              )}\n                            </label>\n                          );\n                        })}\n                      </div>\n                    </motion.div>\n                  )}\n                </AnimatePresence>\n              </div>\n            );\n          })}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
    }
  ]
}