{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"c-filters-7","type":"registry:block","title":"Filtering a table","description":"The query tree compiled to a row predicate.","dependencies":["@tanstack/react-table","cn"],"registryDependencies":["avatar","@reui/badge","card","@reui/data-grid","@reui/data-grid-column-header","@reui/data-grid-pagination","@reui/data-grid-scroll-area","@reui/data-grid-table","@reui/filters","@reui/filters-query","@reui/filters-types"],"files":[{"path":"c-filters-7.tsx","type":"registry:block","content":"\"use client\"\n\nimport { useMemo, useState } from \"react\"\nimport {\n  DataGrid,\n  DataGridContainer,\n  dataGridFeatures,\n  type DataGridFeatures,\n} from \"@/components/reui/data-grid/data-grid\"\nimport { DataGridColumnHeader } from \"@/components/reui/data-grid/data-grid-column-header\"\nimport { DataGridPagination } from \"@/components/reui/data-grid/data-grid-pagination\"\nimport { DataGridScrollArea } from \"@/components/reui/data-grid/data-grid-scroll-area\"\nimport { DataGridTable } from \"@/components/reui/data-grid/data-grid-table\"\nimport { Filters } from \"@/components/reui/filters/filters\"\nimport {\n  createFilterQuery,\n  createFilterRule,\n  isFilterRule,\n} from \"@/components/reui/filters/filters-query\"\nimport type {\n  FilterField,\n  FilterNode,\n  FilterOption,\n  FilterQuery,\n  FilterRule,\n} from \"@/components/reui/filters/filters-types\"\nimport {\n  ColumnDef,\n  PaginationState,\n  SortingState,\n  useTable,\n} from \"@tanstack/react-table\"\n\nimport { cn } from \"cn\"\nimport {\n  Avatar,\n  AvatarFallback,\n  AvatarImage,\n} from \"@/components/ui/avatar\"\nimport { Badge } from \"@/components/reui/badge\"\nimport { Card } from \"@/components/ui/card\"\nimport { IconPlaceholder } from \"@/app/(create)/components/icon-placeholder\"\n\n/* -------------------------------------------------------------------------- */\n/*                                    Tones                                   */\n/* -------------------------------------------------------------------------- */\n\n/** A filled swatch, at c-filters-1's size. */\nfunction Dot({ className }: { className: string }) {\n  return <span className={cn(\"size-2 shrink-0 rounded-full\", className)} />\n}\n\n/**\n * Priority's glyph, and the reason it is a star rather than a third `Dot`:\n * Status, Priority and Availability are all \"pick some of a short list\", so a\n * third coloured circle would have made one chip read as another. A rank is not\n * a state.\n *\n * OUTLINE in all five icon sets, deliberately. lucide, tabler and hugeicons\n * draw their star with a stroke and would take a `fill-current`, but phosphor\n * and remixicon draw theirs as an already filled RING, which no class can\n * solidify. Filling would therefore give three libraries a solid star and two a\n * hollow one, so all five stay hollow.\n *\n * The colour arrives with its own `!`, and the paths are pinned to inherit it,\n * because every style sheet repaints a highlighted row's descendants with\n * `color: var(--accent-foreground)` at a specificity no plain utility beats.\n * Unpinned, the star greys out under the cursor, and each path's `currentColor`\n * resolves against the repaint rather than against the star. Dots never met\n * this: a `bg-*` swatch has no `color` to repaint.\n */\nfunction Star({ className }: { className: string }) {\n  return (\n    <IconPlaceholder\n      lucide=\"StarIcon\"\n      tabler=\"IconStar\"\n      hugeicons=\"StarIcon\"\n      phosphor=\"StarIcon\"\n      remixicon=\"RiStarLine\"\n      className={cn(\"shrink-0 **:text-inherit!\", className)}\n    />\n  )\n}\n\n/**\n * The country's flag, drawn the same way in the option row and in the cell.\n *\n * A plain `<img>`, and it stays one: `next/image` would tie a registry example\n * to a framework, and these are 16px SVGs from a CDN, which is the case its\n * optimizer has nothing to offer.\n */\nfunction Flag({ code, className }: { code: string; className?: string }) {\n  return (\n    // eslint-disable-next-line @next/next/no-img-element\n    <img\n      src={`https://flagcdn.com/${code}.svg`}\n      alt=\"\"\n      className={cn(\"size-4 shrink-0 rounded-full object-cover\", className)}\n    />\n  )\n}\n\n/**\n * Three palettes, each settled inside itself.\n *\n * Hues repeat ACROSS the sets (active and online are both green, and in a staff\n * table they should be) but never inside one of the two LABEL sets: the moment\n * two rows of one such menu read as the same colour, the colour has stopped\n * being an identifier there. Priority answers to a different rule again, set\n * out over its own table below.\n *\n * Written out as whole class names because Tailwind scans source text, so a\n * `bg-${hue}-500` assembled at runtime reaches the browser as a class nothing\n * generated.\n */\nconst STATUSES = [\n  { value: \"active\", label: \"Active\", dot: \"bg-emerald-500\" },\n  { value: \"invited\", label: \"Invited\", dot: \"bg-sky-500\" },\n  { value: \"suspended\", label: \"Suspended\", dot: \"bg-rose-500\" },\n]\n\nconst AVAILABILITIES = [\n  { value: \"online\", label: \"Online\", dot: \"bg-emerald-500\" },\n  { value: \"away\", label: \"Away\", dot: \"bg-amber-500\" },\n  { value: \"busy\", label: \"Busy\", dot: \"bg-rose-500\" },\n  { value: \"offline\", label: \"Offline\", dot: \"bg-zinc-400\" },\n]\n\n// Priority is a ramp rather than a set of labels, so its tones run one way and\n// the star wears them as `text-*` carrying its own `!`. Cool to hot by hue\n// (160, 83, 44, 17 in OKLCH) and rising in chroma the whole way, which is what\n// puts the four in an order the eye can take without reading the words.\n//\n// The top step is `rose-500` and NOT the theme's own `destructive`, which is\n// the obvious pick and the wrong one: destructive measures hue 22 in light and\n// dark, so it lands BETWEEN High and Urgent and a ladder ending on it doubles\n// back. In dark mode it is lighter and less saturated than the step below it\n// too, so the worst rank would read as the milder colour. A ramp's last step\n// has to be its most intense in every theme, and only a fixed palette step\n// promises that.\nconst PRIORITIES = [\n  { value: \"low\", label: \"Low\", star: \"text-emerald-500!\" },\n  { value: \"medium\", label: \"Medium\", star: \"text-yellow-500!\" },\n  { value: \"high\", label: \"High\", star: \"text-orange-500!\" },\n  { value: \"urgent\", label: \"Urgent\", star: \"text-rose-500!\" },\n]\n\nconst ROLES = [\n  { value: \"Product Manager\", label: \"Product Manager\" },\n  { value: \"Data Scientist\", label: \"Data Scientist\" },\n  { value: \"Designer\", label: \"Designer\" },\n  { value: \"Developer\", label: \"Developer\" },\n]\n\nconst DOTS = new Map(\n  [...STATUSES, ...AVAILABILITIES].map((tone) => [tone.value, tone.dot])\n)\n\n/* -------------------------------------------------------------------------- */\n/*                                  The rows                                  */\n/* -------------------------------------------------------------------------- */\n\ninterface Staff {\n  id: string\n  name: string\n  email: string\n  avatar: string\n  role: string\n  priority: string\n  status: string\n  availability: string\n  location: string\n  flag: string\n}\n\nconst STAFF: Staff[] = [\n  {\n    id: \"1\",\n    name: \"Alex Johnson\",\n    email: \"alex@apple.com\",\n    avatar:\n      \"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80\",\n    role: \"Product Manager\",\n    priority: \"urgent\",\n    status: \"active\",\n    availability: \"online\",\n    location: \"United States\",\n    flag: \"us\",\n  },\n  {\n    id: \"2\",\n    name: \"Sarah Chen\",\n    email: \"sarah@openai.com\",\n    avatar:\n      \"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80\",\n    role: \"Data Scientist\",\n    priority: \"high\",\n    status: \"active\",\n    availability: \"away\",\n    location: \"United Kingdom\",\n    flag: \"gb\",\n  },\n  {\n    id: \"3\",\n    name: \"Michael Rodriguez\",\n    email: \"michael@meta.com\",\n    avatar:\n      \"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80\",\n    role: \"Designer\",\n    priority: \"medium\",\n    status: \"invited\",\n    availability: \"busy\",\n    location: \"Canada\",\n    flag: \"ca\",\n  },\n  {\n    id: \"4\",\n    name: \"Emma Wilson\",\n    email: \"emma@tesla.com\",\n    avatar:\n      \"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80\",\n    role: \"Developer\",\n    priority: \"low\",\n    status: \"suspended\",\n    availability: \"offline\",\n    location: \"Australia\",\n    flag: \"au\",\n  },\n  {\n    id: \"5\",\n    name: \"David Kim\",\n    email: \"david@sap.com\",\n    avatar:\n      \"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80\",\n    role: \"Developer\",\n    priority: \"high\",\n    status: \"active\",\n    availability: \"online\",\n    location: \"Germany\",\n    flag: \"de\",\n  },\n  {\n    id: \"6\",\n    name: \"Aron Thompson\",\n    email: \"aron@keenthemes.com\",\n    avatar:\n      \"https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80\",\n    role: \"Designer\",\n    priority: \"medium\",\n    status: \"active\",\n    availability: \"away\",\n    location: \"Malaysia\",\n    flag: \"my\",\n  },\n  {\n    id: \"7\",\n    name: \"James Brown\",\n    email: \"james@bbva.es\",\n    avatar:\n      \"https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80\",\n    role: \"Product Manager\",\n    priority: \"low\",\n    status: \"invited\",\n    availability: \"busy\",\n    location: \"Spain\",\n    flag: \"es\",\n  },\n  {\n    id: \"8\",\n    name: \"Maria Garcia\",\n    email: \"maria@sony.jp\",\n    avatar:\n      \"https://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80\",\n    role: \"Data Scientist\",\n    priority: \"urgent\",\n    status: \"active\",\n    availability: \"offline\",\n    location: \"Japan\",\n    flag: \"jp\",\n  },\n  {\n    id: \"9\",\n    name: \"Nick Johnson\",\n    email: \"nick@lvmh.fr\",\n    avatar:\n      \"https://images.unsplash.com/photo-1485206412256-701ccc5b93ca?w=96&h=96&dpr=2&q=80\",\n    role: \"Developer\",\n    priority: \"medium\",\n    status: \"active\",\n    availability: \"online\",\n    location: \"France\",\n    flag: \"fr\",\n  },\n  {\n    id: \"10\",\n    name: \"Liam Thompson\",\n    email: \"liam@eni.it\",\n    avatar:\n      \"https://images.unsplash.com/photo-1542595913-85d69b0edbaf?w=96&h=96&dpr=2&q=80\",\n    role: \"Designer\",\n    priority: \"low\",\n    status: \"suspended\",\n    availability: \"away\",\n    location: \"Italy\",\n    flag: \"it\",\n  },\n  {\n    id: \"11\",\n    name: \"Olivia Martinez\",\n    email: \"olivia@vale.br\",\n    avatar:\n      \"https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=96&h=96&dpr=2&q=80\",\n    role: \"Data Scientist\",\n    priority: \"high\",\n    status: \"active\",\n    availability: \"busy\",\n    location: \"Brazil\",\n    flag: \"br\",\n  },\n  {\n    id: \"12\",\n    name: \"Ethan Park\",\n    email: \"ethan@tata.in\",\n    avatar:\n      \"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=96&h=96&dpr=2&q=80\",\n    role: \"Product Manager\",\n    priority: \"medium\",\n    status: \"invited\",\n    availability: \"online\",\n    location: \"India\",\n    flag: \"in\",\n  },\n]\n\n/**\n * Every country the rows mention, once each, in the order they introduce them.\n *\n * Derived rather than declared, so a country cannot be offered that no row\n * carries: an option that matches nothing is a filter that looks broken.\n */\nconst LOCATIONS: FilterOption[] = Array.from(\n  new Map(\n    STAFF.map((person) => [\n      person.location,\n      {\n        value: person.location,\n        label: person.location,\n        icon: <Flag code={person.flag} />,\n      },\n    ])\n  ).values()\n)\n\n/* -------------------------------------------------------------------------- */\n/*                        Stacked value display renderers                     */\n/* -------------------------------------------------------------------------- */\n\n/**\n * c-filters-1's treatment, reused rather than re-invented: one pick reads as\n * its swatch and its word, several collapse to overlapping swatches plus a\n * count, so the chip is one width whether two statuses are picked or all three.\n * The empty word is a prop because the same renderer serves two fields.\n */\nfunction StackedDots({\n  options,\n  empty,\n}: {\n  options: FilterOption[]\n  empty: string\n}) {\n  if (options.length === 0) return <>{empty}</>\n  if (options.length === 1) {\n    return (\n      <span className=\"flex items-center gap-1.5\">\n        <Dot className={DOTS.get(options[0].value) ?? \"bg-muted-foreground\"} />\n        {options[0].label}\n      </span>\n    )\n  }\n  return (\n    <span className=\"flex items-center gap-1.5\">\n      <span className=\"flex items-center\">\n        {options.slice(0, 4).map((option) => (\n          <span\n            key={option.value}\n            className={cn(\n              \"ring-background -ml-1 size-2.5 rounded-full ring-2 first:ml-0\",\n              DOTS.get(option.value) ?? \"bg-muted-foreground\"\n            )}\n          />\n        ))}\n      </span>\n      <span className=\"text-muted-foreground text-xs tabular-nums\">\n        {options.length}\n      </span>\n    </span>\n  )\n}\n\n/**\n * The same collapse, in priority's own glyph.\n *\n * Split from `StackedDots` rather than parameterised, because the two glyphs do\n * not stack the same way. A dot cuts itself out of its neighbour with `ring-2\n * ring-background`; a ring around a star draws a rounded RECTANGLE around the\n * icon's box, so these lean on each other far less instead and keep their\n * outlines readable.\n *\n * Priority is one field with one ladder, so this renderer, the table it reads\n * and the empty word it falls back to are kept identical here and in\n * c-filters-1. One concept, one implementation, across both examples. That is\n * also why the empty word is written in rather than taken as a prop the way\n * `StackedDots` above takes its: this renderer serves ONE field, that one\n * serves two.\n */\nfunction StackedStars({ options }: { options: FilterOption[] }) {\n  if (options.length === 0) return <>any priority</>\n  if (options.length === 1) {\n    const only = PRIORITIES.find((entry) => entry.value === options[0].value)\n    return (\n      <span className=\"flex items-center gap-1.5\">\n        <Star\n          className={cn(\"size-3.5\", only?.star ?? \"text-muted-foreground\")}\n        />\n        {options[0].label}\n      </span>\n    )\n  }\n  return (\n    <span className=\"flex items-center gap-1.5\">\n      <span className=\"flex items-center\">\n        {options.slice(0, 4).map((option) => {\n          const entry = PRIORITIES.find(\n            (candidate) => candidate.value === option.value\n          )\n          return (\n            <Star\n              key={option.value}\n              className={cn(\n                \"-ml-0.5 size-3.5 first:ml-0\",\n                entry?.star ?? \"text-muted-foreground\"\n              )}\n            />\n          )\n        })}\n      </span>\n      <span className=\"text-muted-foreground text-xs tabular-nums\">\n        {options.length}\n      </span>\n    </span>\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                   Schema                                   */\n/* -------------------------------------------------------------------------- */\n\n// One field per column, and no field without a column. That is what makes the\n// grid underneath a check on the query rather than an illustration beside it:\n// every chip narrows something the table prints, and nothing filters on an\n// attribute it does not.\n//\n// Printed is not the same as on screen, and this note used to claim it was. Six\n// columns of real content need about 830px, so in a card narrower than that the\n// tail of the row sits past the right edge, behind the scroll area's own\n// horizontal scrollbar: about one column short at the ~700px catalog width, two\n// in a 560px docs frame. The sizes below are each the widest line that column\n// actually holds, which is what keeps the overhang to the tail rather than a\n// third of the row.\n//\n// Icon names are written out per library rather than built by a helper: the\n// shadcn CLI rewrites these attributes to a real import at install time and\n// only accepts string literals, so a shared `icon(...)` factory would break\n// `shadcn add` for everyone installing this example.\nconst fields: FilterField[] = [\n  {\n    id: \"staff\",\n    label: \"Staff\",\n    type: \"text\",\n    icon: (\n      <IconPlaceholder\n        lucide=\"UserRoundIcon\"\n        tabler=\"IconUser\"\n        hugeicons=\"UserIcon\"\n        phosphor=\"UserIcon\"\n        remixicon=\"RiUserLine\"\n      />\n    ),\n  },\n  {\n    id: \"role\",\n    label: \"Occupation\",\n    type: \"select\",\n    // Arity, not the field's type, is what opens a multi-select editor: `is any\n    // of` takes many values, so a `select` field edits as a checklist under it.\n    defaultOperator: \"is_any_of\",\n    options: ROLES,\n    // Four rows are read, not searched. The input is hidden rather than\n    // removed: it owns focus and `aria-activedescendant`, so taking it out\n    // would take the keyboard with it, and typing still narrows the list.\n    searchable: false,\n    icon: (\n      <IconPlaceholder\n        lucide=\"BriefcaseBusinessIcon\"\n        tabler=\"IconBriefcase\"\n        hugeicons=\"Briefcase01Icon\"\n        phosphor=\"BriefcaseIcon\"\n        remixicon=\"RiBriefcaseLine\"\n      />\n    ),\n  },\n  {\n    // A person has ONE priority, so this is a select whose VALUE happens to be\n    // a list: `is any of` asks a question about that single rank, where the\n    // `has any of` a multiselect would default to asks about a set the row does\n    // not have.\n    id: \"priority\",\n    label: \"Priority\",\n    type: \"select\",\n    defaultOperator: \"is_any_of\",\n    options: PRIORITIES.map((tone) => ({\n      value: tone.value,\n      label: tone.label,\n      icon: <Star className={cn(\"size-3.5\", tone.star)} />,\n    })),\n    // No `searchable` key, so the search box stays. Four ranks are few enough\n    // to read, so this is the treatment on show rather than a necessity:\n    // Occupation, Status and Availability all hide their box, and Priority and\n    // Location are what keep the other half of that choice visible.\n    //\n    // And a box that stays gets a word of its own instead of the generic\n    // \"Search...\". The primitive will not compose one: a built in\n    // `Search ${label}...` is hardcoded English no `labels` override can reach,\n    // and lowercasing a label is locale hostile besides (Turkish dotted i,\n    // German nouns), so the wording belongs to the schema.\n    placeholder: \"Search priority...\",\n    renderValue: ({ options }) => <StackedStars options={options} />,\n    icon: (\n      <IconPlaceholder\n        lucide=\"StarIcon\"\n        tabler=\"IconStar\"\n        hugeicons=\"StarIcon\"\n        phosphor=\"StarIcon\"\n        remixicon=\"RiStarLine\"\n      />\n    ),\n  },\n  {\n    id: \"status\",\n    label: \"Status\",\n    type: \"select\",\n    defaultOperator: \"is_any_of\",\n    options: STATUSES.map((tone) => ({\n      value: tone.value,\n      label: tone.label,\n      icon: <Dot className={tone.dot} />,\n    })),\n    searchable: false,\n    renderValue: ({ options }) => (\n      <StackedDots options={options} empty=\"any status\" />\n    ),\n    icon: (\n      <IconPlaceholder\n        lucide=\"CircleDotIcon\"\n        tabler=\"IconCircleDot\"\n        hugeicons=\"RecordIcon\"\n        phosphor=\"CircleIcon\"\n        remixicon=\"RiRecordCircleLine\"\n      />\n    ),\n  },\n  {\n    id: \"availability\",\n    label: \"Availability\",\n    type: \"select\",\n    defaultOperator: \"is_any_of\",\n    options: AVAILABILITIES.map((tone) => ({\n      value: tone.value,\n      label: tone.label,\n      icon: <Dot className={tone.dot} />,\n    })),\n    searchable: false,\n    renderValue: ({ options }) => (\n      <StackedDots options={options} empty=\"any availability\" />\n    ),\n    icon: (\n      <IconPlaceholder\n        lucide=\"WifiIcon\"\n        tabler=\"IconWifi\"\n        hugeicons=\"Wifi01Icon\"\n        phosphor=\"WifiHighIcon\"\n        remixicon=\"RiWifiLine\"\n      />\n    ),\n  },\n  {\n    id: \"location\",\n    label: \"Location\",\n    type: \"select\",\n    defaultOperator: \"is_any_of\",\n    // Twelve countries, so this one keeps its search box: a list that long is\n    // typed at rather than scanned.\n    options: LOCATIONS,\n    placeholder: \"Search locations...\",\n    // A country list carries no order of its own, unlike the ladders above, so\n    // it is the one field here that asks for alphabetical.\n    //\n    // `sortSelected` stands on its own: this field does not set `pinSelected`,\n    // so there is one group and the whole menu reads A to Z. On a field that\n    // did stack its picks, the same setting would order each group instead.\n    // The ladders keep their schema order either way: sorting one would print\n    // Low, Medium, High, Urgent as High, Low, Medium, Urgent, and destroy the\n    // only thing the rank had going for it.\n    sortSelected: \"label\",\n    icon: (\n      <IconPlaceholder\n        lucide=\"GlobeIcon\"\n        tabler=\"IconWorld\"\n        hugeicons=\"Globe02Icon\"\n        phosphor=\"GlobeSimpleIcon\"\n        remixicon=\"RiGlobalLine\"\n      />\n    ),\n  },\n]\n\n/* -------------------------------------------------------------------------- */\n/*                          Query tree to row predicate                       */\n/* -------------------------------------------------------------------------- */\n\n/**\n * The value one field reads from one row.\n *\n * A lookup rather than `row[path]` because the Staff column prints a name AND\n * an email: a filter named after that column has to search both, or it hides a\n * row whose visible cell holds exactly what was typed.\n */\nfunction readField(row: Staff, path: string): unknown {\n  if (path === \"staff\") return `${row.name} ${row.email}`\n  return row[path as keyof Staff]\n}\n\n/**\n * Compiling the query to a row predicate.\n *\n * The primitive ships no compilers on purpose: every backend wants a different\n * shape, and a half-right SQL emitter is worse than none. What it does\n * guarantee is that the query is a plain serialisable tree, so a compiler is a\n * short recursive function like this one. Note it handles GROUPS as well as\n * rules, so the same code keeps working when nested groups are turned on.\n *\n * The switch covers the whole OPERATOR CATALOG rather than only the operators\n * this schema's six fields offer, which is why the numeric arms are here with\n * no number field in sight: the catalog is fixed, so a compiler written against\n * it is complete, and adding a Salary column later needs no change here.\n */\nfunction matchesRule(row: Staff, rule: FilterRule): boolean {\n  const actual = readField(row, rule.path[0])\n  const value = rule.value\n\n  const result = (() => {\n    switch (rule.operator) {\n      case \"contains\":\n        return String(actual)\n          .toLowerCase()\n          .includes(String(value).toLowerCase())\n      case \"not_contains\":\n        return !String(actual)\n          .toLowerCase()\n          .includes(String(value).toLowerCase())\n      case \"starts_with\":\n        return String(actual)\n          .toLowerCase()\n          .startsWith(String(value).toLowerCase())\n      case \"ends_with\":\n        return String(actual)\n          .toLowerCase()\n          .endsWith(String(value).toLowerCase())\n      case \"is\":\n      case \"eq\":\n        return String(actual) === String(value)\n      case \"is_not\":\n      case \"neq\":\n        return String(actual) !== String(value)\n      case \"is_any_of\":\n        return (value as string[] | undefined)?.includes(String(actual)) ?? true\n      case \"is_none_of\":\n        return !(\n          (value as string[] | undefined)?.includes(String(actual)) ?? false\n        )\n      case \"gt\":\n        return Number(actual) > Number(value)\n      case \"gte\":\n        return Number(actual) >= Number(value)\n      case \"lt\":\n        return Number(actual) < Number(value)\n      case \"lte\":\n        return Number(actual) <= Number(value)\n      case \"between\": {\n        const [from, to] = (value as number[] | undefined) ?? []\n        return Number(actual) >= Number(from) && Number(actual) <= Number(to)\n      }\n      case \"not_between\": {\n        const [from, to] = (value as number[] | undefined) ?? []\n        return !(Number(actual) >= Number(from) && Number(actual) <= Number(to))\n      }\n      case \"empty\":\n        return actual === undefined || actual === null || actual === \"\"\n      case \"not_empty\":\n        return !(actual === undefined || actual === null || actual === \"\")\n      default:\n        return true\n    }\n  })()\n\n  return rule.negated ? !result : result\n}\n\n/**\n * A rule with nothing to test yet, which matches everything.\n *\n * `undefined` is the obvious case: the grid must not empty out while a value is\n * still being chosen. The EMPTY LIST is the one that actually bit. Unchecking\n * the last option of a many-arity operator commits `[]`, not `undefined`, so\n * `Availability is any of` was reachable with an empty value, and there the chip\n * printed \"any availability\" while a literal `[].includes(...)` matched no row\n * at all: the same words as an unfinished rule, and the opposite result.\n *\n * An empty list is the absence of a constraint, exactly as the chip says. Fixing\n * it here rather than in the renderer is deliberate - the word is right, the\n * predicate was wrong.\n *\n * `empty` and `not_empty` are excluded because they carry no value: for those\n * two, having none is the whole test.\n */\nfunction isIncomplete(rule: FilterRule): boolean {\n  if (rule.operator === \"empty\" || rule.operator === \"not_empty\") return false\n  return (\n    rule.value === undefined ||\n    (Array.isArray(rule.value) && rule.value.length === 0)\n  )\n}\n\nfunction matches(row: Staff, node: FilterNode): boolean {\n  if (isFilterRule(node)) {\n    if (isIncomplete(node)) return true\n    return matchesRule(row, node)\n  }\n  if (node.rules.length === 0) return true\n  return node.combinator === \"and\"\n    ? node.rules.every((child) => matches(row, child))\n    : node.rules.some((child) => matches(row, child))\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                  The page                                  */\n/* -------------------------------------------------------------------------- */\n\nfunction initials(name: string) {\n  return name\n    .split(\" \")\n    .map((part) => part[0])\n    .join(\"\")\n}\n\n/** Where a value sits in its ladder. The sort key for the three ranked columns. */\nfunction rankIn(ladder: { value: string }[], value: string) {\n  return ladder.findIndex((entry) => entry.value === value)\n}\n\nexport function Pattern() {\n  const [query, setQuery] = useState<FilterQuery>(() =>\n    createFilterQuery([\n      createFilterRule({\n        id: \"seed-1\",\n        path: [\"status\"],\n        operator: \"is_any_of\",\n        value: [\"active\", \"invited\"],\n      }),\n    ])\n  )\n  const [pagination, setPagination] = useState<PaginationState>({\n    pageIndex: 0,\n    pageSize: 5,\n  })\n  const [sorting, setSorting] = useState<SortingState>([\n    { id: \"name\", desc: false },\n  ])\n\n  const rows = useMemo(\n    () => STAFF.filter((row) => matches(row, query)),\n    [query]\n  )\n\n  const columns = useMemo<ColumnDef<DataGridFeatures, Staff>[]>(\n    () => [\n      {\n        accessorKey: \"name\",\n        id: \"name\",\n        header: ({ column }) => (\n          <DataGridColumnHeader title=\"Staff\" column={column} />\n        ),\n        // `min-w-0` on the text block and `truncate` on both lines, because a\n        // flex child's default `min-width: auto` refuses to shrink below its\n        // content: without it the longest address here pushes out of its column\n        // and prints across Occupation instead of ending in an ellipsis.\n        cell: ({ row }) => (\n          <div className=\"flex items-center gap-2.5\">\n            <Avatar className=\"size-8 shrink-0\">\n              <AvatarImage src={row.original.avatar} alt={row.original.name} />\n              <AvatarFallback>{initials(row.original.name)}</AvatarFallback>\n            </Avatar>\n            <div className=\"min-w-0\">\n              <div className=\"text-foreground truncate font-medium\">\n                {row.original.name}\n              </div>\n              <div className=\"text-muted-foreground truncate text-xs\">\n                {row.original.email}\n              </div>\n            </div>\n          </div>\n        ),\n        size: 200,\n        enableSorting: true,\n      },\n      {\n        accessorKey: \"role\",\n        header: ({ column }) => (\n          <DataGridColumnHeader title=\"Occupation\" column={column} />\n        ),\n        // Wide enough for the longest title on ONE line. A column sized to the\n        // average wraps its outliers, and a wrapped cell is a taller row for\n        // every other row in the page.\n        cell: (info) => (\n          <span className=\"whitespace-nowrap\">{info.getValue() as string}</span>\n        ),\n        size: 155,\n        enableSorting: true,\n      },\n      {\n        // Sorted by the ladder's own order, not by the word. An `accessorKey`\n        // would sort a priority column High, Low, Medium, Urgent, which is\n        // alphabetical and meaningless; the three ranked columns here all sort\n        // on their rank and print their label from the row.\n        id: \"priority\",\n        accessorFn: (row) => rankIn(PRIORITIES, row.priority),\n        header: ({ column }) => (\n          <DataGridColumnHeader title=\"Priority\" column={column} />\n        ),\n        // `size-3.5`, the same star the menu row and the chip draw. Every other\n        // glyph here is already one size in both places - the flag is `size-4`\n        // in the option list and in its cell, the dot `size-2` in both - and a\n        // priority that changed size on its way from the filter to the row\n        // would be the only one that did.\n        cell: ({ row }) => {\n          const tone = PRIORITIES.find(\n            (entry) => entry.value === row.original.priority\n          )\n          return (\n            <span className=\"flex items-center gap-1.5\">\n              <Star className={cn(\"size-3.5\", tone?.star)} />\n              {tone?.label}\n            </span>\n          )\n        },\n        size: 110,\n        enableSorting: true,\n      },\n      {\n        id: \"status\",\n        accessorFn: (row) => rankIn(STATUSES, row.status),\n        header: ({ column }) => (\n          <DataGridColumnHeader title=\"Status\" column={column} />\n        ),\n        // A badge here and a bare swatch in the menu, deliberately: the option\n        // row prints the word beside the swatch already, so a badge there would\n        // say \"Invited\" twice, while a cell holding nothing but the word needs\n        // the box to keep it from reading as ordinary text.\n        cell: ({ row }) => {\n          const tone = STATUSES.find(\n            (entry) => entry.value === row.original.status\n          )\n          return (\n            <Badge variant=\"outline\" className=\"gap-1.5\">\n              <Dot className={tone?.dot ?? \"bg-muted-foreground\"} />\n              {tone?.label}\n            </Badge>\n          )\n        },\n        size: 110,\n        enableSorting: true,\n      },\n      {\n        id: \"availability\",\n        accessorFn: (row) => rankIn(AVAILABILITIES, row.availability),\n        header: ({ column }) => (\n          <DataGridColumnHeader title=\"Availability\" column={column} />\n        ),\n        cell: ({ row }) => {\n          const tone = AVAILABILITIES.find(\n            (entry) => entry.value === row.original.availability\n          )\n          return (\n            <span className=\"flex items-center gap-1.5\">\n              <Dot className={tone?.dot ?? \"bg-muted-foreground\"} />\n              {tone?.label}\n            </span>\n          )\n        },\n        size: 115,\n        enableSorting: true,\n      },\n      {\n        accessorKey: \"location\",\n        header: ({ column }) => (\n          <DataGridColumnHeader title=\"Location\" column={column} />\n        ),\n        cell: ({ row }) => (\n          <span className=\"flex items-center gap-1.5\">\n            <Flag code={row.original.flag} />\n            {row.original.location}\n          </span>\n        ),\n        size: 140,\n        enableSorting: true,\n      },\n    ],\n    []\n  )\n\n  const table = useTable({\n    features: dataGridFeatures,\n    columns,\n    // The FILTERED rows, not the full set, so sorting, paging and the record\n    // count are all taken over what the query left standing.\n    data: rows,\n    pageCount: Math.ceil(rows.length / pagination.pageSize),\n    getRowId: (row: Staff) => row.id,\n    state: { pagination, sorting },\n    onPaginationChange: setPagination,\n    onSortingChange: setSorting,\n  })\n\n  return (\n    <DataGrid\n      table={table}\n      recordCount={rows.length}\n      emptyMessage=\"No staff match these filters\"\n      // Dense, because the toolbar above it is the subject and the rows are the\n      // evidence: five rows at the default rung cost about 60px more, and this\n      // whole card has to hold its height inside one authored frame.\n      //\n      // Density is NOT what keeps the pager visible, and the note here used to\n      // say it was. `DataGridPagination` goes single row at `sm`, which is a\n      // VIEWPORT query, not a container one, so in any frame narrower than\n      // 640px it stacks its three parts however short the rows are: page\n      // buttons, record range, then rows-per-page, about 140px of it. Nothing\n      // this example can pass shortens that, so the example's `previewHeight`\n      // is what pays for it, and 360 was never enough.\n      tableLayout={{ dense: true }}\n    >\n      <Card className=\"w-full gap-0 py-0\">\n        <div className=\"border-b p-3\">\n          <Filters\n            fields={fields}\n            query={query}\n            onQueryChange={(next) => {\n              setQuery(next)\n              // Back to page one on every edit. A narrower query can leave the\n              // current page past the end of the result set, and a grid drawing\n              // nothing under a pager that reports rows is worse than a grid\n              // that moved.\n              setPagination((current) => ({ ...current, pageIndex: 0 }))\n            }}\n            showClear\n          />\n        </div>\n\n        <DataGridContainer>\n          <DataGridScrollArea>\n            <DataGridTable />\n          </DataGridScrollArea>\n        </DataGridContainer>\n\n        <div className=\"border-t p-3\">\n          {/*\n            Four overrides, and every one of them undoes the same bug.\n            `DataGridPagination` goes single row at `sm`, which is a VIEWPORT\n            query rather than a container one, so inside a card narrower than\n            640px it stacks its three parts however wide the card itself is:\n            page buttons, then the record range, then rows-per-page, reversed by\n            `order-*` and about 140px tall. In a preview frame that is most of\n            the height this example has to spend, and a pager nobody can see\n            cannot show that the query changed the page count.\n              flex-row py-0      the parent's own axis and padding. Plain\n                                 utilities, because the primitive merges this\n                                 className through `cn`, so tailwind-merge drops\n                                 the `flex-col` and `py-2.5` it replaces.\n              **:order-none!     source order at every depth. Two levels need\n                                 it, the two halves of the bar and the range\n                                 against the buttons inside the right half.\n              *:py-0!            the two halves' own stacking padding.\n              *:last:flex-row!   the right half, which stacks internally too.\n            The three `!` are load-bearing: those classes live on the\n            primitive's own children, out of tailwind-merge's reach, so nothing\n            else can win against them.\n\n            `flex-wrap` is left alone deliberately, so this degrades rather than\n            breaks: on a genuinely narrow phone the two halves wrap to two rows\n            instead of forcing a 400px row into 375px.\n          */}\n          <DataGridPagination\n            sizes={[5, 10, 25]}\n            className=\"flex-row py-0 **:order-none! *:py-0! *:last:flex-row!\"\n          />\n        </div>\n      </Card>\n    </DataGrid>\n  )\n}","target":"components/examples/c-filters-7.tsx"}],"meta":{"order":7,"gridSize":1,"previewHeight":"520","label":"Table Filtering"}}