Filter builder

Builds a nested AND/OR filter over any record list.

2 of 7 projects
Stewardship campaign briefQueued
Kids check-in signageQueued
Installation
$Terminal
npx shadcn@latest add https://sdk-components.thesqd.com/r/filter-builder.json
Usage
Declare the fields a clause can test, hold the tree in state, and evaluate it with filterRecords.
TSImport
import { FilterBuilder } from "@/components/blocks/filter-builder";
TSExample
"use client";

import * as React from "react";
import { FilterBuilder } from "@/components/blocks/filter-builder";

const FIELDS = [
  { id: "title", label: "Title" },
  {
    id: "status",
    label: "Status",
    options: [
      { value: "queued", label: "Queued" },
      { value: "delivered", label: "Delivered" },
    ],
  },
];

const PROJECTS = [
  { id: "1", title: "Stewardship campaign brief", status: "queued" },
  { id: "2", title: "Annual report cover",        status: "delivered" },
];

export function Example() {
  const [rows, setRows] = React.useState(PROJECTS);

  return (
    <>
      <FilterBuilder fields={FIELDS} records={PROJECTS} onFilteredChange={setRows} />
      {rows.map((row) => <div key={row.id}>{row.title}</div>)}
    </>
  );
}
Composition
Anatomy of the filter builder, its data model, and the evaluation helpers.
FilterBuilder
├── Filters button (default) — FunnelSimple icon + active-clause count badge
│   └── Popover → the panel
└── inline — the panel with no trigger

Panel (one group per nesting level)
├── "Where" lead on the first row
├── Conjunction <Select> on the SECOND row only — and / or, owned by the
│   group, echoed as plain text on every row after it
├── Clause row
│   ├── drag handle (reveals on hover — reorders within the group)
│   ├── field <Select>
│   ├── operator <Select>    is / is not / contains
│   ├── value <Select>       when the field has options or groups (given, or
│   │                        derived from `records`); `groups` draws a heading
│   │                        per group …
│   ├── value <TextField>    … otherwise a free-text value
│   └── remove ✕
├── Nested group row — one level, for `A and (B or C)`
└── Actions: Add filter · Add group (root only) · Clear all (destructive)

The popover panel tweens its height as rows come and go (transitions.dev card
resize), and with no clauses it shrinks to just the two buttons.

Data model
FilterGroup { id, conjunction: "and" | "or", children: FilterNode[] }
FilterNode  = FilterClause | FilterGroup
FilterClause { id, fieldId, operator?, value }

Evaluation (separate from the UI — the builder never touches your data)
├── filterRecords(records, group, fields)  → the rows that pass
├── matchesFilterGroup(record, group, fields) → one record
├── countFilters(group)  → the clauses that actually filter (the badge's number)
└── toFilterGroup(clauses | group | json) → normalizes any of them into a group

Persisting a view
├── filterToJson(group)  → the tree as a JSON string (URL, localStorage, a column)
├── parseFilterJson(json) → the tree back; anything malformed returns an empty group
└── `onJsonChange` / `defaultValue={json}` do both without the helpers

Matching rules
├── An array value tests membership; anything else compares as text.
├── A field with options accepts either the stored `value` or its `label`.
├── `contains` is case-insensitive substring.
└── An empty clause value and an empty group are no-ops, not walls.
Filters popover
Data grid v3's rule builder, unbound from the grid — the same panel, styling, drag-to-reorder and AND/OR nesting, driven by a plain `fields` list instead of grid columns. The trigger carries an active-clause count badge. Hand it `records` and `onFilteredChange` and it evaluates the tree for you; take the `FilterGroup` off `onValueChange` and run `filterRecords` yourself when the same tree has to drive a board, a list, a calendar or a server query.
2 of 7 projects
Stewardship campaign briefQueued
Kids check-in signageQueued
Inline panel
`inline` drops the trigger and mounts the panel itself — for a filter bar that lives on the page rather than behind a button. Add a clause, switch the join to **or**, or **Add group** to nest one level for `A and (B or C)`; drag a row by its handle to reorder it within its group.
2 of 7 projects
Where
Stewardship campaign briefQueued
Kids check-in signageQueued
API Reference
Props for the builder, its fields, and a single clause.

FilterBuilder

PropTypeDefaultDescription
fieldsFilterField[]—Fields a clause can test. A new clause starts blank — the user picks the field and value.
valueFilterGroup—Controlled filter tree. Pair with `onValueChange`.
defaultValueFilterClause[] | FilterGroup | string—Uncontrolled initial tree. A flat clause list is joined with `and`, and a JSON string (what `onJsonChange` hands back) is parsed — a malformed one falls back to an empty tree instead of throwing.
onValueChange(next: FilterGroup) => void—Fired with the whole tree after every edit, reorder or removal.
onJsonChange(json: string) => void—The same change as `onValueChange`, already serialised — hand it straight back as `defaultValue` to restore the view.
recordsRecord<string, unknown>[]—The records being filtered. Read to derive value options for fields that declare none (first 60 distinct values), and to compute `onFilteredChange`.
onFilteredChange(rows: T[]) => void—The records that pass the current tree, handed over whenever it changes. Use `filterRecords` instead when the tree drives something else (a server query, another view).
inlinebooleanfalseRender the panel bare instead of behind a Filters button.
labelstring"Filters"Trigger label.
widthnumber520Resting panel width in px, capped at the viewport.
maxWidthnumber760How wide the panel may grow for a long value before it truncates instead. Capped at the viewport.
classNamestring—Applied to the trigger, or to the inline panel wrapper.

FilterField

PropTypeDefaultDescription
idstring—Key read off each record.
labelstring—Shown in the field select.
options{ value: string; label: string }[]—The field's vocabulary. Renders a value select and narrows the operators to `is` / `is not`. Omit for a free-text field.
groups{ label: string; options: { value: string; label: string }[] }[]—The same vocabulary split under headings, drawn in the given order (e.g. people under their team, then "Former staff"). A field with only `groups` is an option field: it gets a grouped value select, `is` / `is not`, and label lookups across every group. When both are set, `options` wins.
operators("is" | "is not" | "contains")[]—Override the offered operators. Defaults to all three, or `is` / `is not` when the field has options.

FilterClause

PropTypeDefaultDescription
idstring—Stable clause id — also the drag key.
fieldIdstring—The `FilterField.id` this clause tests.
operator"is" | "is not" | "contains""is"How the value is compared.
valuestring—The value tested against. An empty value is a no-op, so a half-written clause hides nothing.