{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "filter-bar",
  "title": "Filter Bar",
  "description": "A faceted filter bar with searchable multi-select popovers, count badges, and active filters that spring in and out as chips.",
  "dependencies": ["framer-motion"],
  "registryDependencies": ["@godui/godui-theme"],
  "files": [
    {
      "path": "packages/components/src/filter-bar/filter-bar.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"framer-motion\";\nimport * as React from \"react\";\n\nexport type FilterOption = {\n  label: string;\n  value: string;\n  count?: number;\n};\n\nexport type Facet = {\n  id: string;\n  label: string;\n  options: FilterOption[];\n};\n\nexport type FilterValue = Record<string, string[]>;\n\nexport type FilterBarProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"onChange\" | \"defaultValue\"\n> & {\n  facets: Facet[];\n  value?: FilterValue;\n  defaultValue?: FilterValue;\n  onChange?: (value: FilterValue) => void;\n  /** Show a search box inside each facet popover. */\n  searchable?: boolean;\n  /** Show option counts. */\n  showCounts?: boolean;\n};\n\nconst CloseIcon = (\n  <svg\n    aria-hidden=\"true\"\n    viewBox=\"0 0 24 24\"\n    className=\"h-3.5 w-3.5\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"2.5\"\n    strokeLinecap=\"round\"\n  >\n    <path d=\"M6 6l12 12M18 6 6 18\" />\n  </svg>\n);\n\nconst PlusIcon = (\n  <svg\n    aria-hidden=\"true\"\n    viewBox=\"0 0 24 24\"\n    className=\"h-4 w-4\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"2\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n  >\n    <path d=\"M12 5v14M5 12h14\" />\n  </svg>\n);\n\nconst FilterBar = React.forwardRef<HTMLDivElement, FilterBarProps>(\n  (\n    {\n      facets,\n      value: valueProp,\n      defaultValue,\n      onChange,\n      searchable = true,\n      showCounts = true,\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const reduceMotion = useReducedMotion();\n    const isControlled = valueProp !== undefined;\n    const [internal, setInternal] = React.useState<FilterValue>(\n      () => defaultValue ?? {},\n    );\n    const value = isControlled ? valueProp : internal;\n    const [open, setOpen] = React.useState<string | null>(null);\n    const [query, setQuery] = React.useState(\"\");\n    const rootRef = React.useRef<HTMLDivElement>(null);\n\n    React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);\n\n    React.useEffect(() => {\n      if (!open) return;\n      const onDown = (e: MouseEvent) => {\n        if (rootRef.current && !rootRef.current.contains(e.target as Node)) {\n          setOpen(null);\n        }\n      };\n      const onKey = (e: KeyboardEvent) => {\n        if (e.key === \"Escape\") setOpen(null);\n      };\n      document.addEventListener(\"mousedown\", onDown);\n      document.addEventListener(\"keydown\", onKey);\n      return () => {\n        document.removeEventListener(\"mousedown\", onDown);\n        document.removeEventListener(\"keydown\", onKey);\n      };\n    }, [open]);\n\n    const commit = (next: FilterValue) => {\n      if (!isControlled) setInternal(next);\n      onChange?.(next);\n    };\n\n    const toggleOption = (facetId: string, optValue: string) => {\n      const current = value[facetId] ?? [];\n      const has = current.includes(optValue);\n      const nextList = has\n        ? current.filter((v) => v !== optValue)\n        : [...current, optValue];\n      const next = { ...value, [facetId]: nextList };\n      if (nextList.length === 0) delete next[facetId];\n      commit(next);\n    };\n\n    const clearFacet = (facetId: string) => {\n      const next = { ...value };\n      delete next[facetId];\n      commit(next);\n    };\n\n    const clearAll = () => commit({});\n\n    const spring = reduceMotion\n      ? { duration: 0 }\n      : ({ type: \"spring\", stiffness: 520, damping: 32 } as const);\n\n    const activeCount = Object.values(value).reduce(\n      (sum, list) => sum + list.length,\n      0,\n    );\n\n    const summarize = (facet: Facet, selected: string[]) => {\n      const first = facet.options.find((o) => o.value === selected[0]);\n      const firstLabel = first?.label ?? selected[0];\n      return selected.length > 1\n        ? `${firstLabel} +${selected.length - 1}`\n        : firstLabel;\n    };\n\n    return (\n      <motion.div\n        ref={rootRef}\n        layout\n        className={`flex flex-wrap items-center gap-2 ${className ?? \"\"}`}\n        {...(props as React.ComponentProps<typeof motion.div>)}\n      >\n        {facets.map((facet) => {\n          const selected = value[facet.id] ?? [];\n          const hasSelection = selected.length > 0;\n          const isOpen = open === facet.id;\n          const filtered = facet.options.filter((o) =>\n            o.label.toLowerCase().includes(query.toLowerCase()),\n          );\n          return (\n            <motion.div key={facet.id} layout className=\"relative\">\n              <div\n                className={`group flex items-center rounded-full border [transition:border-color_150ms_ease,background-color_150ms_ease] ${\n                  hasSelection\n                    ? \"border-foreground/15 bg-accent\"\n                    : \"border-dashed border-border bg-background hover:border-foreground/25 hover:bg-accent/50\"\n                }`}\n              >\n                <button\n                  type=\"button\"\n                  aria-expanded={isOpen}\n                  aria-haspopup=\"listbox\"\n                  onClick={() => {\n                    setQuery(\"\");\n                    setOpen(isOpen ? null : facet.id);\n                  }}\n                  className=\"flex items-center gap-1.5 rounded-full py-1.5 pr-2 pl-3 font-medium text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                >\n                  {!hasSelection && (\n                    <span className=\"text-muted-foreground\">{PlusIcon}</span>\n                  )}\n                  <span\n                    className={\n                      hasSelection ? \"text-foreground\" : \"text-muted-foreground\"\n                    }\n                  >\n                    {facet.label}\n                  </span>\n                  <AnimatePresence initial={false}>\n                    {hasSelection && (\n                      <motion.span\n                        initial={\n                          reduceMotion\n                            ? { opacity: 0 }\n                            : { opacity: 0, width: 0 }\n                        }\n                        animate={{ opacity: 1, width: \"auto\" }}\n                        exit={\n                          reduceMotion\n                            ? { opacity: 0 }\n                            : { opacity: 0, width: 0 }\n                        }\n                        transition={\n                          reduceMotion\n                            ? { duration: 0 }\n                            : { duration: 0.2, ease: \"easeOut\" }\n                        }\n                        className=\"flex items-center gap-1.5 overflow-hidden\"\n                      >\n                        <span className=\"h-3.5 w-px bg-border\" />\n                        <span className=\"whitespace-nowrap text-foreground\">\n                          {summarize(facet, selected)}\n                        </span>\n                      </motion.span>\n                    )}\n                  </AnimatePresence>\n                </button>\n                <AnimatePresence>\n                  {hasSelection && (\n                    <motion.button\n                      type=\"button\"\n                      initial={\n                        reduceMotion\n                          ? { opacity: 0 }\n                          : { opacity: 0, scale: 0.6 }\n                      }\n                      animate={{ opacity: 1, scale: 1 }}\n                      exit={\n                        reduceMotion\n                          ? { opacity: 0 }\n                          : { opacity: 0, scale: 0.6 }\n                      }\n                      transition={spring}\n                      onClick={() => clearFacet(facet.id)}\n                      aria-label={`Clear ${facet.label}`}\n                      className=\"mr-1 flex h-6 w-6 items-center justify-center rounded-full text-muted-foreground [transition:background-color_150ms_ease,color_150ms_ease] hover:bg-foreground/10 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n                    >\n                      {CloseIcon}\n                    </motion.button>\n                  )}\n                </AnimatePresence>\n              </div>\n\n              <AnimatePresence>\n                {isOpen && (\n                  <motion.div\n                    role=\"listbox\"\n                    initial={\n                      reduceMotion\n                        ? { opacity: 0 }\n                        : { opacity: 0, scale: 0.96, y: -4 }\n                    }\n                    animate={{ opacity: 1, scale: 1, y: 0 }}\n                    exit={\n                      reduceMotion\n                        ? { opacity: 0 }\n                        : { opacity: 0, scale: 0.96, y: -4 }\n                    }\n                    transition={spring}\n                    className=\"absolute top-full left-0 z-popover mt-2 w-60 origin-top-left rounded-xl border border-border bg-background p-1.5 shadow-xl\"\n                  >\n                    {searchable && (\n                      <input\n                        // biome-ignore lint/a11y/noAutofocus: focus the search when the facet popover opens\n                        autoFocus\n                        value={query}\n                        onChange={(e) => setQuery(e.target.value)}\n                        placeholder={`Search ${facet.label.toLowerCase()}…`}\n                        className=\"mb-1 w-full rounded-lg border border-border bg-muted px-3 py-1.5 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n                      />\n                    )}\n                    <ul className=\"max-h-60 overflow-y-auto\">\n                      {filtered.length === 0 && (\n                        <li className=\"px-3 py-2 text-muted-foreground text-sm\">\n                          No matches\n                        </li>\n                      )}\n                      {filtered.map((opt) => {\n                        const checked = selected.includes(opt.value);\n                        return (\n                          <li key={opt.value}>\n                            <button\n                              type=\"button\"\n                              role=\"option\"\n                              aria-selected={checked}\n                              onClick={() => toggleOption(facet.id, opt.value)}\n                              className=\"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm [transition:background-color_150ms_ease] hover:bg-accent\"\n                            >\n                              <span\n                                className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border [transition:background-color_150ms_ease,border-color_150ms_ease] ${\n                                  checked\n                                    ? \"border-foreground bg-foreground text-background\"\n                                    : \"border-border\"\n                                }`}\n                              >\n                                {checked && (\n                                  <svg\n                                    aria-hidden=\"true\"\n                                    viewBox=\"0 0 24 24\"\n                                    className=\"h-3 w-3\"\n                                    fill=\"none\"\n                                    stroke=\"currentColor\"\n                                    strokeWidth=\"3.5\"\n                                    strokeLinecap=\"round\"\n                                    strokeLinejoin=\"round\"\n                                  >\n                                    <path d=\"M20 6 9 17l-5-5\" />\n                                  </svg>\n                                )}\n                              </span>\n                              <span className=\"flex-1 text-foreground\">\n                                {opt.label}\n                              </span>\n                              {showCounts && opt.count !== undefined && (\n                                <span className=\"text-muted-foreground text-xs tabular-nums\">\n                                  {opt.count}\n                                </span>\n                              )}\n                            </button>\n                          </li>\n                        );\n                      })}\n                    </ul>\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </motion.div>\n          );\n        })}\n\n        <AnimatePresence>\n          {activeCount > 0 && (\n            <motion.button\n              type=\"button\"\n              layout\n              initial={\n                reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.9 }\n              }\n              animate={{ opacity: 1, scale: 1 }}\n              exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}\n              transition={spring}\n              onClick={clearAll}\n              className=\"rounded-full px-2.5 py-1.5 font-medium text-muted-foreground text-sm underline-offset-4 [transition:color_150ms_ease] hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring\"\n            >\n              Clear all\n            </motion.button>\n          )}\n        </AnimatePresence>\n      </motion.div>\n    );\n  },\n);\nFilterBar.displayName = \"FilterBar\";\n\nexport { FilterBar };\n",
      "type": "registry:ui",
      "target": "components/godui/filter-bar.tsx"
    }
  ],
  "type": "registry:ui"
}
