Skip to contentVibraUI
Inputs & filters

Filter builder

Rules of field, operator and value joined by AND or OR, with one level of groups; applyFilter runs them on your rows and the address carries them.

A filter is data — rules of field, operator and value, joined by AND or OR, with one level of groups for the other join — and the builder only edits it: hand it fields and keep the FilterValue it reports. Each field type has its own operators (OPERATORS) and its own control for the value: an Input for text, a NumberInput for a number or a count of days, a DatePicker for a day, two of either for "is between", a NativeSelect of the options for "is" and a MultiSelect for "is any of", and nothing for a boolean or "is empty". Another operator keeps a value of the same shape — "is greater than" to "is less than" keeps the number — and another field starts the rule over. A new group answers the other way from the filter around it. applyFilter and matchesFilter run the filter on your rows: text without case, numbers only when finite, dates by their UTC day, between inclusive either way round. Pass get when a field is not a plain key of the row, and now — REFERENCE_DATE on a page — for "is in the last N days", which reads the clock only when it is given no day. An unfinished rule — no value yet, a blank one, half a range — is ignored rather than matching nothing, a group none of whose rules is finished has no say, and describeFilter leaves both out of the one sentence it writes for a summary. filterToParam writes the filter as one base64url word for ?filter=; the server reads it back with filterFromParam(param, fields), which drops a rule on a field you do not offer, with an operator its type does not take, an option the field does not have or a value of the wrong shape, and any group nested deeper than one level, gives every rule it keeps a fresh id, and answers null for anything that is not a filter — then runs what is left with applyFilter on the rows it looked up itself. Import those from filter-builder/filter-url and filter-builder/filter in server code: the folder's index is a client module. At maxRules both add buttons stay in the tab order, aria-disabled and described by "Up to 20 rules", so the reason is heard rather than guessed. The focus follows the work: into the Field of a rule just added, onto the nearest Add rule when one is removed. Rows wrap at a phone's width, the value under its field and operator. The root is a group, so an aria-label names the whole filter.

Install

npx shadcn@latest add @vibra/filter-builder

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
fields({ id: string; label: string; type: "text" | "number" | "date" | "boolean" } | { id: string; label: string; type: "enum"; options: { value: string; label: string }[] })[]—What a rule can name, in the order the Field select lists them; a new rule starts on the first.
value{ combinator: "and" | "or"; rules: (FilterRule | FilterGroup)[] }—The filter, when you hold it; controlled when given. A rule is { id, field, operator, value? }, a group { id, combinator, rules }.
defaultValueFilterValueEMPTY_FILTERWhere a builder that holds its own filter starts.
onValueChange(value: FilterValue) => void—Every change, unfinished rules included — applyFilter ignores those, so the rows never empty while a rule is being written.
maxRulesnumber20How many rules the filter may hold, those in groups counted one by one; at the limit the add buttons say "Up to N rules".
size"sm" | "default""default"sm drops every control to h-7 for a dense toolbar.
classNamestring—Merged onto the root; the rest of the div's props are spread onto it.

Dependencies

Source

components/ui/filter-builder/index.tsx
"use client"

import * as React from "react"
import { PlusIcon, Trash2Icon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
  EMPTY_FILTER,
  OPERATORS,
  applyFilter,
  countRules,
  describeFilter,
  isComplete,
  isFilterGroup,
  matchesFilter,
  type FilterField,
  type FilterGroup,
  type FilterOperator,
  type FilterRule,
  type FilterValue,
} from "@/components/ui/filter-builder/filter"
import { filterFromParam, filterToParam } from "@/components/ui/filter-builder/filter-url"
import { RuleRow } from "@/components/ui/filter-builder/rule-row"
import { SegmentedControl } from "@/components/ui/segmented-control"

// One import builds the builder and runs what it makes. A server module reads
// a filter from filter-builder/filter-url and runs it with
// filter-builder/filter, neither of which has a client boundary.
export { EMPTY_FILTER, OPERATORS, applyFilter, countRules, describeFilter, filterFromParam, filterToParam, isComplete, isFilterGroup, matchesFilter }
export type { FilterField, FilterGroup, FilterOperator, FilterRule, FilterValue }

export type FilterBuilderProps = Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> & {
  fields: FilterField[]
  value?: FilterValue
  defaultValue?: FilterValue
  onValueChange?: (value: FilterValue) => void
  maxRules?: number
  size?: "sm" | "default"
}

type Combinator = FilterValue["combinator"]

let made = 0
/** An id for a rule or a group the builder adds — never one filterFromParam hands out ("rule-1"). */
const newId = () => `fb-${++made}`

const TOP_ADD = '[data-slot="filter-builder-add"][data-scope="filter"]'

/** "Match [All | Any] of these rules": the AND or the OR of one list. */
function Match({
  value,
  onChange,
  size,
  children,
}: {
  value: Combinator
  onChange: (value: Combinator) => void
  size: "sm" | "default"
  children: React.ReactNode
}) {
  const id = React.useId()
  return (
    <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
      <span id={id} className="font-medium text-foreground">
        Match
      </span>
      <SegmentedControl
        aria-labelledby={id}
        size={size}
        options={[
          { value: "and", label: "All" },
          { value: "or", label: "Any" },
        ]}
        value={value}
        onValueChange={(next) => onChange(next as Combinator)}
      />
      <span>{children}</span>
    </div>
  )
}

/**
 * Rules of field, operator and value, joined by AND or OR, with one level of
 * groups for the other one: "Value is over 5,000 and (Status is Won or Name
 * starts with lu)". Each value is typed in the kit's own control for its
 * field's type. Controlled when `value` is given.
 */
function FilterBuilder({
  fields,
  value,
  defaultValue,
  onValueChange,
  maxRules = 20,
  size = "default",
  className,
  ...props
}: FilterBuilderProps) {
  const controlled = value !== undefined
  const [internal, setInternal] = React.useState<FilterValue>(defaultValue ?? EMPTY_FILTER)
  const filter = controlled ? value : internal
  const limitId = React.useId()
  const rootRef = React.useRef<HTMLDivElement>(null)

  // Where the focus goes once a change has drawn: into a rule just added, or
  // onto the Add rule a removed rule leaves nearest — never the page. Kept
  // with the filter it was asked for and acted on only when that filter is
  // the one drawn, so a caller that held on to its own value moves nobody's
  // focus later.
  const [focusAfter, setFocusAfter] = React.useState<{ selector: string; filter: FilterValue } | null>(null)
  React.useEffect(() => {
    if (focusAfter?.filter !== filter) return
    rootRef.current?.querySelector<HTMLElement>(focusAfter.selector)?.focus()
  }, [filter, focusAfter])

  const full = countRules(filter) >= maxRules
  const buttonSize = size === "sm" ? "sm" : "default"

  function change(next: FilterValue, focus?: string) {
    setFocusAfter(focus ? { selector: focus, filter: next } : null)
    if (!controlled) setInternal(next)
    onValueChange?.(next)
  }

  const newRule = (): FilterRule => ({ id: newId(), field: fields[0].id, operator: OPERATORS[fields[0].type][0].value })
  const firstControlOf = (rule: FilterRule) => `[data-rule="${rule.id}"] select`

  /** The filter with one group's rules changed; a group left with none goes too. */
  function withList(group: string, edit: (rules: FilterRule[]) => FilterRule[]): FilterValue {
    return {
      ...filter,
      rules: filter.rules.flatMap((entry) => {
        if (entry.id !== group || !isFilterGroup(entry)) return [entry]
        const rules = edit(entry.rules)
        return rules.length > 0 ? [{ ...entry, rules }] : []
      }),
    }
  }

  function addRule(group?: string) {
    if (full) return
    const rule = newRule()
    const next = group === undefined ? { ...filter, rules: [...filter.rules, rule] } : withList(group, (rules) => [...rules, rule])
    change(next, firstControlOf(rule))
  }

  function addGroup() {
    if (full) return
    const rule = newRule()
    // A group is for the other join: inside an AND it answers with an OR.
    const group: FilterGroup = { id: newId(), combinator: filter.combinator === "and" ? "or" : "and", rules: [rule] }
    change({ ...filter, rules: [...filter.rules, group] }, firstControlOf(rule))
  }

  function updateRule(group: string | undefined, next: FilterRule) {
    if (group !== undefined) return change(withList(group, (rules) => rules.map((rule) => (rule.id === next.id ? next : rule))))
    change({ ...filter, rules: filter.rules.map((entry) => (entry.id === next.id ? next : entry)) })
  }

  function removeRule(group: string | undefined, id: string) {
    if (group === undefined) return change({ ...filter, rules: filter.rules.filter((entry) => entry.id !== id) }, TOP_ADD)
    const next = withList(group, (rules) => rules.filter((rule) => rule.id !== id))
    const stays = next.rules.some((entry) => entry.id === group)
    change(next, stays ? `[data-group="${group}"] [data-slot="filter-builder-add"]` : TOP_ADD)
  }

  function addButton(label: string, onClick: () => void, scope: string) {
    return (
      <Button
        type="button"
        variant="outline"
        size={buttonSize}
        data-slot="filter-builder-add"
        data-scope={scope}
        // Still in the tab order when the filter is full, so the reason is heard.
        disabled={full}
        focusableWhenDisabled
        aria-describedby={full ? limitId : undefined}
        onClick={onClick}
      >
        <PlusIcon data-icon="inline-start" />
        {label}
      </Button>
    )
  }

  const groupNumbers = new Map(filter.rules.filter(isFilterGroup).map((group, index) => [group.id, index + 1]))

  return (
    <div
      ref={rootRef}
      // A group, so the aria-label a caller gives names the whole filter.
      role="group"
      data-slot="filter-builder"
      data-size={size}
      data-combinator={filter.combinator}
      data-full={full || undefined}
      className={cn("flex w-full min-w-0 flex-col gap-3", className)}
      {...props}
    >
      <Match value={filter.combinator} onChange={(combinator) => change({ ...filter, combinator })} size={size}>
        of these rules
      </Match>

      {filter.rules.length > 0 ? (
        <ol className="flex flex-col gap-2">
          {filter.rules.map((entry) => {
            if (!isFilterGroup(entry)) {
              return (
                <li key={entry.id}>
                  <RuleRow
                    rule={entry}
                    fields={fields}
                    size={size}
                    onChange={(next) => updateRule(undefined, next)}
                    onRemove={() => removeRule(undefined, entry.id)}
                  />
                </li>
              )
            }
            return (
              <li key={entry.id}>
                <div
                  role="group"
                  aria-label={`Group ${groupNumbers.get(entry.id)}`}
                  data-slot="filter-builder-group"
                  data-group={entry.id}
                  data-combinator={entry.combinator}
                  // The group is a plane of its own inside the builder, not a box drawn round it.
                  className="flex flex-col gap-2 rounded-lg bg-muted/50 p-2.5"
                >
                  <div className="flex items-start gap-2">
                    <Match
                      value={entry.combinator}
                      onChange={(combinator) =>
                        change({ ...filter, rules: filter.rules.map((other) => (other.id === entry.id ? { ...entry, combinator } : other)) })
                      }
                      size={size}
                    >
                      of the rules in this group
                    </Match>
                    <Button
                      type="button"
                      variant="ghost"
                      size={size === "sm" ? "icon-xs" : "icon-sm"}
                      aria-label="Remove group"
                      onClick={() => change({ ...filter, rules: filter.rules.filter((other) => other.id !== entry.id) }, TOP_ADD)}
                      className="ms-auto text-muted-foreground"
                    >
                      <Trash2Icon />
                    </Button>
                  </div>
                  <ol className="flex flex-col gap-2">
                    {entry.rules.map((rule) => (
                      <li key={rule.id}>
                        <RuleRow
                          rule={rule}
                          fields={fields}
                          size={size}
                          onChange={(next) => updateRule(entry.id, next)}
                          onRemove={() => removeRule(entry.id, rule.id)}
                        />
                      </li>
                    ))}
                  </ol>
                  <div>{addButton("Add rule", () => addRule(entry.id), "group")}</div>
                </div>
              </li>
            )
          })}
        </ol>
      ) : (
        <p className="text-sm text-muted-foreground">No rules yet, so every row shows.</p>
      )}

      <div className="flex flex-wrap items-center gap-2">
        {addButton("Add rule", () => addRule(), "filter")}
        {addButton("Add group", addGroup, "filter-group")}
        {full ? (
          <span id={limitId} className="text-xs text-muted-foreground">
            Up to {maxRules} {maxRules === 1 ? "rule" : "rules"}
          </span>
        ) : null}
      </div>
    </div>
  )
}

export { FilterBuilder }
components/ui/filter-builder/rule-row.tsx
"use client"

import * as React from "react"
import { XIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { DatePicker } from "@/components/ui/date-picker"
import {
  OPERATORS,
  type FilterField,
  type FilterOperator,
  type FilterRule,
} from "@/components/ui/filter-builder/filter"
import { Input } from "@/components/ui/input"
import { MultiSelect } from "@/components/ui/multi-select"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { NumberInput } from "@/components/ui/number-input"

type Size = "sm" | "default"
type Operator = (typeof OPERATORS)[FilterField["type"]][number]

/** A rule's day ("2026-09-20") as the UTC midnight DatePicker shows and hands back. */
const dayToDate = (day: unknown) => (typeof day === "string" && /^\d{4}-\d{2}-\d{2}$/.test(day) ? new Date(`${day}T00:00:00Z`) : undefined)
const dateToDay = (date: Date | undefined) => (date ? date.toISOString().slice(0, 10) : "")
const finite = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : null)

// Half of a range may be set before the other: the missing side waits as NaN
// for a number and "" for a day, which isComplete reads as unfinished, so the
// half-set rule is ignored and still shows what was typed.

/** The control a rule's value is typed or picked in, by its field's type and how many values its operator takes. */
function RuleValue({
  rule,
  field,
  operator,
  size,
  onValue,
}: {
  rule: FilterRule
  field: FilterField
  operator: Operator
  size: Size
  onValue: (value: FilterRule["value"]) => void
}) {
  const value = rule.value
  const box = "min-w-0 flex-[3_1_12rem]"
  if (operator.arity === 0) return null

  if (field.type === "text") {
    return (
      <Input
        aria-label="Value"
        placeholder="Value"
        value={typeof value === "string" ? value : ""}
        onChange={(event) => onValue(event.target.value === "" ? undefined : event.target.value)}
        className={cn(box, size === "sm" && "h-7")}
      />
    )
  }

  if (field.type === "enum") {
    if (operator.arity === "many") {
      return (
        <MultiSelect
          aria-label="Value"
          size={size}
          options={field.options}
          value={Array.isArray(value) ? (value as string[]) : []}
          onValueChange={(chosen) => onValue(chosen.length > 0 ? chosen : undefined)}
          placeholder="Choose options"
          className={box}
        />
      )
    }
    return (
      <NativeSelect
        aria-label="Value"
        size={size}
        value={typeof value === "string" ? value : ""}
        onChange={(event) => onValue(event.target.value || undefined)}
        className={box}
      >
        <NativeSelectOption value="" disabled>
          Choose…
        </NativeSelectOption>
        {field.options.map((option) => (
          <NativeSelectOption key={option.value} value={option.value}>
            {option.label}
          </NativeSelectOption>
        ))}
      </NativeSelect>
    )
  }

  if (operator.arity === 2) {
    const [from, to] = Array.isArray(value) ? value : []
    const empty = (side: number | string) => side === "" || (typeof side === "number" && Number.isNaN(side))
    const both = (next: [number, number] | [string, string]) => onValue(empty(next[0]) && empty(next[1]) ? undefined : next)
    return (
      <div className={cn("flex items-center gap-2", box)}>
        {field.type === "number" ? (
          <>
            <NumberInput aria-label="From" size={size} value={finite(from)} onValueChange={(next) => both([next ?? NaN, finite(to) ?? NaN])} className="min-w-0 flex-1" />
            <span className="text-sm text-muted-foreground">and</span>
            <NumberInput aria-label="To" size={size} value={finite(to)} onValueChange={(next) => both([finite(from) ?? NaN, next ?? NaN])} className="min-w-0 flex-1" />
          </>
        ) : (
          <>
            <DatePicker aria-label="From" size={size} format="MMM d, yyyy" value={dayToDate(from)} onValueChange={(date) => both([dateToDay(date), typeof to === "string" ? to : ""])} className="min-w-0 flex-1" />
            <span className="text-sm text-muted-foreground">and</span>
            <DatePicker aria-label="To" size={size} format="MMM d, yyyy" value={dayToDate(to)} onValueChange={(date) => both([typeof from === "string" ? from : "", dateToDay(date)])} className="min-w-0 flex-1" />
          </>
        )}
      </div>
    )
  }

  if (field.type === "number" || rule.operator === "in_last_days") {
    const days = rule.operator === "in_last_days"
    return (
      <NumberInput
        aria-label="Value"
        size={size}
        value={finite(value)}
        onValueChange={(next) => onValue(next ?? undefined)}
        min={days ? 1 : undefined}
        precision={days ? 0 : undefined}
        suffix={days ? "days" : undefined}
        className={box}
      />
    )
  }

  return (
    <DatePicker
      aria-label="Value"
      size={size}
      format="MMM d, yyyy"
      value={dayToDate(value)}
      onValueChange={(date) => onValue(date ? dateToDay(date) : undefined)}
      className={box}
    />
  )
}

/** One rule: its field, its operator, its value, and the button that takes it out. */
export function RuleRow({
  rule,
  fields,
  size,
  onChange,
  onRemove,
}: {
  rule: FilterRule
  fields: FilterField[]
  size: Size
  onChange: (rule: FilterRule) => void
  onRemove: () => void
}) {
  const field = fields.find((entry) => entry.id === rule.field) ?? fields[0]
  const operators = OPERATORS[field.type]
  const operator = operators.find((entry) => entry.value === rule.operator) ?? operators[0]

  // Another field starts the rule over; another operator keeps a value of the
  // same shape — "is greater than" to "is less than" keeps the number.
  function chooseField(id: string) {
    const next = fields.find((entry) => entry.id === id) ?? fields[0]
    onChange({ id: rule.id, field: next.id, operator: OPERATORS[next.type][0].value })
  }
  function chooseOperator(value: FilterOperator) {
    const next = operators.find((entry) => entry.value === value) ?? operators[0]
    const keeps = next.arity === operator.arity && (next.value === "in_last_days") === (operator.value === "in_last_days")
    onChange(keeps && rule.value !== undefined ? { ...rule, operator: next.value } : { id: rule.id, field: rule.field, operator: next.value })
  }

  // The bases put the field and the operator on one line and the value on the
  // next at a phone's width, all three on one line from about 500px, and each
  // grows into the room that is left — the value most.
  return (
    <div data-slot="filter-builder-rule" data-rule={rule.id} data-type={field.type} className="flex flex-wrap items-center gap-2">
      <NativeSelect aria-label="Field" size={size} value={field.id} onChange={(event) => chooseField(event.target.value)} className="min-w-0 flex-[1_1_7rem]">
        {fields.map((entry) => (
          <NativeSelectOption key={entry.id} value={entry.id}>
            {entry.label}
          </NativeSelectOption>
        ))}
      </NativeSelect>
      <NativeSelect
        aria-label="Operator"
        size={size}
        value={operator.value}
        onChange={(event) => chooseOperator(event.target.value as FilterOperator)}
        className="min-w-0 flex-[1_1_9rem]"
      >
        {operators.map((entry) => (
          <NativeSelectOption key={entry.value} value={entry.value}>
            {entry.label}
          </NativeSelectOption>
        ))}
      </NativeSelect>
      <RuleValue
        rule={rule}
        field={field}
        operator={operator}
        size={size}
        onValue={(value) => onChange(value === undefined ? { id: rule.id, field: rule.field, operator: rule.operator } : { ...rule, value })}
      />
      <Button
        type="button"
        variant="ghost"
        size={size === "sm" ? "icon-xs" : "icon-sm"}
        aria-label="Remove rule"
        onClick={onRemove}
        className="ms-auto text-muted-foreground"
      >
        <XIcon />
      </Button>
    </div>
  )
}
components/ui/filter-builder/filter.ts
import { formatDate, formatNumber } from "@/lib/format"

// A filter as data: what the builder edits, what applyFilter runs, what the
// address carries. No React here, so a server action can run the filter it
// read back with filterFromParam against the rows it looked up.

export type FilterField =
  | { id: string; label: string; type: "text" | "number" | "date" | "boolean" }
  | { id: string; label: string; type: "enum"; options: { value: string; label: string }[] }

export type FilterOperator =
  | "contains" | "not_contains" | "equals" | "not_equals" | "starts_with" | "is_empty" | "is_not_empty"
  | "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "between"
  | "before" | "after" | "on" | "in_last_days"
  | "is" | "is_not" | "is_any_of" | "is_none_of"
  | "is_true" | "is_false"

export type FilterRule = {
  id: string
  field: string
  operator: FilterOperator
  value?: string | number | [number, number] | [string, string] | string[]
}
export type FilterGroup = { id: string; combinator: "and" | "or"; rules: FilterRule[] }
export type FilterValue = { combinator: "and" | "or"; rules: (FilterRule | FilterGroup)[] }

type OperatorDoc = { value: FilterOperator; label: string; arity: 0 | 1 | 2 | "many" }

/** What each type of field can be asked, in the words a summary reads them with, and how many values each takes. */
export const OPERATORS: Record<FilterField["type"], OperatorDoc[]> = {
  text: [
    { value: "contains", label: "contains", arity: 1 },
    { value: "not_contains", label: "does not contain", arity: 1 },
    { value: "equals", label: "is", arity: 1 },
    { value: "not_equals", label: "is not", arity: 1 },
    { value: "starts_with", label: "starts with", arity: 1 },
    { value: "is_empty", label: "is empty", arity: 0 },
    { value: "is_not_empty", label: "is not empty", arity: 0 },
  ],
  number: [
    { value: "eq", label: "equals", arity: 1 },
    { value: "neq", label: "does not equal", arity: 1 },
    { value: "gt", label: "is greater than", arity: 1 },
    { value: "gte", label: "is at least", arity: 1 },
    { value: "lt", label: "is less than", arity: 1 },
    { value: "lte", label: "is at most", arity: 1 },
    { value: "between", label: "is between", arity: 2 },
  ],
  date: [
    { value: "on", label: "is on", arity: 1 },
    { value: "before", label: "is before", arity: 1 },
    { value: "after", label: "is after", arity: 1 },
    { value: "between", label: "is between", arity: 2 },
    { value: "in_last_days", label: "is in the last", arity: 1 },
  ],
  enum: [
    { value: "is", label: "is", arity: 1 },
    { value: "is_not", label: "is not", arity: 1 },
    { value: "is_any_of", label: "is any of", arity: "many" },
    { value: "is_none_of", label: "is none of", arity: "many" },
  ],
  boolean: [
    { value: "is_true", label: "is true", arity: 0 },
    { value: "is_false", label: "is false", arity: 0 },
  ],
}

/** No rules: every row matches. */
export const EMPTY_FILTER: FilterValue = { combinator: "and", rules: [] }

export function isFilterGroup(entry: FilterRule | FilterGroup): entry is FilterGroup {
  return "rules" in entry
}

const DAY = 86_400_000

const isNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value)

/** "2026-09-20" as a count of UTC days, or null when it is no day on the calendar ("2026-02-30"). */
function dayOfIso(value: unknown): number | null {
  const match = typeof value === "string" ? value.match(/^(\d{4})-(\d{2})-(\d{2})$/) : null
  if (!match) return null
  const [year, month, day] = match.slice(1).map(Number)
  const date = new Date(Date.UTC(year, month - 1, day))
  return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? date.getTime() / DAY : null
}

/** A row's date — a Date, an ISO string or epoch milliseconds — as its UTC day. */
function dayOfCell(value: unknown): number | null {
  const time = value instanceof Date ? value.getTime() : typeof value === "string" || typeof value === "number" ? new Date(value).getTime() : NaN
  return Number.isFinite(time) ? Math.floor(time / DAY) : null
}

/**
 * Whether a rule says enough to filter by: an operator its field's type
 * offers, and a value of the shape that operator takes — finite numbers,
 * real days, words that are not blank, an option the field has.
 */
export function isComplete(rule: FilterRule, field: FilterField): boolean {
  const operator = OPERATORS[field.type].find((entry) => entry.value === rule.operator)
  if (!operator) return false
  const { value } = rule
  const known = (option: unknown) => field.type === "enum" && field.options.some((entry) => entry.value === option)
  if (operator.arity === 0) return true
  if (operator.arity === "many") return Array.isArray(value) && value.length > 0 && value.every(known)
  if (operator.arity === 2) {
    if (!Array.isArray(value) || value.length !== 2) return false
    return field.type === "number" ? value.every(isNumber) : value.every((day) => dayOfIso(day) !== null)
  }
  if (field.type === "text") return typeof value === "string" && value.trim() !== ""
  if (field.type === "number") return isNumber(value)
  if (field.type === "enum") return known(value)
  if (rule.operator === "in_last_days") return Number.isInteger(value) && (value as number) >= 1
  return dayOfIso(value) !== null
}

type Read<T> = { fields: Map<string, FilterField>; get: (row: T, field: string) => unknown; today: () => number }

/** One rule against one row: true or false, or null when the rule is unfinished and has no say. */
function ruleMatches<T>(row: T, rule: FilterRule, read: Read<T>): boolean | null {
  const field = read.fields.get(rule.field)
  if (!field || !isComplete(rule, field)) return null
  const cell = read.get(row, field.id)
  const value = rule.value
  switch (field.type) {
    case "text": {
      const text = cell == null ? "" : String(cell).toLowerCase()
      const wanted = typeof value === "string" ? value.trim().toLowerCase() : ""
      if (rule.operator === "is_empty") return text.trim() === ""
      if (rule.operator === "is_not_empty") return text.trim() !== ""
      if (rule.operator === "contains") return text.includes(wanted)
      if (rule.operator === "not_contains") return !text.includes(wanted)
      if (rule.operator === "starts_with") return text.startsWith(wanted)
      return rule.operator === "equals" ? text.trim() === wanted : text.trim() !== wanted
    }
    case "number": {
      const number = typeof cell === "number" ? cell : typeof cell === "string" && cell.trim() !== "" ? Number(cell) : NaN
      if (!Number.isFinite(number)) return false
      if (rule.operator === "between") {
        const [from, to] = value as [number, number]
        return number >= Math.min(from, to) && number <= Math.max(from, to)
      }
      const target = value as number
      const compare = { eq: number === target, neq: number !== target, gt: number > target, gte: number >= target, lt: number < target, lte: number <= target }
      return compare[rule.operator as keyof typeof compare]
    }
    case "date": {
      const day = dayOfCell(cell)
      if (day === null) return false
      if (rule.operator === "in_last_days") {
        const today = read.today()
        return day > today - (value as number) && day <= today
      }
      if (rule.operator === "between") {
        const [from, to] = (value as [string, string]).map((iso) => dayOfIso(iso)!)
        return day >= Math.min(from, to) && day <= Math.max(from, to)
      }
      const target = dayOfIso(value)!
      return rule.operator === "on" ? day === target : rule.operator === "before" ? day < target : day > target
    }
    case "enum": {
      const option = cell == null ? null : String(cell)
      if (rule.operator === "is") return option === value
      if (rule.operator === "is_not") return option !== value
      const listed = option !== null && (value as string[]).includes(option)
      return rule.operator === "is_any_of" ? listed : !listed
    }
    case "boolean":
      return rule.operator === "is_true" ? cell === true : cell !== true
  }
}

/** Joins the verdicts that have a say; with none, the whole has none either. */
function combine(verdicts: (boolean | null)[], combinator: "and" | "or"): boolean | null {
  const said = verdicts.filter((verdict): verdict is boolean => verdict !== null)
  if (said.length === 0) return null
  return combinator === "and" ? said.every(Boolean) : said.some(Boolean)
}

function matches<T>(row: T, filter: FilterValue, read: Read<T>): boolean {
  const verdicts = filter.rules.map((entry) =>
    isFilterGroup(entry) ? combine(entry.rules.map((rule) => ruleMatches(row, rule, read)), entry.combinator) : ruleMatches(row, entry, read)
  )
  return combine(verdicts, filter.combinator) ?? true
}

type MatchOptions<T> = { now?: Date; get?: (row: T, field: string) => unknown }

function reader<T>(fields: FilterField[], options: MatchOptions<T>): Read<T> {
  let today: number | undefined
  return {
    fields: new Map(fields.map((field) => [field.id, field])),
    get: options.get ?? ((row, field) => (row as Record<string, unknown>)[field]),
    // The clock is read only when a rule counts days back from today, and only
    // when the caller gave no `now` — a page passes REFERENCE_DATE.
    today: () => (today ??= Math.floor((options.now ?? new Date()).getTime() / DAY)),
  }
}

/**
 * Whether one row passes the filter. An unfinished rule is ignored rather
 * than matching nothing, a group with no finished rule has no say, and a
 * filter with no say at all lets every row through.
 */
export function matchesFilter<T>(row: T, filter: FilterValue, fields: FilterField[], options: MatchOptions<T> = {}): boolean {
  return matches(row, filter, reader(fields, options))
}

/** The rows that pass the filter, in their own order. */
export function applyFilter<T>(rows: T[], filter: FilterValue, fields: FilterField[], options: MatchOptions<T> = {}): T[] {
  const read = reader(fields, options)
  return rows.filter((row) => matches(row, filter, read))
}

/** A finished rule in words: "Value is greater than 5,000", "Name starts with “lu”". */
function ruleWords(rule: FilterRule, field: FilterField): string {
  const operator = OPERATORS[field.type].find((entry) => entry.value === rule.operator)!
  const value = rule.value
  const day = (iso: string) => formatDate(iso, "medium", { timeZone: "UTC" })
  const option = (wanted: string) => (field.type === "enum" ? field.options.find((entry) => entry.value === wanted)?.label : undefined) ?? wanted
  let words = ""
  if (operator.arity === 0) words = ""
  else if (field.type === "text") words = `“${(value as string).trim()}”`
  else if (field.type === "enum") words = Array.isArray(value) ? (value as string[]).map(option).join(", ") : option(value as string)
  else if (rule.operator === "in_last_days") words = `${value} ${value === 1 ? "day" : "days"}`
  else {
    const write = (part: number | string) => (field.type === "number" ? formatNumber(part as number) : day(part as string))
    words = Array.isArray(value) ? `${write(value[0])} and ${write(value[1])}` : write(value as number | string)
  }
  return [field.label, operator.label, words].filter(Boolean).join(" ")
}

/**
 * The filter in one sentence for a summary or a button: its finished rules,
 * joined the way they combine, a group of two or more in brackets. An empty
 * string when nothing is set.
 */
export function describeFilter(filter: FilterValue, fields: FilterField[]): string {
  const byId = new Map(fields.map((field) => [field.id, field]))
  const words = (rule: FilterRule) => {
    const field = byId.get(rule.field)
    return field && isComplete(rule, field) ? [ruleWords(rule, field)] : []
  }
  return filter.rules
    .flatMap((entry) => {
      if (!isFilterGroup(entry)) return words(entry)
      const inner = entry.rules.flatMap(words)
      if (inner.length === 0) return []
      const joined = inner.join(` ${entry.combinator} `)
      return [inner.length > 1 ? `(${joined})` : joined]
    })
    .join(` ${filter.combinator} `)
}

/** How many rules the filter holds, those inside groups counted one by one. */
export function countRules(filter: FilterValue): number {
  return filter.rules.reduce((count, entry) => count + (isFilterGroup(entry) ? entry.rules.length : 1), 0)
}
components/ui/filter-builder/filter-url.ts
import {
  OPERATORS,
  isComplete,
  isFilterGroup,
  type FilterField,
  type FilterGroup,
  type FilterOperator,
  type FilterRule,
  type FilterValue,
} from "@/components/ui/filter-builder/filter"

// A filter in the address: `?filter=<this>`, so a filtered view can be shared,
// reloaded and read on the server. The short form drops the ids and the key
// names — a rule is [field, operator, value?], a group { c, r } — and travels
// as base64url, which an address carries without escaping.

type ShortRule = [string, string] | [string, string, unknown]

/** Long enough for a hundred rules; anything longer is not a filter this builder wrote. */
const MAX_PARAM_LENGTH = 8192
const MAX_RULES = 100

function toBase64Url(text: string): string {
  let binary = ""
  for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte)
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}

function fromBase64Url(param: string): string | null {
  if (!/^[\w-]+$/.test(param)) return null
  try {
    const base64 = param.replace(/-/g, "+").replace(/_/g, "/")
    const bytes = Uint8Array.from(atob(base64 + "=".repeat((4 - (base64.length % 4)) % 4)), (char) => char.charCodeAt(0))
    return new TextDecoder("utf-8", { fatal: true }).decode(bytes)
  } catch {
    return null
  }
}

/** The filter as one address-safe word: base64url of its short form, without ids. */
export function filterToParam(filter: FilterValue): string {
  const short = (rule: FilterRule): ShortRule => (rule.value === undefined ? [rule.field, rule.operator] : [rule.field, rule.operator, rule.value])
  return toBase64Url(
    JSON.stringify({
      c: filter.combinator,
      r: filter.rules.map((entry) => (isFilterGroup(entry) ? { c: entry.combinator, r: entry.rules.map(short) } : short(entry))),
    })
  )
}

const isCombined = (data: unknown): data is { c: "and" | "or"; r: unknown[] } =>
  typeof data === "object" &&
  data !== null &&
  !Array.isArray(data) &&
  ((data as { c?: unknown }).c === "and" || (data as { c?: unknown }).c === "or") &&
  Array.isArray((data as { r?: unknown }).r)

/**
 * Reads a filter back out of the address against the fields a page offers —
 * the check a server makes before it filters by what it was sent. A rule on
 * a field that is not offered, with an operator its type does not take, an
 * option the field does not have or a value of the wrong shape is dropped,
 * and so is a group nested deeper than one level; every rule kept gets a
 * fresh id. Null when the value is not a filter at all.
 */
export function filterFromParam(param: string, fields: FilterField[]): FilterValue | null {
  if (!param || param.length > MAX_PARAM_LENGTH) return null
  const json = fromBase64Url(param)
  if (json === null) return null
  let data: unknown
  try {
    data = JSON.parse(json)
  } catch {
    return null
  }
  if (!isCombined(data)) return null

  let made = 0
  const freshId = (kind: "rule" | "group") => `${kind}-${++made}`
  let kept = 0

  const readRule = (entry: unknown): FilterRule | null => {
    if (!Array.isArray(entry) || entry.length < 2 || entry.length > 3 || kept >= MAX_RULES) return null
    const [fieldId, operator, value] = entry as ShortRule
    const field = fields.find((candidate) => candidate.id === fieldId)
    if (!field || typeof operator !== "string") return null
    const arity = OPERATORS[field.type].find((candidate) => candidate.value === operator)?.arity
    // A rule that takes no value keeps none, whatever the address carried.
    const rule: FilterRule = { id: freshId("rule"), field: field.id, operator: operator as FilterOperator }
    if (arity !== 0 && entry.length === 3) rule.value = value as FilterRule["value"]
    if (!isComplete(rule, field)) return null
    kept += 1
    return rule
  }

  const rules: (FilterRule | FilterGroup)[] = []
  for (const entry of data.r) {
    if (isCombined(entry)) {
      // One level of groups: a group inside a group is not a rule, so it goes.
      const inner = entry.r.map(readRule).filter((rule): rule is FilterRule => rule !== null)
      if (inner.length > 0) rules.push({ id: freshId("group"), combinator: entry.c, rules: inner })
    } else {
      const rule = readRule(entry)
      if (rule) rules.push(rule)
    }
  }
  return { combinator: data.c, rules }
}