{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "filterable-grid",
  "type": "registry:ui",
  "title": "Filterable Grid",
  "description": "A grid filtered by tag chips and sorted from a menu, with items gliding between layouts as filters change.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/filterable-grid.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nexport interface FilterableItem {\n  /** Stable, unique id (used as the animation key). */\n  id: string;\n  title: string;\n  tags: string[];\n  /** Optional body for the default card. */\n  content?: React.ReactNode;\n}\n\nexport interface SortOption<T extends FilterableItem = FilterableItem> {\n  value: string;\n  label: string;\n  /** Comparator; omit to keep the original order. */\n  compare?: (a: T, b: T) => number;\n}\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface FilterableGridProps<T extends FilterableItem = FilterableItem>\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\" | \"defaultValue\" | \"onChange\"> {\n  items: T[];\n  /** Chips to show, in order. Defaults to every tag in the order it first appears. */\n  filters?: string[];\n  /** Pick several tags at once, or one at a time. */\n  selection?: \"multi\" | \"single\";\n  /** With several tags picked: show items that have any of them, or all of them. */\n  match?: \"any\" | \"all\";\n  /** Selected tags (controlled). An empty array shows everything. */\n  selected?: string[];\n  /** Initially selected tags (uncontrolled). */\n  defaultSelected?: string[];\n  onSelectedChange?: (selected: string[]) => void;\n  /** Sort choices. Defaults to Featured (original order), A–Z and Z–A by title. */\n  sortOptions?: SortOption<T>[];\n  /** Show the sort menu. */\n  showSort?: boolean;\n  /** Show the “Showing x of y” count (it is also the live region). */\n  showCount?: boolean;\n  /** Text of the count and its announcement. */\n  countLabel?: (shown: number, total: number) => string;\n  /** Show how many items carry each tag inside its chip. */\n  showTagCounts?: boolean;\n  /** Renders one item. Defaults to a simple card with title, tags and content. */\n  renderItem?: (item: T) => React.ReactNode;\n  /** Shown when no item matches. A “Clear filters” button is added below it. */\n  emptyState?: React.ReactNode;\n  /** Smallest column width in px; the grid fits as many columns as it can. */\n  minItemWidth?: number;\n  /** Gap between items in px. */\n  gap?: number;\n  /** Spring for items moving to their new place. */\n  spring?: Spring;\n  /** Accessible name of the chip group. */\n  label?: string;\n  /** Active chip and focus colour. */\n  accent?: string;\n}\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst byTitle = (a: FilterableItem, b: FilterableItem) => a.title.localeCompare(b.title);\nconst DEFAULT_SORTS: SortOption<FilterableItem>[] = [\n  { value: \"featured\", label: \"Featured\" },\n  { value: \"az\", label: \"A–Z\", compare: byTitle },\n  { value: \"za\", label: \"Z–A\", compare: (a, b) => byTitle(b, a) },\n];\n\nconst defaultCount = (shown: number, total: number) => `Showing ${shown} of ${total}`;\n\nfunction DefaultCard({ item }: { item: FilterableItem }) {\n  return (\n    <div className=\"flex h-full flex-col rounded-xl border border-zinc-900/10 bg-white p-4 dark:border-white/10 dark:bg-zinc-900\">\n      <h3 className=\"text-sm font-semibold tracking-tight text-zinc-950 dark:text-zinc-50\">{item.title}</h3>\n      {item.content && <div className=\"mt-1 text-sm text-zinc-600 dark:text-zinc-400\">{item.content}</div>}\n      <ul className=\"mt-auto flex list-none flex-wrap gap-1 p-0 pt-3\" aria-label=\"Tags\">\n        {item.tags.map((tag) => (\n          <li key={tag} className=\"rounded-full border border-zinc-900/10 px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-500 dark:border-white/10 dark:text-zinc-400\">\n            {tag}\n          </li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n\n/**\n * A grid of items filtered by tag chips and sorted from a menu. When the selection changes,\n * items that stay glide to their new cells (motion layout), leaving items pop out of the flow\n * and shrink away, and new ones grow in (AnimatePresence popLayout). A polite live region\n * reports “Showing x of y”.\n */\nexport function FilterableGrid<T extends FilterableItem>({\n  items,\n  filters,\n  selection = \"multi\",\n  match = \"any\",\n  selected: selectedProp,\n  defaultSelected = [],\n  onSelectedChange,\n  sortOptions,\n  showSort = true,\n  showCount = true,\n  countLabel = defaultCount,\n  showTagCounts = true,\n  renderItem,\n  emptyState,\n  minItemWidth = 220,\n  gap = 16,\n  spring = { stiffness: 400, damping: 36, mass: 0.9 },\n  label = \"Filter by tag\",\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  ...props\n}: FilterableGridProps<T>) {\n  const uid = React.useId();\n  const reduceMotion = usePrefersReducedMotion();\n  const [internal, setInternal] = React.useState<string[]>(defaultSelected);\n  const selected = selectedProp ?? internal;\n  const sorts = (sortOptions ?? DEFAULT_SORTS) as SortOption<T>[];\n  const [sortValue, setSortValue] = React.useState(sorts[0]?.value ?? \"\");\n\n  const chips = filters ?? [...new Set(items.flatMap((item) => item.tags))];\n  const tagCounts = new Map<string, number>();\n  for (const item of items) for (const tag of item.tags) tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);\n\n  const setSelected = (next: string[]) => {\n    if (selectedProp === undefined) setInternal(next);\n    onSelectedChange?.(next);\n  };\n\n  const toggle = (tag: string) => {\n    const on = selected.includes(tag);\n    if (selection === \"single\") setSelected(on ? [] : [tag]);\n    else setSelected(on ? selected.filter((t) => t !== tag) : [...selected, tag]);\n  };\n\n  // In single mode only the first selected tag counts, even if more are passed in.\n  const active = selection === \"single\" ? selected.slice(0, 1) : selected;\n  const filtered = items.filter((item) => {\n    if (!active.length) return true;\n    return match === \"all\" ? active.every((t) => item.tags.includes(t)) : active.some((t) => item.tags.includes(t));\n  });\n  const compare = sorts.find((s) => s.value === sortValue)?.compare;\n  const visible = compare ? [...filtered].sort(compare) : filtered;\n\n  const layoutTransition = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, ...spring };\n  const focusRing =\n    \"outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--fg-accent)]\";\n  const chipBase = `inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-[13px] font-medium tracking-tight transition-colors duration-150 ${focusRing}`;\n  const chipOff =\n    \"border-zinc-900/10 bg-white text-zinc-600 hover:border-zinc-900/25 hover:text-zinc-950 dark:border-white/10 dark:bg-white/[0.03] dark:text-zinc-400 dark:hover:border-white/25 dark:hover:text-zinc-100\";\n  const chipOn = \"border-[var(--fg-accent)] bg-[var(--fg-accent)] text-zinc-950\";\n\n  return (\n    <div\n      className={`flex w-full min-w-0 flex-col gap-5 ${className}`}\n      style={{ [\"--fg-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <div className=\"flex flex-wrap items-center justify-between gap-3\">\n        <div role=\"group\" aria-label={label} className=\"flex flex-wrap gap-2\">\n          <button type=\"button\" aria-pressed={active.length === 0} onClick={() => setSelected([])} className={`${chipBase} ${active.length === 0 ? chipOn : chipOff}`}>\n            All\n            {showTagCounts && <span className=\"font-mono text-[10px] opacity-60\">{items.length}</span>}\n          </button>\n          {chips.map((tag) => {\n            const on = active.includes(tag);\n            return (\n              <button key={tag} type=\"button\" aria-pressed={on} onClick={() => toggle(tag)} className={`${chipBase} ${on ? chipOn : chipOff}`}>\n                {tag}\n                {showTagCounts && (\n                  <span className=\"font-mono text-[10px] opacity-60\">\n                    <span className=\"sr-only\">, </span>\n                    {tagCounts.get(tag) ?? 0}\n                    <span className=\"sr-only\"> items</span>\n                  </span>\n                )}\n              </button>\n            );\n          })}\n        </div>\n\n        {showSort && sorts.length > 1 && (\n          <label className=\"flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n            Sort\n            <select\n              value={sortValue}\n              onChange={(event) => setSortValue(event.target.value)}\n              className={`h-8 rounded-lg border border-zinc-900/10 bg-white px-2 font-sans text-[13px] normal-case tracking-tight text-zinc-900 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 ${focusRing}`}\n            >\n              {sorts.map((s) => (\n                <option key={s.value} value={s.value}>\n                  {s.label}\n                </option>\n              ))}\n            </select>\n          </label>\n        )}\n      </div>\n\n      {/* Always rendered so it can announce changes; visually hidden when the count is turned off. */}\n      <p\n        id={`${uid}-count`}\n        aria-live=\"polite\"\n        className={showCount ? \"-mt-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\" : \"sr-only\"}\n      >\n        {countLabel(visible.length, items.length)}\n      </p>\n\n      <ul\n        aria-describedby={`${uid}-count`}\n        className=\"relative m-0 grid list-none p-0\"\n        style={{ gap, gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${minItemWidth}px), 1fr))` }}\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {visible.map((item) => (\n            <motion.li\n              key={item.id}\n              layout={!reduceMotion}\n              initial={reduceMotion ? false : { opacity: 0, scale: 0.9 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={reduceMotion ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: 0.9 }}\n              transition={{ layout: layoutTransition, default: reduceMotion ? { duration: 0 } : { duration: 0.22, ease: [0.22, 1, 0.36, 1] } }}\n              className=\"min-w-0\"\n            >\n              {renderItem ? renderItem(item) : <DefaultCard item={item} />}\n            </motion.li>\n          ))}\n        </AnimatePresence>\n      </ul>\n\n      {visible.length === 0 && (\n        <div className=\"flex flex-col items-center gap-3 rounded-xl border border-dashed border-zinc-900/15 px-6 py-10 text-center dark:border-white/15\">\n          <div className=\"text-sm text-zinc-600 dark:text-zinc-400\">{emptyState ?? \"Nothing matches these filters.\"}</div>\n          <button\n            type=\"button\"\n            onClick={() => setSelected([])}\n            className={`h-8 rounded-full border border-zinc-900/15 px-3 text-[13px] font-medium text-zinc-900 hover:bg-zinc-900/[0.04] dark:border-white/15 dark:text-zinc-100 dark:hover:bg-white/[0.06] ${focusRing}`}\n          >\n            Clear filters\n          </button>\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/filterable-grid"
}
