{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "active-filters",
  "type": "registry:ui",
  "title": "Active Filters",
  "description": "Applied filters as removable chips that close ranks, fold to +N and undo.",
  "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/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/active-filters.tsx",
      "content": "\"use client\";\nimport { Toolbar } from \"@base-ui/react/toolbar\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Undo, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type ActiveFilter = {\n  id: string;\n  /** What is being filtered: \"Status\". */\n  field: string;\n  /** Defaults to \"is\". */\n  operator?: string;\n  /** One value, or several: [\"Paid\", \"Refunded\"]. */\n  value: string | string[];\n  /** Decorative, before the field name. */\n  icon?: React.ReactNode;\n};\n\nexport type ActiveFiltersProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\" | \"children\"> & {\n  value?: ActiveFilter[];\n  defaultValue?: ActiveFilter[];\n  onValueChange?: (filters: ActiveFilter[]) => void;\n  /** Makes each chip's body a button, e.g. to reopen the filter's editor. */\n  onEdit?: (filter: ActiveFilter, trigger: HTMLElement) => void;\n  /** \"collapse\" keeps one line and folds the rest into +N; \"wrap\" shows every chip. */\n  overflow?: \"collapse\" | \"wrap\";\n  /** Show Clear all when there are two or more filters. */\n  clearable?: boolean;\n  /** Milliseconds the Undo offer stays after Clear all. 0 turns it off. */\n  undoTimeout?: number;\n  /** Shown in place of the chips when nothing is applied, holding the row's height. */\n  empty?: React.ReactNode;\n  \"aria-label\"?: string;\n};\n\nconst summary = (f: ActiveFilter) => `${f.field} ${f.operator ?? \"is\"} ${[f.value].flat().join(\", \")}`;\n\nexport function ActiveFilters({\n  value,\n  defaultValue = [],\n  onValueChange,\n  onEdit,\n  overflow = \"collapse\",\n  clearable = true,\n  undoTimeout = 6000,\n  empty = \"No filters applied\",\n  \"aria-label\": ariaLabel = \"Active filters\",\n  className,\n  ...rest\n}: ActiveFiltersProps) {\n  const reduce = !!useReducedMotion();\n  const [filters, setFilters] = useControllableState({ value, defaultValue, onChange: onValueChange });\n  const [expanded, setExpanded] = useState(false);\n  const [cleared, setCleared] = useState<ActiveFilter[] | null>(null);\n  const [announce, setAnnounce] = useState(\"\");\n  const rootRef = useRef<HTMLDivElement>(null);\n  const chipRefs = useRef(new Map<string, HTMLElement>());\n  const pendingFocus = useRef<{ id: string | null; part: \"edit\" | \"remove\" } | null>(null);\n  const undoRef = useRef<HTMLButtonElement>(null);\n\n  const { rowRef, measureRef, fit } = useFit(filters.map((f) => f.id + summary(f)).join(\"|\"), overflow === \"collapse\" && !expanded);\n  const collapsed = overflow === \"collapse\" && !expanded;\n  const shown = collapsed ? filters.slice(0, fit) : filters;\n  const hidden = filters.length - shown.length;\n\n  // The Undo offer lapses on its own, and the moment the filters change from anywhere else.\n  useEffect(() => {\n    if (!cleared || !undoTimeout) return;\n    const t = window.setTimeout(() => setCleared(null), undoTimeout);\n    return () => window.clearTimeout(t);\n  }, [cleared, undoTimeout]);\n  const liveCleared = cleared && filters.length === 0 ? cleared : null;\n\n  // After a removal, focus lands on the neighbor that slid into its place.\n  useLayoutEffect(() => {\n    const target = pendingFocus.current;\n    if (!target) return;\n    pendingFocus.current = null;\n    if (target.id === \"undo\") return undoRef.current?.focus();\n    if (target.id === \"more\") return rootRef.current?.querySelector<HTMLElement>(\"[data-more]\")?.focus();\n    const chip = target.id ? chipRefs.current.get(target.id) : null;\n    const el = chip?.querySelector<HTMLElement>(`[data-part=\"${target.part}\"]`) ?? chip?.querySelector<HTMLElement>(\"button\");\n    (el ?? rootRef.current)?.focus();\n  });\n\n  const remove = (id: string) => {\n    const index = filters.findIndex((f) => f.id === id);\n    if (index < 0) return;\n    const chip = chipRefs.current.get(id);\n    const owned = !!chip?.contains(document.activeElement);\n    const next = filters.filter((f) => f.id !== id);\n    if (owned) {\n      const part = (document.activeElement as HTMLElement | null)?.dataset.part === \"edit\" ? \"edit\" : \"remove\";\n      const neighbor = next[Math.min(index, next.length - 1)]?.id ?? null;\n      // If the neighbor is folded into +N it isn't on screen; fall back to the last visible chip.\n      const visible = collapsed ? next.slice(0, Math.max(1, fit - 1)) : next;\n      pendingFocus.current = { id: visible.some((f) => f.id === neighbor) ? neighbor : (visible[visible.length - 1]?.id ?? null), part };\n    }\n    setFilters(next);\n    setAnnounce(`Removed ${summary(filters[index])}`);\n  };\n\n  const clearAll = () => {\n    const had = filters;\n    setFilters([]);\n    setExpanded(false);\n    if (undoTimeout) {\n      setCleared(had);\n      pendingFocus.current = { id: \"undo\", part: \"remove\" };\n    } else pendingFocus.current = { id: null, part: \"remove\" };\n    setAnnounce(`Cleared ${had.length} ${had.length === 1 ? \"filter\" : \"filters\"}`);\n  };\n\n  const undo = () => {\n    if (!liveCleared) return;\n    setFilters(liveCleared);\n    setCleared(null);\n    pendingFocus.current = { id: liveCleared[liveCleared.length - 1]?.id ?? null, part: \"remove\" };\n    setAnnounce(`Restored ${liveCleared.length} ${liveCleared.length === 1 ? \"filter\" : \"filters\"}`);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent, id: string) => {\n    if (e.key === \"Backspace\" || e.key === \"Delete\") {\n      e.preventDefault();\n      remove(id);\n    }\n  };\n\n  const chipMotion = {\n    initial: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, 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.9, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.out } },\n    transition: reduce ? { duration: 0.14 } : { ...spring.snappy, filter: { duration: 0.18, ease: ease.out } }, // blur on a tween: a spring can overshoot below zero\n  };\n\n  const showClear = clearable && filters.length > 1;\n  const showLess = overflow === \"collapse\" && expanded && filters.length > 0;\n\n  return (\n    <div ref={rootRef} tabIndex={-1} className={cn(\"relative min-w-0 outline-none\", className)} {...rest}>\n      <Toolbar.Root\n        ref={rowRef}\n        aria-label={ariaLabel}\n        className={cn(\"flex min-h-7 min-w-0 items-center gap-1.5\", collapsed ? \"-mx-1 flex-nowrap overflow-x-clip px-1\" : \"flex-wrap\")}\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {shown.map((f, i) => (\n            <motion.div\n              key={f.id}\n              ref={(el: HTMLDivElement | null) => {\n                if (el) chipRefs.current.set(f.id, el);\n                else chipRefs.current.delete(f.id);\n              }}\n              layout={!reduce}\n              {...chipMotion}\n              className={cn(\"flex min-w-0\", i === 0 ? \"shrink\" : \"shrink-0\")}\n            >\n              <Chip filter={f} onEdit={onEdit} onRemove={() => remove(f.id)} onKeyDown={(e) => onKeyDown(e, f.id)} />\n            </motion.div>\n          ))}\n\n          {hidden > 0 && (\n            <motion.div key=\"more\" layout={!reduce} {...chipMotion} className=\"flex shrink-0\">\n              <Toolbar.Button\n                data-more=\"\"\n                onClick={() => {\n                  // Focus moves to the first chip that was folded away, which is what they came for.\n                  pendingFocus.current = { id: filters[shown.length]?.id ?? null, part: onEdit ? \"edit\" : \"remove\" };\n                  setExpanded(true);\n                }}\n                aria-label={`Show ${hidden} more ${hidden === 1 ? \"filter\" : \"filters\"}`}\n                className={cn(ghostButton, \"border border-dashed border-line-2 px-2 text-fg-2 tabular\")}\n              >\n                +<NumberFlow value={hidden} />\n              </Toolbar.Button>\n            </motion.div>\n          )}\n\n          {(showClear || showLess) && (\n            // Trailing actions sit together at the far end, so leftover space reads as intended,\n            // and they wrap as one unit instead of stranding Clear all on a line of its own.\n            <motion.div key=\"actions\" layout={!reduce} {...chipMotion} className=\"ml-auto flex shrink-0 items-center gap-0.5\">\n              {showLess && (\n                <Toolbar.Button\n                  onClick={() => {\n                    pendingFocus.current = { id: \"more\", part: \"remove\" };\n                    setExpanded(false);\n                  }}\n                  className={cn(ghostButton, \"px-2 text-fg-3\")}>\n                  Show less\n                </Toolbar.Button>\n              )}\n              {showClear && (\n                <Toolbar.Button onClick={clearAll} className={cn(ghostButton, \"px-2 text-fg-3\")}>\n                  Clear all\n                </Toolbar.Button>\n              )}\n            </motion.div>\n          )}\n\n          {filters.length === 0 && (\n            <motion.div\n              key={liveCleared ? \"undo\" : \"empty\"}\n              layout={!reduce}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={{ duration: 0.2, ease: ease.out }}\n              className=\"flex h-7 min-w-0 shrink-0 items-center gap-2 text-[12.5px]\"\n            >\n              {liveCleared ? (\n                <>\n                  <span className=\"text-fg-3\">\n                    Cleared {liveCleared.length} {liveCleared.length === 1 ? \"filter\" : \"filters\"}\n                  </span>\n                  <Toolbar.Button ref={undoRef} onClick={undo} className={cn(ghostButton, \"gap-1 px-1.5 text-fg\")}>\n                    <Undo size={14} />\n                    Undo\n                  </Toolbar.Button>\n                </>\n              ) : (\n                <span className=\"text-fg-4\">{empty}</span>\n              )}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </Toolbar.Root>\n\n      {/* Every chip laid out invisibly on one line, so the row knows how many fit before showing them. */}\n      {overflow === \"collapse\" && (\n        <div ref={measureRef} aria-hidden inert className=\"pointer-events-none invisible absolute top-0 left-0 flex h-0 items-start gap-1.5 overflow-visible\">\n          {filters.map((f) => (\n            <div key={f.id} className=\"flex shrink-0\">\n              <Chip filter={f} onEdit={onEdit} />\n            </div>\n          ))}\n          <div data-measure=\"more\" className={cn(ghostButton, \"border border-dashed px-2 tabular\")}>+{filters.length}</div>\n          <div data-measure=\"clear\" className={cn(ghostButton, \"px-2\")}>Clear all</div>\n        </div>\n      )}\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n\nconst ghostButton = cn(\n  \"inline-flex h-7 shrink-0 items-center rounded-md text-[12.5px] font-medium whitespace-nowrap 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 hover:bg-hover hover:text-fg active:scale-[0.96] active:duration-75\",\n  \"pointer-coarse:relative pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-2\",\n);\n\ntype ChipProps = {\n  filter: ActiveFilter;\n  onEdit?: (filter: ActiveFilter, trigger: HTMLElement) => void;\n  onRemove?: () => void;\n  onKeyDown?: (e: React.KeyboardEvent) => void;\n};\n\nfunction Chip({ filter, onEdit, onRemove, onKeyDown }: ChipProps) {\n  const values = [filter.value].flat();\n  const text = summary(filter);\n  const body = (\n    <>\n      {filter.icon && <span className=\"flex shrink-0 text-fg-3 [&_svg]:size-3.5\">{filter.icon}</span>}\n      <span className=\"shrink-0 text-fg-2\">{filter.field}</span>\n      <span className=\"shrink-0 text-fg-3\">{filter.operator ?? \"is\"}</span>\n      <span className=\"min-w-0 truncate font-medium text-fg\">{values.slice(0, 2).join(\", \")}</span>\n      {values.length > 2 && <span className=\"shrink-0 text-fg-3 tabular\">+{values.length - 2}</span>}\n    </>\n  );\n  const measuring = !onRemove;\n\n  return (\n    <div\n      data-chip=\"\"\n      title={values.length > 2 ? text : undefined}\n      className=\"group/chip flex h-7 max-w-[18rem] min-w-0 items-center rounded-md border border-line-2 bg-raised text-[12.5px] shadow-[var(--shadow)] transition-[border-color] duration-150 hover:border-fg-4\"\n    >\n      {onEdit && !measuring ? (\n        <Toolbar.Button\n          data-part=\"edit\"\n          aria-label={`Edit filter: ${text}`}\n          onClick={(e) => onEdit(filter, e.currentTarget)}\n          onKeyDown={onKeyDown}\n          className=\"flex h-full min-w-0 items-center gap-1.5 rounded-l-[5px] pr-1.5 pl-2 outline-none transition-[background-color,opacity] duration-150 group-has-[[data-part=remove]:hover]/chip:opacity-50 hover:bg-hover focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\"\n        >\n          {body}\n        </Toolbar.Button>\n      ) : (\n        // Hovering the × dims the chip it will take away, so the target is never in doubt.\n        <span className=\"flex h-full min-w-0 items-center gap-1.5 pr-1.5 pl-2 transition-opacity duration-150 group-has-[[data-part=remove]:hover]/chip:opacity-50\">{body}</span>\n      )}\n      {measuring ? (\n        <span className=\"h-full w-6 shrink-0 border-l border-line\" />\n      ) : (\n        <Toolbar.Button\n          data-part=\"remove\"\n          aria-label={`Remove filter: ${text}`}\n          onClick={onRemove}\n          onKeyDown={onKeyDown}\n          className={cn(\n            \"relative grid h-full w-6 shrink-0 place-items-center rounded-r-[5px] border-l border-line text-fg-3\",\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,color] duration-150 hover:bg-hover hover:text-fg active:[&>svg]:scale-[0.8]\",\n            \"pointer-coarse:after:absolute pointer-coarse:after:-inset-2\",\n          )}\n        >\n          <X size={12} className=\"transition-transform duration-100 ease-out\" />\n        </Toolbar.Button>\n      )}\n    </div>\n  );\n}\n\n// How many chips fit on one line beside the +N and Clear all buttons, measured from the hidden copy.\nfunction useFit(key: string, enabled: boolean) {\n  const rowRef = useRef<HTMLDivElement>(null);\n  const measureRef = useRef<HTMLDivElement>(null);\n  const [fit, setFit] = useState(Infinity);\n\n  useEffect(() => {\n    const row = rowRef.current;\n    const measure = measureRef.current;\n    if (!enabled || !row || !measure) return;\n    let frame = 0;\n    const compute = () => {\n      const chips = [...measure.children].filter((c) => !(c as HTMLElement).dataset.measure) as HTMLElement[];\n      const more = measure.querySelector<HTMLElement>(\"[data-measure=more]\")?.offsetWidth ?? 0;\n      const clear = chips.length > 1 ? (measure.querySelector<HTMLElement>(\"[data-measure=clear]\")?.offsetWidth ?? 0) : 0;\n      const gap = 6;\n      const available = row.parentElement?.clientWidth ?? row.clientWidth;\n      let used = clear ? clear + gap : 0;\n      let n = 0;\n      for (let i = 0; i < chips.length; i++) {\n        const w = chips[i].offsetWidth + (i ? gap : 0);\n        const reserve = i < chips.length - 1 ? gap + more : 0;\n        if (used + w + reserve > available) break;\n        used += w;\n        n = i + 1;\n      }\n      // At least one chip always shows (it truncates), so the row never reads as empty.\n      setFit(Math.max(1, n));\n    };\n    const schedule = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(compute);\n    };\n    const observer = new ResizeObserver(schedule);\n    if (row.parentElement) observer.observe(row.parentElement);\n    observer.observe(measure);\n    schedule();\n    return () => {\n      observer.disconnect();\n      cancelAnimationFrame(frame);\n    };\n  }, [key, enabled]);\n\n  return { rowRef, measureRef, fit: enabled ? fit : Infinity };\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/active-filters"
}
