{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "filter-grid",
  "type": "registry:ui",
  "title": "Filter Grid",
  "description": "Card grid filtered by chips and search that reflows instead of reshuffling.",
  "categories": [
    "tables"
  ],
  "dependencies": [
    "@base-ui/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/filter-grid.tsx",
      "content": "\"use client\";\nimport { Toggle } from \"@base-ui/react/toggle\";\nimport { ToggleGroup } from \"@base-ui/react/toggle-group\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Search, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type FilterGridCategory = { value: string; label: string };\n\nconst ALL = \"__all\";\nconst normalize = (s: string) => s.toLocaleLowerCase().normalize(\"NFD\").replace(/\\p{Diacritic}/gu, \"\");\n\n/* -------------------------------------------------------------------------------------------------\n * Headless filtering\n * -----------------------------------------------------------------------------------------------*/\n\nexport type UseFilterGridOptions<T> = {\n  items: T[];\n  /** One category or several. */\n  getCategory: (item: T) => string | string[];\n  /** Everything a search should match: name, description, tags. */\n  getText: (item: T) => string;\n  category?: string | null;\n  defaultCategory?: string | null;\n  onCategoryChange?: (category: string | null) => void;\n  query?: string;\n  defaultQuery?: string;\n  onQueryChange?: (query: string) => void;\n};\n\n/** Search and category filtering, with per-category counts that follow the search. */\nexport function useFilterGrid<T>({ items, getCategory, getText, category: catProp, defaultCategory = null, onCategoryChange, query: queryProp, defaultQuery = \"\", onQueryChange }: UseFilterGridOptions<T>) {\n  const [category, setCategory] = useControllableState<string | null>({ value: catProp, defaultValue: defaultCategory, onChange: onCategoryChange });\n  const [query, setQuery] = useControllableState({ value: queryProp, defaultValue: defaultQuery, onChange: onQueryChange });\n  const q = normalize(query.trim());\n\n  const { matches, counts, results } = useMemo(() => {\n    // Every word has to appear somewhere, in any order.\n    const words = q.split(/\\s+/).filter(Boolean);\n    const matches = items.filter((item) => {\n      const text = normalize(getText(item));\n      return words.every((w) => text.includes(w));\n    });\n    const counts = new Map<string, number>();\n    for (const item of matches) for (const c of [getCategory(item)].flat()) counts.set(c, (counts.get(c) ?? 0) + 1);\n    const results = category ? matches.filter((item) => [getCategory(item)].flat().includes(category)) : matches;\n    return { matches, counts, results };\n  }, [items, q, category, getCategory, getText]);\n\n  return { category, setCategory, query, setQuery, results, matchCount: matches.length, counts };\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Match highlight\n * -----------------------------------------------------------------------------------------------*/\n\n/** Marks each searched word inside a string. Safe with any characters in the query. */\nexport function FilterGridHighlight({ text, query, className }: { text: string; query: string; className?: string }) {\n  const words = query.trim().split(/\\s+/).filter(Boolean).map((w) => w.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"));\n  if (!words.length) return <>{text}</>;\n  const parts = text.split(new RegExp(`(${words.join(\"|\")})`, \"gi\"));\n  return (\n    <>\n      {parts.map((p, i) =>\n        i % 2 ? (\n          <mark key={i} className={cn(\"rounded-[3px] bg-fg/12 text-fg [box-decoration-break:clone] [-webkit-box-decoration-break:clone]\", className)}>\n            {p}\n          </mark>\n        ) : (\n          p\n        ),\n      )}\n    </>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * The grid\n * -----------------------------------------------------------------------------------------------*/\n\nexport type FilterGridProps<T> = UseFilterGridOptions<T> &\n  Omit<React.ComponentProps<\"div\">, \"children\" | \"defaultValue\"> & {\n    categories: FilterGridCategory[];\n    getKey: (item: T) => string;\n    renderItem: (item: T, state: { query: string }) => React.ReactNode;\n    /** Singular and plural noun for counts and the empty state. */\n    itemLabel?: { one: string; other: string };\n    /** Label for the chip that clears the category. */\n    allLabel?: string;\n    searchPlaceholder?: string;\n    /** Narrowest a card may get before the grid drops a column. */\n    minItemWidth?: number;\n    /** Classes for the scrolling grid region. Give the root a height to make it scroll. */\n    gridClassName?: string;\n  };\n\nexport function FilterGrid<T>({\n  items,\n  getCategory,\n  getText,\n  category: catProp,\n  defaultCategory,\n  onCategoryChange,\n  query: queryProp,\n  defaultQuery,\n  onQueryChange,\n  categories,\n  getKey,\n  renderItem,\n  itemLabel = { one: \"item\", other: \"items\" },\n  allLabel = \"All\",\n  searchPlaceholder = \"Search\",\n  minItemWidth = 180,\n  gridClassName,\n  className,\n  ...rest\n}: FilterGridProps<T>) {\n  const f = useFilterGrid({ items, getCategory, getText, category: catProp, defaultCategory, onCategoryChange, query: queryProp, defaultQuery, onQueryChange });\n  const reduce = useReducedMotion();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const noun = (n: number) => (n === 1 ? itemLabel.one : itemLabel.other);\n  const activeLabel = categories.find((c) => c.value === f.category)?.label;\n  const trimmed = f.query.trim();\n\n  // Announce the result count once typing settles, not on every keystroke.\n  const [announced, setAnnounced] = useState(\"\");\n  const summary = f.results.length ? `${f.results.length} ${noun(f.results.length)}` : `No ${itemLabel.other}`;\n  useEffect(() => {\n    const t = window.setTimeout(() => setAnnounced(summary), 450);\n    return () => window.clearTimeout(t);\n  }, [summary]);\n\n  return (\n    <div className={cn(\"flex min-h-0 flex-col gap-3\", className)} {...rest}>\n      <div className=\"flex items-center gap-3\">\n        <div className=\"relative flex h-8 min-w-0 flex-1 items-center rounded-lg border border-line-2 bg-raised shadow-[var(--shadow)] transition-[border-color,box-shadow] duration-150 hover:border-fg-4 focus-within:border-fg-4 focus-within:ring-2 focus-within:ring-fg/10\">\n          <Search size={14} className=\"pointer-events-none ml-2.5 shrink-0 text-fg-4\" />\n          <input\n            ref={inputRef}\n            type=\"search\"\n            value={f.query}\n            onChange={(e) => f.setQuery(e.target.value)}\n            onKeyDown={(e) => {\n              if (e.key === \"Escape\" && f.query) {\n                e.preventDefault();\n                f.setQuery(\"\");\n              }\n            }}\n            placeholder={searchPlaceholder}\n            aria-label={searchPlaceholder}\n            autoComplete=\"off\"\n            spellCheck={false}\n            enterKeyHint=\"search\"\n            className=\"h-full min-w-0 flex-1 bg-transparent px-2 text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px] [&::-webkit-search-cancel-button]:hidden\"\n          />\n          {f.query && (\n            <button\n              type=\"button\"\n              aria-label=\"Clear search\"\n              onClick={() => (f.setQuery(\"\"), inputRef.current?.focus())}\n              className=\"relative mr-1.5 grid size-5 shrink-0 place-items-center rounded-md text-fg-3 outline-none transition-[color,background-color,scale] duration-150 hover:bg-hover hover:text-fg focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3 active:scale-[0.9] before:absolute before:-inset-3 before:content-[''] pointer-fine:before:hidden\"\n            >\n              <X size={12} />\n            </button>\n          )}\n        </div>\n        <span aria-hidden className=\"shrink-0 text-[12px] text-fg-3 tabular max-sm:hidden\">{summary}</span>\n      </div>\n\n      <Chips\n        categories={categories}\n        value={f.category}\n        onChange={f.setCategory}\n        counts={f.counts}\n        total={f.matchCount}\n        allLabel={allLabel}\n        reduce={!!reduce}\n      />\n\n      <motion.div layoutScroll className={cn(\"relative min-h-0 flex-1 overflow-y-auto overscroll-contain\", gridClassName)}>\n        <ul\n          className=\"relative grid gap-2\"\n          style={{ gridTemplateColumns: `repeat(auto-fill, minmax(min(${minItemWidth}px, 100%), 1fr))` }}\n          aria-label={activeLabel ? `${activeLabel} ${itemLabel.other}` : itemLabel.other}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {f.results.map((item) => (\n              <motion.li\n                key={getKey(item)}\n                layout={reduce ? false : \"position\"}\n                // Leavers shrink away fast; arrivals wait a beat for the room they need.\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, filter: \"blur(2px)\" }}\n                animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\", transition: { duration: reduce ? 0.12 : 0.22, ease: ease.out, delay: reduce ? 0 : 0.06 } }}\n                exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.96, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } }}\n                transition={spring.soft}\n                className=\"flex min-w-0\"\n              >\n                {renderItem(item, { query: f.query })}\n              </motion.li>\n            ))}\n          </AnimatePresence>\n        </ul>\n\n        <AnimatePresence initial={false}>\n          {f.results.length === 0 && (\n            <motion.div\n              key=\"empty\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1, transition: { duration: 0.2, delay: reduce ? 0 : 0.12 } }}\n              exit={{ opacity: 0, transition: { duration: 0.08 } }}\n              className=\"absolute inset-0 flex min-h-40 flex-col items-center justify-center gap-2.5 px-4 text-center\"\n            >\n              <EmptyMessage\n                query={trimmed}\n                categoryLabel={activeLabel}\n                otherMatches={f.category != null && f.matchCount > 0 ? f.matchCount : 0}\n                noun={itemLabel.other}\n                onClearQuery={() => (f.setQuery(\"\"), inputRef.current?.focus())}\n                onClearCategory={() => f.setCategory(null)}\n              />\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </motion.div>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announced}\n      </span>\n    </div>\n  );\n}\n\n// Says why it's empty and offers the one action that fixes it.\nfunction EmptyMessage({\n  query,\n  categoryLabel,\n  otherMatches,\n  noun,\n  onClearQuery,\n  onClearCategory,\n}: {\n  query: string;\n  categoryLabel?: string;\n  otherMatches: number;\n  noun: string;\n  onClearQuery: () => void;\n  onClearCategory: () => void;\n}) {\n  const where = categoryLabel ? ` in ${categoryLabel}` : \"\";\n  const title = query ? (\n    <>\n      No {noun}\n      {where} match <span className=\"text-fg\">“{query}”</span>\n    </>\n  ) : (\n    <>No {noun}{where} yet</>\n  );\n  const action = otherMatches ? { label: `Show ${otherMatches} in all categories`, run: onClearCategory } : query ? { label: \"Clear search\", run: onClearQuery } : categoryLabel ? { label: \"Show all\", run: onClearCategory } : null;\n\n  return (\n    <>\n      <p className=\"max-w-[36ch] text-[13px] text-fg-2 [text-wrap:balance]\">{title}</p>\n      {action && (\n        <button\n          type=\"button\"\n          onClick={action.run}\n          className=\"h-7 rounded-md border border-line-2 bg-raised px-2.5 text-[12px] font-medium text-fg shadow-[var(--shadow)] outline-none transition-[background-color,border-color,scale] duration-150 hover:border-fg-4 hover:bg-hover focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 active:scale-[0.97]\"\n        >\n          {action.label}\n        </button>\n      )}\n    </>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Category chips: one pill slides to the chosen chip; the row scrolls sideways when it must\n * -----------------------------------------------------------------------------------------------*/\n\nfunction Chips({\n  categories,\n  value,\n  onChange,\n  counts,\n  total,\n  allLabel,\n  reduce,\n}: {\n  categories: FilterGridCategory[];\n  value: string | null;\n  onChange: (v: string | null) => void;\n  counts: Map<string, number>;\n  total: number;\n  allLabel: string;\n  reduce: boolean;\n}) {\n  const pillId = useId();\n  const rowRef = useRef<HTMLDivElement>(null);\n\n  // Fade whichever edge has more chips beyond it. Written straight to the DOM: no re-render per scroll frame.\n  useEffect(() => {\n    const el = rowRef.current;\n    if (!el) return;\n    const sync = () => {\n      el.dataset.fadeStart = String(el.scrollLeft > 2);\n      el.dataset.fadeEnd = String(el.scrollLeft + el.clientWidth < el.scrollWidth - 2);\n    };\n    sync();\n    const ro = new ResizeObserver(sync);\n    ro.observe(el);\n    el.addEventListener(\"scroll\", sync, { passive: true });\n    return () => {\n      ro.disconnect();\n      el.removeEventListener(\"scroll\", sync);\n    };\n  }, []);\n\n  const chips = [{ value: ALL, label: allLabel, count: total }, ...categories.map((c) => ({ ...c, count: counts.get(c.value) ?? 0 }))];\n  const current = value ?? ALL;\n\n  return (\n    <ToggleGroup\n      ref={rowRef}\n      value={[current]}\n      onValueChange={(next) => {\n        // A chip row always has one choice; pressing the current chip keeps it.\n        if (!next[0]) return;\n        onChange(next[0] === ALL ? null : next[0]);\n        document.getElementById(`${pillId}-${next[0]}`)?.scrollIntoView({ block: \"nearest\", inline: \"nearest\", behavior: reduce ? \"auto\" : \"smooth\" });\n      }}\n      aria-label=\"Categories\"\n      className={cn(\n        \"-mx-1 flex scroll-px-6 gap-1 overflow-x-auto px-1 py-0.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n        \"data-[fade-start=true]:data-[fade-end=true]:[mask-image:linear-gradient(to_right,transparent,var(--fg)_20px,var(--fg)_calc(100%-20px),transparent)]\",\n        \"data-[fade-start=true]:data-[fade-end=false]:[mask-image:linear-gradient(to_right,transparent,var(--fg)_20px)]\",\n        \"data-[fade-start=false]:data-[fade-end=true]:[mask-image:linear-gradient(to_left,transparent,var(--fg)_20px)]\",\n      )}\n    >\n      {chips.map((c) => {\n        const on = c.value === current;\n        return (\n          <Toggle\n            key={c.value}\n            id={`${pillId}-${c.value}`}\n            value={c.value}\n            className={cn(\n              \"group/chip relative flex h-7 shrink-0 select-none items-center gap-1.5 rounded-full px-3 text-[12.5px] font-medium outline-none\",\n              \"transition-[color,scale] duration-150 active:scale-[0.96] active:duration-75\",\n              \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n              \"before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n              on ? \"text-frame\" : c.count ? \"text-fg-2 hover:text-fg\" : \"text-fg-4 hover:text-fg-3\",\n            )}\n          >\n            {!on && <span aria-hidden className=\"absolute inset-0 rounded-full border border-line-2 transition-colors duration-150 group-hover/chip:border-fg-4 group-hover/chip:bg-hover\" />}\n            {on && <motion.span layoutId={pillId} aria-hidden transition={reduce ? { duration: 0 } : spring.snappy} className=\"absolute inset-0 rounded-full bg-fg\" />}\n            <span className=\"relative\">{c.label}</span>\n            <span className={cn(\"relative tabular text-[11.5px] font-normal transition-colors duration-150\", on ? \"text-frame/65\" : \"text-fg-4\")}>{c.count}</span>\n          </Toggle>\n        );\n      })}\n    </ToggleGroup>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/filter-grid"
}
