{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "table-filter",
  "type": "registry:ui",
  "title": "Table filter",
  "description": "A filter for one column of a Table, in its header: a funnel button that opens the column's values as Menu rows, checkboxes with how many rows each would show, or radio buckets for a number. A filtering column's button wears the selected segment.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/menu.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/table-filter.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/table-filter 2026.11.02. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * TableFilter: a filter for one column of a Table, in its header (components.md, \"Table and list rows\", Filters).\n * Pass it as a TableHead's `filter`. It is a small button that opens the Menu with the column's values as rows.\n *\n * - Values: `multiple` (the default) is checkbox rows, any number on; none on means no filter, so every row shows.\n *   A checkbox row can say how many rows it would show, in the Menu's tabular hint at the end (\"1,204\"); count them\n *   against the other filters, so the numbers are what picking it would give. A Show all row under a hairline clears\n *   the column. Single is radio rows with All first, for buckets of a number such as \"Over 60 s\".\n * - The button: a 16px funnel in --litt-fg-subtle at rest, 28px, the control radius (44px under a finger, the header's\n *   height). While the column filters, it is raised (R3, selected is raised): the press material's block (--litt-block,\n *   --litt-block-lift), the funnel in --litt-fg and, with several values, how many are on, in tabular figures. Never the accent: a table can\n *   filter on several columns at once.\n * - The Menu does the rest: glass, 44px rows, the gliding highlight, typeahead, and rows that stay open as they toggle,\n *   so several values change in one visit. The rows filter as they toggle; nothing waits for an Apply.\n * - Accessibility: the button is named for the column and what it holds (\"Filter status, 2 of 4 on\"), and the menu is\n *   named \"Filter status\". Say the result in a polite status of your own (\"1,170 of 10,000 deploys\").\n * - Motion: the Menu's, on the responsive tier. The button's fill changes on the instant tier.\n *\n * Filters in the page address (components.md, \"Table and list rows\", Filters in the address): useUrlFilter and\n * useUrlParam keep a filter, a search or a sort in the query string, so a filtered view can be bookmarked, shared or\n * reloaded, and Back and Forward bring the last one back.\n * - Each value is its own parameter, readable and safe for any text: ?status=failed&site=Halden+Gallery. An empty value\n *   leaves the address, so an unfiltered table has a clean one.\n * - Writes replace the history entry instead of adding one, so Back leaves the page instead of undoing toggles one by\n *   one. They wait 200ms after the last change, so typing a search never floods the history API (Safari throttles it),\n *   while the hooks hand back the new value at once. A write the page has moved on from (another path or hash) is dropped.\n * - The address is untrusted: useUrlFilter keeps only the `values` you allow, so a stale or hand-edited link can never\n *   leave a filter on that nothing can turn off.\n * - Any router: it reads location and writes with history.replaceState, keeping history.state, which Next.js's App\n *   Router and React Router both follow. On the server, and in the render that hydrates, every value is empty, so the\n *   two agree; the address is read right after. In an app without server rendering it is read in the first render.\n */\nimport { useCallback, useMemo, useSyncExternalStore, type ReactNode } from \"react\";\nimport { FilterIcon } from \"./icons\";\nimport { Menu, MenuCheckboxItem, MenuItem, MenuLabel, MenuRadioGroup, MenuRadioItem, MenuSeparator } from \"./menu\";\n\nexport type TableFilterOption = {\n  /** What the row stands for, in `value` and onValueChange. */\n  value: string;\n  /** The row's words: \"Ready\", \"Over 60 s\". */\n  label: string;\n  /** How many rows picking it would show, said at the end of a checkbox row. Leave it out to say nothing. */\n  count?: number;\n};\n\nexport type TableFilterProps = {\n  /** The column's name, as its header says it: \"Status\". It names the button and the menu. */\n  column: string;\n  /** The values to filter by, in the order they should be listed. */\n  options: TableFilterOption[];\n  /** The values on. An empty list is no filter: every row shows. Single mode holds at most one. */\n  value: string[];\n  /** Called with the values on after every toggle, or [] for Show all. */\n  onValueChange: (value: string[]) => void;\n  /** Checkbox rows, any number on (the default), or radio rows with All first, for buckets of a number. */\n  multiple?: boolean;\n  /** The All row's words in single mode. */\n  allLabel?: string;\n  /** Which edge of the button the menu lines up with: end for a column at the table's right. */\n  align?: \"start\" | \"end\";\n};\n\nconst ALL = \"__all__\";\nconst COUNT = new Intl.NumberFormat();\n\nconst button =\n  \"flex h-7 min-w-7 cursor-pointer items-center justify-center gap-1 rounded-litt-control px-1.5 [-webkit-tap-highlight-color:transparent] \" +\n  \"transition-[background-color,color] duration-(--litt-dur-instant) ease-litt \" +\n  \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus \" +\n  \"pointer-coarse:h-11 pointer-coarse:min-w-11\";\nconst idle =\n  \"text-litt-fg-subtle hover:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] hover:text-litt-fg \" +\n  \"pointer-coarse:active:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] pointer-coarse:active:text-litt-fg\";\n/* Selected is raised (R3): the press material's block, as a switch's block and the current page are. */\nconst on = \"bg-litt-block text-litt-fg shadow-(--litt-block-lift) forced-colors:border\";\n\n/** A column's filter: a funnel button in its header that opens the column's values as checkbox (or radio) rows. */\nexport function TableFilter({ column, options, value, onValueChange, multiple = true, allLabel = \"All\", align = \"start\" }: TableFilterProps) {\n  const active = value.length > 0;\n  const name = column.toLowerCase();\n  const picked = options.find((o) => o.value === value[0]);\n  const said = !active ? \"\" : multiple ? `, ${value.length} of ${options.length} on` : `, ${picked?.label ?? value[0]}`;\n  const hint = (o: TableFilterOption): string | undefined => (o.count === undefined ? undefined : COUNT.format(o.count));\n\n  const trigger = (\n    <button type=\"button\" aria-label={`Filter ${name}${said}`} className={button + \" \" + (active ? on : idle)}>\n      <FilterIcon />\n      {active && multiple && (\n        <span aria-hidden=\"true\" className=\"text-litt-caption font-normal tabular-nums\">\n          {value.length}\n        </span>\n      )}\n    </button>\n  );\n\n  let rows: ReactNode;\n  if (multiple) {\n    rows = options.map((o) => (\n      <MenuCheckboxItem\n        key={o.value}\n        checked={value.includes(o.value)}\n        shortcut={hint(o)}\n        onCheckedChange={(checked) => onValueChange(checked ? [...value, o.value] : value.filter((v) => v !== o.value))}\n      >\n        {o.label}\n      </MenuCheckboxItem>\n    ));\n  } else {\n    rows = (\n      <MenuRadioGroup value={value[0] ?? ALL} onValueChange={(v) => onValueChange(v === ALL ? [] : [v])}>\n        <MenuRadioItem value={ALL}>{allLabel}</MenuRadioItem>\n        {options.map((o) => (\n          <MenuRadioItem key={o.value} value={o.value}>\n            {o.label}\n          </MenuRadioItem>\n        ))}\n      </MenuRadioGroup>\n    );\n  }\n\n  return (\n    <Menu trigger={trigger} label={`Filter ${name}`} align={align}>\n      <MenuLabel>{column}</MenuLabel>\n      {rows}\n      {multiple && (\n        <>\n          <MenuSeparator />\n          <MenuItem disabled={!active} onSelect={() => onValueChange([])}>\n            Show all\n          </MenuItem>\n        </>\n      )}\n    </Menu>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Filters in the page address                                                */\n/* -------------------------------------------------------------------------- */\n\n/** How long a write waits after the last change before it reaches the history, in ms. */\nconst WRITE_AFTER = 200;\n\n/* One store for every hook on the page: the query string, with a write that has not reached the history yet. */\nconst listeners = new Set<() => void>();\nlet pending: { search: string; at: string } | null = null;\nlet timer: ReturnType<typeof setTimeout> | undefined;\n\nconst here = () => window.location.pathname + window.location.hash;\nconst readSearch = () => (pending && pending.at === here() ? pending.search : window.location.search);\nconst notify = () => listeners.forEach((listener) => listener());\n\nfunction flush() {\n  clearTimeout(timer);\n  if (!pending) return;\n  const { search, at } = pending;\n  /* The page moved on while the write waited: never put one page's filters on another. */\n  if (at !== here()) {\n    pending = null;\n    notify();\n    return;\n  }\n  try {\n    window.history.replaceState(window.history.state, \"\", window.location.pathname + search + window.location.hash);\n    pending = null;\n  } catch {\n    /* The browser throttled the history API: the value holds, and it tries again in a moment. */\n    timer = setTimeout(flush, 1000);\n  }\n}\n\n/* Back and Forward: the address wins over a write still waiting. */\nfunction onPopState() {\n  clearTimeout(timer);\n  pending = null;\n  notify();\n}\n\nfunction subscribe(listener: () => void) {\n  listeners.add(listener);\n  if (listeners.size === 1) {\n    window.addEventListener(\"popstate\", onPopState);\n    window.addEventListener(\"pagehide\", flush);\n  }\n  return () => {\n    listeners.delete(listener);\n    if (listeners.size > 0) return;\n    window.removeEventListener(\"popstate\", onPopState);\n    window.removeEventListener(\"pagehide\", flush);\n  };\n}\n\nfunction writeParam(name: string, values: readonly string[]) {\n  const params = new URLSearchParams(readSearch());\n  params.delete(name);\n  for (const v of values) if (v !== \"\") params.append(name, v);\n  const text = params.toString();\n  const search = text ? `?${text}` : \"\";\n  if (search === readSearch()) return;\n  pending = { search, at: here() };\n  notify();\n  clearTimeout(timer);\n  timer = setTimeout(flush, WRITE_AFTER);\n}\n\n/** The query string, empty on the server and in the render that hydrates. */\nfunction useSearch() {\n  return useSyncExternalStore(subscribe, readSearch, () => \"\");\n}\n\nexport type UrlFilterOptions = {\n  /** The values this parameter may hold, such as the TableFilter's option values. Anything else in the address is ignored. */\n  values?: readonly string[];\n  /** false keeps one value at most, for a TableFilter with multiple={false}. */\n  multiple?: boolean;\n};\n\n/**\n * A TableFilter's values, kept in the page address as one parameter per value: `?status=ready&status=failed`. Returns\n * the values on and a setter, the shape of TableFilter's `value` and `onValueChange`. The list keeps its identity until\n * the parameter changes, so it can sit in a useMemo's or useDeferredValue's inputs.\n */\nexport function useUrlFilter(name: string, { values, multiple = true }: UrlFilterOptions = {}): [string[], (next: string[]) => void] {\n  const search = useSearch();\n  /* Joined, so an options list rebuilt every render does not count as a change. */\n  const allowed = values?.join(\"\\u0000\");\n  const value = useMemo(() => {\n    const ok = allowed === undefined ? null : new Set(allowed.split(\"\\u0000\"));\n    const all = new URLSearchParams(search).getAll(name).filter((v, i, list) => (!ok || ok.has(v)) && list.indexOf(v) === i);\n    return multiple ? all : all.slice(0, 1);\n  }, [search, name, allowed, multiple]);\n  const set = useCallback((next: string[]) => writeParam(name, multiple ? next : next.slice(0, 1)), [name, multiple]);\n  return [value, set];\n}\n\n/**\n * One text value kept in the page address, such as a search (`?q=halden`) or a sort (`?sort=slow`). Returns it, or \"\"\n * when the address has none, and a setter; setting \"\" takes the parameter out.\n */\nexport function useUrlParam(name: string): [string, (next: string) => void] {\n  const search = useSearch();\n  const value = useMemo(() => new URLSearchParams(search).get(name) ?? \"\", [search, name]);\n  const set = useCallback((next: string) => writeParam(name, [next]), [name]);\n  return [value, set];\n}\n",
      "target": "components/litt/table-filter.tsx"
    }
  ],
  "docs": "Import TableFilter from components/litt/table-filter and pass it as a TableHead's filter. Hold the values on in state ([] is no filter) and filter your rows with them; count each value against the other filters. To keep them in the page address, so the view can be shared or reloaded, hold them with useUrlFilter (and a search or sort with useUrlParam) instead of useState.",
  "meta": {
    "version": "2026.11.02",
    "released": "2026-11-02",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
