{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "filter-grid",
  "type": "registry:ui",
  "title": "Filter grid",
  "description": "Preserves spatial continuity as filtered results rearrange.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "data-commerce"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/filter-grid/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "light",
    "signature": "Results rearrange while preserving spatial relationships",
    "sceneFamily": "product-mono",
    "motionRole": "ui",
    "primaryState": "Compact result grid after filtering",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/filter-grid.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/filter-grid.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nconst CELL = { type: \"spring\", stiffness: 520, damping: 34, mass: 0.45 } as const;\nconst MOVE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\nconst EASE = [0.23, 1, 0.32, 1] as const;\nconst LEAVE = { duration: 0.14, ease: [0.23, 1, 0.32, 1] } as const;\nconst INSTANT = { duration: 0 } as const;\n\nexport type FilterDefinition<T> = {\n  id: string;\n  label: string;\n  match: (item: T) => boolean;\n};\n\nexport type UseFilterGridOptions<T> = {\n  items: readonly T[];\n  filters: readonly FilterDefinition<T>[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (id: string) => void;\n};\n\nexport type UseFilterGridResult<T> = {\n  active: string;\n  activeLabel: string;\n  select: (id: string) => void;\n  visible: T[];\n  counts: Record<string, number>;\n  total: number;\n};\n\nexport function useFilterGrid<T>({\n  items,\n  filters,\n  value,\n  defaultValue,\n  onValueChange,\n}: UseFilterGridOptions<T>): UseFilterGridResult<T> {\n  const fallback = filters[0]?.id ?? \"\";\n  const [internal, setInternal] = useState(() => defaultValue ?? fallback);\n\n  const requested = value ?? internal;\n  const current = filters.find((f) => f.id === requested) ?? filters[0];\n  const active = current?.id ?? fallback;\n\n  const emit = useRef(onValueChange);\n  emit.current = onValueChange;\n\n  const counts = useMemo(() => {\n    const next: Record<string, number> = {};\n    for (const filter of filters) {\n      let n = 0;\n      for (const item of items) if (filter.match(item)) n += 1;\n      next[filter.id] = n;\n    }\n    return next;\n  }, [filters, items]);\n\n  const visible = useMemo(() => {\n    const filter = filters.find((f) => f.id === active);\n    if (!filter) return [...items];\n    return items.filter((item) => filter.match(item));\n  }, [filters, items, active]);\n\n  const select = useCallback(\n    (id: string) => {\n      if (value === undefined) setInternal(id);\n      if (id !== active) emit.current?.(id);\n    },\n    [value, active],\n  );\n\n  return {\n    active,\n    activeLabel: current?.label ?? \"\",\n    select,\n    visible,\n    counts,\n    total: items.length,\n  };\n}\n\nexport type FilterGridProps<T> = {\n  items: readonly T[];\n  filters: readonly FilterDefinition<T>[];\n  getKey: (item: T) => string;\n  renderItem: (item: T) => ReactNode;\n  label: string;\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (id: string) => void;\n  columns?: number;\n  rowHeight?: number;\n  maxRows?: number;\n  gap?: number;\n  emptyLabel?: string;\n  formatFilterCount?: (label: string, count: number, total: number) => string;\n  formatResultCount?: (label: string, count: number, total: number) => string;\n  className?: string;\n};\n\nexport function FilterGrid<T>({\n  items,\n  filters,\n  getKey,\n  renderItem,\n  label,\n  value,\n  defaultValue,\n  onValueChange,\n  columns = 3,\n  rowHeight = 72,\n  maxRows = 4,\n  gap = 8,\n  emptyLabel = \"Nothing matches this filter\",\n  formatFilterCount = (name, count, itemCount) => `${name}, ${count} of ${itemCount}`,\n  formatResultCount = (name, count, itemCount) => `${name}: ${count} of ${itemCount} shown`,\n  className = \"\",\n}: FilterGridProps<T>) {\n  const uid = useId();\n  const gridId = `${uid}-grid`;\n  const reduced = useReducedMotion();\n\n  const { active, activeLabel, select, visible, counts, total } = useFilterGrid({\n    items,\n    filters,\n    value,\n    defaultValue,\n    onValueChange,\n  });\n\n  const gridRef = useRef<HTMLUListElement>(null);\n  const chips = useRef<(HTMLButtonElement | null)[]>([]);\n  const heldFocus = useRef(false);\n\n  const cols = Math.max(1, Math.floor(columns));\n  const rows = Math.min(Math.max(1, Math.ceil(total / cols)), Math.max(1, maxRows));\n  const box = rows * rowHeight + (rows - 1) * gap;\n\n  const index = Math.max(\n    0,\n    filters.findIndex((f) => f.id === active),\n  );\n\n  const choose = useCallback(\n    (id: string) => {\n      const grid = gridRef.current;\n      heldFocus.current =\n        !!grid && grid.contains(document.activeElement) && grid !== document.activeElement;\n      select(id);\n    },\n    [select],\n  );\n\n  const settle = useCallback(() => {\n    if (!heldFocus.current) return;\n    heldFocus.current = false;\n    const grid = gridRef.current;\n    if (grid && !grid.contains(document.activeElement)) grid.focus();\n  }, []);\n\n  const go = useCallback(\n    (i: number) => {\n      const next = filters[(i + filters.length) % filters.length];\n      if (!next) return;\n      chips.current[(i + filters.length) % filters.length]?.focus();\n      choose(next.id);\n    },\n    [filters, choose],\n  );\n\n  const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") {\n      e.preventDefault();\n      go(i + 1);\n    } else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") {\n      e.preventDefault();\n      go(i - 1);\n    } else if (e.key === \"Home\") {\n      e.preventDefault();\n      go(0);\n    } else if (e.key === \"End\") {\n      e.preventDefault();\n      go(filters.length - 1);\n    }\n  };\n\n  const swap = reduced ? INSTANT : CELL;\n  const step = reduced ? INSTANT : { layout: MOVE, duration: 0.2, ease: EASE };\n  const leave = reduced ? INSTANT : LEAVE;\n\n  const capped = Math.ceil(total / cols) > Math.max(1, maxRows);\n\n  return (\n    <div className={`w-full ${className}`}>\n      <div\n        role=\"radiogroup\"\n        aria-label={label}\n        aria-controls={gridId}\n        className=\"flex flex-wrap items-center gap-1.5\"\n      >\n        {filters.map((filter, i) => {\n          const on = i === index;\n          return (\n            <button\n              key={filter.id}\n              ref={(node) => {\n                chips.current[i] = node;\n              }}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={on}\n              tabIndex={on ? 0 : -1}\n              onClick={() => choose(filter.id)}\n              onKeyDown={(e) => onKeyDown(e, i)}\n              className=\"group relative inline-grid h-8 select-none place-items-center rounded-[6px] px-3 outline-none focus-visible:shadow-[0_1px_3px_rgba(28,25,23,0.18)] dark:focus-visible:shadow-[0_1px_3px_rgba(0,0,0,0.5)]\"\n              style={{ touchAction: \"manipulation\" }}\n            >\n              {on ? (\n                <motion.span\n                  aria-hidden\n                  layoutId={reduced ? undefined : `${uid}-thumb`}\n                  transition={CELL}\n                  className=\"absolute inset-0 rounded-[6px] bg-stone-800 dark:bg-stone-100\"\n                />\n              ) : null}\n\n              <span\n                aria-hidden\n                className={`pointer-events-none absolute inset-0 rounded-[6px] border group-focus-visible:border-[#4568FF] dark:group-focus-visible:border-[#93B0FF] ${\n                  on ? \"border-transparent\" : \"border-stone-200 dark:border-white/[0.16]\"\n                }`}\n              />\n              <span className=\"relative col-start-1 row-start-1 inline-grid\">\n                <motion.span\n                  aria-hidden\n                  initial={false}\n                  animate={{ opacity: on ? 0 : 1 }}\n                  transition={swap}\n                  className=\"col-start-1 row-start-1 inline-flex items-center gap-1.5 whitespace-nowrap text-[12.5px] font-medium text-stone-700 dark:text-stone-200\"\n                >\n                  {filter.label}\n                  <span className=\"text-[10.5px] tabular-nums text-stone-500 dark:text-stone-400\">\n                    {counts[filter.id]}\n                  </span>\n                </motion.span>\n                <motion.span\n                  aria-hidden\n                  initial={false}\n                  animate={{ opacity: on ? 1 : 0 }}\n                  transition={swap}\n                  className=\"col-start-1 row-start-1 inline-flex items-center gap-1.5 whitespace-nowrap text-[12.5px] font-medium text-stone-50 dark:text-stone-900\"\n                >\n                  {filter.label}\n                  <span className=\"text-[10.5px] tabular-nums opacity-70\">\n                    {counts[filter.id]}\n                  </span>\n                </motion.span>\n                <span className=\"sr-only\">\n                  {formatFilterCount(filter.label, counts[filter.id], total)}\n                </span>\n              </span>\n            </button>\n          );\n        })}\n      </div>\n      <div className=\"relative mt-2.5\">\n        <ul\n          id={gridId}\n          ref={gridRef}\n          tabIndex={-1}\n          className={`relative overflow-y-auto overscroll-contain outline-none ${\n            capped ? \"[scrollbar-gutter:stable]\" : \"\"\n          }`}\n          style={{\n            display: \"grid\",\n            gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,\n            gridAutoRows: `${rowHeight}px`,\n            gap: `${gap}px`,\n            height: `${box}px`,\n          }}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\" onExitComplete={settle}>\n            {visible.map((item) => (\n              <motion.li\n                key={getKey(item)}\n                layout={reduced ? false : \"position\"}\n                initial={{ opacity: 0, scale: 0.97 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ opacity: 0, scale: 0.98, transition: leave }}\n                transition={step}\n                className=\"min-w-0 overflow-hidden rounded-[11px] border border-stone-200 bg-white p-2.5 shadow-[0_1px_2px_rgba(28,25,23,0.06),0_4px_10px_-8px_rgba(28,25,23,0.45)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:shadow-[0_1px_6px_rgba(0,0,0,0.45)]\"\n              >\n                {renderItem(item)}\n              </motion.li>\n            ))}\n          </AnimatePresence>\n        </ul>\n        <AnimatePresence initial={false}>\n          {visible.length === 0 && (\n            <motion.div\n              key=\"empty\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0, transition: leave }}\n              transition={reduced ? INSTANT : { duration: 0.2, ease: EASE }}\n              className=\"pointer-events-none absolute inset-0 grid place-items-center\"\n            >\n              <span className=\"text-[12.5px] text-stone-500 dark:text-stone-400\">\n                {emptyLabel}\n              </span>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {formatResultCount(activeLabel, visible.length, total)}\n      </p>\n    </div>\n  );\n}\n"
    }
  ]
}
