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-builderNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { Badge } from "@/components/ui/badge"
import {
FilterBuilder,
applyFilter,
describeFilter,
type FilterField,
type FilterValue,
} from "@/components/ui/filter-builder"
const FIELDS: FilterField[] = [
{ id: "company", label: "Company", type: "text" },
{ id: "value", label: "Value", type: "number" },
{ id: "closes", label: "Closes", type: "date" },
{
id: "stage",
label: "Stage",
type: "enum",
options: [
{ value: "lead", label: "Lead" },
{ value: "proposal", label: "Proposal" },
{ value: "won", label: "Won" },
{ value: "lost", label: "Lost" },
],
},
{ id: "renewal", label: "Renewal", type: "boolean" },
]
const DEALS = [
{ company: "Foxglove Analytics", value: 48_000, closes: "2026-10-02", stage: "proposal", renewal: false },
{ company: "Hedgerow Studio", value: 12_500, closes: "2026-09-18", stage: "won", renewal: true },
{ company: "Ironwood Analytics", value: 86_000, closes: "2026-10-20", stage: "lead", renewal: false },
{ company: "Lakeside Analytics", value: 23_000, closes: "2026-09-29", stage: "proposal", renewal: true },
{ company: "Quarry Systems", value: 9_800, closes: "2026-08-14", stage: "lost", renewal: false },
{ company: "Silverpine Systems", value: 31_000, closes: "2026-10-09", stage: "won", renewal: true },
{ company: "Trellis Labs", value: 54_000, closes: "2026-11-03", stage: "proposal", renewal: false },
{ company: "Northwind Freight", value: 17_200, closes: "2026-09-25", stage: "lead", renewal: true },
]
// "In the last N days" counts back from this day, so the demo never reads the clock.
const TODAY = new Date("2026-10-01T00:00:00Z")
const START: FilterValue = {
combinator: "and",
rules: [
{ id: "min-value", field: "value", operator: "gte", value: 20_000 },
{
id: "still-open",
combinator: "or",
rules: [
{ id: "proposal", field: "stage", operator: "is", value: "proposal" },
{ id: "renewal", field: "renewal", operator: "is_true" },
],
},
],
}
export default function FilterBuilderDemo() {
const [filter, setFilter] = React.useState(START)
const deals = applyFilter(DEALS, filter, FIELDS, { now: TODAY })
const summary = describeFilter(filter, FIELDS)
return (
<div className="flex w-full flex-col gap-4">
<FilterBuilder aria-label="Filter deals" fields={FIELDS} value={filter} onValueChange={setFilter} />
<div className="flex flex-col gap-2 border-t border-rule pt-3">
<p role="status" className="text-sm font-medium tabular-nums">
{/* A run of its own, left to right: a right-to-left page would turn "7 of 12 deals" round. */}
<bdi dir="ltr">
{deals.length} of {DEALS.length} deals
</bdi>
</p>
{summary ? <p className="text-sm text-muted-foreground">{summary}</p> : null}
<ul aria-label="Matching deals" className="flex flex-wrap gap-1.5">
{deals.map((deal) => (
<li key={deal.company}>
<Badge variant="secondary">{deal.company}</Badge>
</li>
))}
</ul>
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| 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 }. |
| defaultValue | FilterValue | EMPTY_FILTER | Where 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. |
| maxRules | number | 20 | How 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. |
| className | string | — | Merged onto the root; the rest of the div's props are spread onto it. |
Dependencies
Source
"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 }"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>
)
}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)
}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 }
}