{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "faceted-filter",
  "type": "registry:ui",
  "title": "Faceted Filter",
  "description": "A filter button whose searchable popover ticks values, counts roll, chips collapse to +N.",
  "categories": [
    "command"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/highlight-match.json"
  ],
  "files": [
    {
      "path": "components/ui/faceted-filter.tsx",
      "content": "\"use client\";\nimport { Combobox } from \"@base-ui/react/combobox\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { HighlightMatch, findMatches } from \"@/components/ui/highlight-match\";\nimport { cn } from \"@/lib/cn\";\nimport { Search } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type FacetOption = {\n  value: string;\n  label: string;\n  /** Decorative: a status glyph, a priority bar, an avatar. Shown in the list only. */\n  icon?: React.ReactNode;\n  /** How many results carry this value under the other active filters. Omit to hide counts. */\n  count?: number;\n  disabled?: boolean;\n};\n\nexport type FacetedFilterProps = {\n  /** The facet's name, on the trigger and in the list's label: \"Status\". */\n  title: string;\n  options: FacetOption[];\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n  /** Chips shown on the trigger before the rest collapse into +N. */\n  maxChips?: number;\n  searchPlaceholder?: string;\n  /** Plural noun for the empty state: \"No statuses match\". Defaults to the title, lowercased. */\n  noun?: string;\n  size?: \"sm\" | \"md\";\n  disabled?: boolean;\n  /** Portal target for the popup. Defaults to document.body. */\n  container?: Combobox.Portal.Props[\"container\"];\n  onOpenChange?: (open: boolean) => void;\n  className?: string;\n};\n\nexport function FacetedFilter({\n  title,\n  options,\n  value,\n  defaultValue = [],\n  onValueChange,\n  maxChips = 2,\n  searchPlaceholder,\n  noun,\n  size = \"sm\",\n  disabled,\n  container,\n  onOpenChange,\n  className,\n}: FacetedFilterProps) {\n  const reduce = !!useReducedMotion();\n  const [inner, setInner] = useState(defaultValue);\n  const controlled = value !== undefined;\n  const ids = controlled ? value : inner;\n  const setIds = (next: string[]) => {\n    if (!controlled) setInner(next);\n    onValueChange?.(next);\n  };\n\n  // Chips follow the list's order, not the order they were ticked, so the trigger reads the same way the list does.\n  const selected = useMemo(() => options.filter((o) => ids.includes(o.value)), [options, ids]);\n  const [query, setQuery] = useState(\"\");\n  const filtered = useMemo(() => (query.trim() ? options.filter((o) => findMatches(o.label, query)) : options), [options, query]);\n  const shown = selected.slice(0, maxChips);\n  const overflow = selected.length - shown.length;\n  const active = selected.length > 0;\n  const hasCounts = options.some((o) => o.count !== undefined);\n\n  const { setList, y, height, opacity } = useGlide(reduce);\n  const { innerRef, width } = useWidth();\n  const statusId = useId();\n  const inputRef = useRef<HTMLInputElement>(null);\n  // Clearing removes the button that was pressed; hand focus back to the search so the popup stays put.\n  const clear = (fn: () => void) => {\n    fn();\n    inputRef.current?.focus();\n  };\n\n  const chipMotion = {\n    initial: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85, filter: \"blur(2px)\" },\n    animate: { opacity: 1, scale: 1, filter: \"blur(0px)\" },\n    exit: reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.85, filter: \"blur(2px)\", transition: { duration: 0.1 } },\n    transition: reduce ? { duration: 0.12 } : { ...spring.snappy, filter: { duration: 0.18, ease: ease.out } }, // blur on a tween: a spring can overshoot below zero\n  };\n\n  return (\n    <Combobox.Root<FacetOption, true>\n      multiple\n      items={options}\n      filteredItems={filtered}\n      autoHighlight\n      value={selected}\n      onValueChange={(next) => setIds(next.map((o) => o.value))}\n      inputValue={query}\n      onInputValueChange={(next, details) => {\n        // Keep the query after a pick, so several matches can be ticked from one search.\n        if (details.reason === \"input-clear\" && details.isItemPress) return details.cancel();\n        setQuery(next);\n      }}\n      onOpenChange={(open) => onOpenChange?.(open)}\n      onOpenChangeComplete={(open) => !open && setQuery(\"\")}\n      isItemEqualToValue={(a, b) => a.value === b.value}\n      itemToStringLabel={(o) => o.label}\n      itemToStringValue={(o) => o.value}\n      disabled={disabled}\n    >\n      <Combobox.Trigger\n        data-active={active || undefined}\n        data-size={size}\n        aria-label={active ? `${title}: ${selected.map((o) => o.label).join(\", \")}` : title}\n        className={cn(\n          \"group/trigger relative inline-flex shrink-0 items-center rounded-md border bg-transparent font-medium text-fg-2 select-none\",\n          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"transition-[background-color,border-color,color,scale] duration-150 ease-out active:scale-[0.97] active:duration-75\",\n          \"border-dashed border-fg-4 hover:border-fg-3 hover:bg-hover hover:text-fg data-popup-open:border-fg-3 data-popup-open:bg-hover data-popup-open:text-fg\",\n          // Once a value is picked the outline turns solid: a filter that is doing something looks set.\n          \"data-active:border-solid data-active:border-line-2 data-active:hover:border-fg-4 data-active:data-popup-open:border-fg-4 data-active:bg-raised data-active:text-fg data-active:shadow-[var(--shadow)] data-active:hover:bg-hover\",\n          \"data-disabled:pointer-events-none data-disabled:opacity-50\",\n          \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-2\",\n          size === \"sm\" ? \"h-7 pr-2 pl-1.5 text-[12.5px]\" : \"h-8 pr-2.5 pl-2 text-[13px]\",\n          className,\n        )}\n      >\n        <PlusCircle active={active} reduce={reduce} />\n        <span className=\"ml-1.5\">{title}</span>\n        <motion.span\n          aria-hidden\n          className=\"flex h-full overflow-hidden\"\n          initial={false}\n          animate={{ width }}\n          transition={reduce ? { duration: 0 } : { duration: 0.24, ease: ease.out }}\n        >\n          <span ref={innerRef} className=\"flex h-full w-max items-center\">\n            {active && <span className=\"mx-2 h-3.5 w-px shrink-0 bg-line-2\" />}\n            <span className=\"flex items-center gap-1\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {shown.map((o) => (\n                  <motion.span key={o.value} layout={!reduce} {...chipMotion} className={chipClass}>\n                    <span className=\"max-w-[9rem] truncate\">{o.label}</span>\n                  </motion.span>\n                ))}\n                {overflow > 0 && (\n                  <motion.span key=\"more\" layout={!reduce} {...chipMotion} className={cn(chipClass, \"text-fg-2\")}>\n                    +<NumberFlow value={overflow} className=\"tabular\" />\n                  </motion.span>\n                )}\n              </AnimatePresence>\n            </span>\n          </span>\n        </motion.span>\n      </Combobox.Trigger>\n\n      <Combobox.Portal container={container}>\n        <Combobox.Positioner align=\"start\" sideOffset={6} collisionPadding={8} className=\"z-(--z-popover) outline-none\">\n          <Combobox.Popup\n            aria-label={`${title} filter`}\n            className={cn(\n              \"flex w-60 max-w-(--available-width) origin-(--transform-origin) flex-col overflow-hidden rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n              \"transition-[opacity,scale] duration-160 ease-out-expo data-ending-style:duration-100 data-ending-style:ease-out\",\n              \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-ending-style:scale-[0.98] data-ending-style:opacity-0\",\n              \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100\",\n            )}\n          >\n            <div className=\"flex h-9 shrink-0 items-center gap-2 border-b border-line px-2.5\">\n              <Search size={14} className=\"shrink-0 text-fg-3\" />\n              <Combobox.Input\n                ref={inputRef}\n                aria-label={searchPlaceholder ?? `Search ${title.toLowerCase()}`}\n                aria-describedby={statusId}\n                placeholder={searchPlaceholder ?? title}\n                onKeyDown={(e) => {\n                  // Backspace in an empty search takes back the last value ticked.\n                  if (e.key === \"Backspace\" && query === \"\" && ids.length) setIds(ids.slice(0, -1));\n                }}\n                className=\"h-full min-w-0 flex-1 bg-transparent text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]\"\n              />\n            </div>\n\n            <Combobox.Empty className=\"empty:hidden\">\n              <div className=\"flex flex-col items-center gap-2 px-3 py-5 text-center text-[12.5px] text-fg-3\">\n                <p className=\"max-w-full truncate\">\n                  {options.length ? (\n                    <>\n                      No {noun ?? title.toLowerCase()} match <span className=\"text-fg-2\">“{query.trim()}”</span>\n                    </>\n                  ) : (\n                    <>Nothing to filter by yet</>\n                  )}\n                </p>\n                {query && (\n                  <button type=\"button\" onClick={() => clear(() => setQuery(\"\"))} className={smallButton}>\n                    Clear search\n                  </button>\n                )}\n              </div>\n            </Combobox.Empty>\n\n            <Combobox.List ref={setList} className=\"relative max-h-[min(calc(var(--available-height)-6rem),17rem)] scroll-py-1 overflow-y-auto overscroll-contain p-1 outline-none empty:hidden\">\n              <motion.div aria-hidden style={{ y, height, opacity }} className=\"pointer-events-none absolute inset-x-1 top-0 rounded-lg bg-line\" />\n              <Combobox.Collection>\n                {(o: FacetOption) => (\n                  <Combobox.Item key={o.value} value={o} disabled={o.disabled} className={itemClass}>\n                    <Box on={ids.includes(o.value)} reduce={reduce} />\n                    {o.icon && <span className=\"flex shrink-0 text-fg-3 [&_svg]:size-3.5\">{o.icon}</span>}\n                    <HighlightMatch text={o.label} query={query} className=\"min-w-0 flex-1 truncate\" />\n                    {hasCounts && (\n                      <span className={cn(\"shrink-0 font-mono text-[11px] text-fg-3 tabular\", o.count === 0 && \"text-fg-4\")}>\n                        {o.count !== undefined && <NumberFlow value={o.count} />}\n                      </span>\n                    )}\n                  </Combobox.Item>\n                )}\n              </Combobox.Collection>\n            </Combobox.List>\n\n            <AnimatePresence initial={false}>\n              {active && (\n                <motion.div\n                  key=\"footer\"\n                  initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n                  animate={{ height: \"auto\", opacity: 1 }}\n                  exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0, transition: { duration: 0.14, ease: ease.out } }}\n                  transition={{ duration: 0.2, ease: ease.out }}\n                  className=\"shrink-0 overflow-hidden\"\n                >\n                  <div className=\"flex h-9 items-center justify-between gap-2 border-t border-line pr-1.5 pl-3 text-[12px] text-fg-3\">\n                    <span className=\"flex items-center gap-1 tabular\">\n                      <NumberFlow value={selected.length} className=\"text-fg-2\" /> selected\n                    </span>\n                    <button type=\"button\" onClick={() => clear(() => setIds([]))} className={smallButton}>\n                      Clear filter\n                    </button>\n                  </div>\n                </motion.div>\n              )}\n            </AnimatePresence>\n            <span id={statusId} className=\"sr-only\">\n              {active ? `${selected.length} selected` : \"None selected\"}\n            </span>\n          </Combobox.Popup>\n        </Combobox.Positioner>\n      </Combobox.Portal>\n    </Combobox.Root>\n  );\n}\n\nconst chipClass = \"flex h-5 min-w-0 shrink-0 items-center rounded-[4px] bg-line px-1.5 text-[11.5px] leading-none font-normal text-fg\";\n\nconst smallButton = cn(\n  \"h-6 rounded-md px-2 text-[12px] font-medium text-fg-2 hover:bg-hover hover:text-fg\",\n  \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n  \"transition-[background-color,color,scale] duration-150 active:scale-[0.96]\",\n);\n\nconst itemClass = cn(\n  \"group/item relative z-[1] flex h-8 cursor-default scroll-my-1 items-center gap-2.5 rounded-lg px-2 text-[13px] text-fg outline-none select-none pointer-coarse:h-10\",\n  \"data-disabled:text-fg-4 data-disabled:[&_*]:text-fg-4\",\n);\n\n// The plus turns a quarter and fades into a filled dot once the filter is active, so a filter\n// that is doing something reads as set from across the toolbar.\nfunction PlusCircle({ active, reduce }: { active: boolean; reduce: boolean }) {\n  return (\n    <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" aria-hidden className=\"shrink-0 text-fg-3 group-hover/trigger:text-fg-2 group-data-active/trigger:text-fg\">\n      <circle cx=\"8\" cy=\"8\" r=\"5.75\" />\n      <motion.g\n        initial={false}\n        animate={active ? { rotate: 45, opacity: 0, scale: 0.6 } : { rotate: 0, opacity: 1, scale: 1 }}\n        transition={reduce ? { duration: 0 } : spring.pop}\n        style={{ originX: \"8px\", originY: \"8px\" }}\n      >\n        <path d=\"M8 5.5v5M5.5 8h5\" />\n      </motion.g>\n      <motion.circle\n        cx=\"8\"\n        cy=\"8\"\n        r=\"2.25\"\n        fill=\"currentColor\"\n        stroke=\"none\"\n        initial={false}\n        animate={active ? { scale: 1, opacity: 1 } : { scale: 0, opacity: 0 }}\n        transition={reduce ? { duration: 0 } : spring.pop}\n        style={{ originX: \"8px\", originY: \"8px\" }}\n      />\n    </svg>\n  );\n}\n\n// A checkbox that squashes on press and draws its tick when it turns on.\nfunction Box({ on, reduce }: { on: boolean; reduce: boolean }) {\n  return (\n    <span\n      aria-hidden\n      data-state={on ? \"checked\" : \"unchecked\"}\n      className={cn(\n        \"grid size-3.5 shrink-0 place-items-center rounded-[4px] border transition-[background-color,border-color,scale] duration-150 ease-out group-active/item:scale-[0.85]\",\n        on ? \"border-fg bg-fg text-frame\" : \"border-line-2 bg-frame group-data-highlighted/item:border-fg-4\",\n      )}\n    >\n      <svg width=\"10\" height=\"10\" viewBox=\"0 0 10 10\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n        <motion.path\n          d=\"M2 5.2 4.1 7.2 8 2.8\"\n          initial={false}\n          animate={{ pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }}\n          transition={reduce ? { duration: 0 } : { pathLength: { duration: 0.2, ease: ease.out }, opacity: { duration: 0.05 } }}\n        />\n      </svg>\n    </span>\n  );\n}\n\n// The chip strip's natural width, so the trigger can tween to it instead of snapping.\nfunction useWidth() {\n  const innerRef = useRef<HTMLSpanElement>(null);\n  const [width, setWidth] = useState<number | \"auto\">(\"auto\");\n  useEffect(() => {\n    const el = innerRef.current;\n    if (!el) return;\n    const observer = new ResizeObserver(([entry]) => setWidth(entry.borderBoxSize?.[0]?.inlineSize ?? el.offsetWidth));\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n  return { innerRef, width };\n}\n\n// One highlight for the list: glides after the pointer, jumps for arrow keys.\nfunction useGlide(reduce: boolean) {\n  const [list, setList] = useState<HTMLDivElement | null>(null);\n  const y = useMotionValue(0);\n  const height = useMotionValue(32);\n  const opacity = useMotionValue(0);\n\n  useEffect(() => {\n    if (!list) return;\n    let keyboard = false;\n    let shown = false;\n    let running: AnimationPlaybackControls[] = [];\n    const stop = () => {\n      running.forEach((c) => c.stop());\n      running = [];\n    };\n    const place = () => {\n      const el = list.querySelector<HTMLElement>(\"[data-highlighted]\");\n      stop();\n      if (!el) {\n        shown = false;\n        running.push(animate(opacity, 0, { duration: reduce ? 0 : 0.12 }));\n        return;\n      }\n      const target = el.hasAttribute(\"data-disabled\") ? 0.45 : 1;\n      if (!shown || keyboard || reduce) {\n        y.jump(el.offsetTop);\n        height.jump(el.offsetHeight);\n        if (keyboard || reduce) opacity.jump(target);\n        else running.push(animate(opacity, target, { duration: 0.08 }));\n      } else {\n        running.push(animate(y, el.offsetTop, spring.follow), animate(height, el.offsetHeight, spring.follow));\n        opacity.jump(target);\n      }\n      shown = true;\n    };\n    const onKey = () => (keyboard = true);\n    const onPointer = () => (keyboard = false);\n    const observer = new MutationObserver(place);\n    observer.observe(list, { subtree: true, childList: true, attributes: true, attributeFilter: [\"data-highlighted\"] });\n    document.addEventListener(\"keydown\", onKey, true);\n    list.addEventListener(\"pointermove\", onPointer);\n    place();\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"keydown\", onKey, true);\n      list.removeEventListener(\"pointermove\", onPointer);\n      stop();\n    };\n  }, [list, reduce, y, height, opacity]);\n\n  return { setList, y, height, opacity };\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/faceted-filter"
}
