Filter Builder
The stateful nested and/or filter builder — host-injected field grammar, per-type value editors, depth and condition caps, FilterChip summary.
- Status
- Since
0.4.0- Accessibility pattern
- nested fieldset/legend groups
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Filter Builder before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify filter-bar-managedInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/filter-bar-managedRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/button, @vegastack/date-picker, @vegastack/date-time, @vegastack/filter-row, @vegastack/input, @vegastack/number-field, @vegastack/people-picker, @vegastack/searchable-select, @vegastack/select, @vegastack/sortable-list, @vegastack/use-async-search.
Usage
import {
FilterBuilder,
type FilterField,
type FilterNode,
} from "@/components/ui/filter-bar-managed";
const VOCABULARY: FilterField<string>[] = [
{
key: "stage",
label: "Stage",
type: "text",
operators: [{ value: "is", label: "is" }],
},
];
const [tree, setTree] = useState<
Extract<FilterNode<string>, { type: "group" }>
>({
type: "group",
op: "and",
children: [],
});
<FilterBuilder vocabulary={VOCABULARY} value={tree} onValueChange={setTree} />;This is the condition builder a list's advanced filters open, beside the
FilterRow. The grammar is host-injected: the component owns the tree shape
(FilterNode — nested and/or groups and condition rows) and its editing
surface; your app supplies the vocabulary (which fields exist, which
operators each accepts, which editor type renders the value) and the editors
registry. The component never validates a field's semantics and never
serialises — the moment it did either, it would have adopted one app's AST.
Scope
| Behaviour | Where it lives |
|---|---|
| The field grammar | Host, via vocabulary — never baked in |
| Value editors beyond text | Host, via editors (date pickers, actor pickers, …) |
| Serialisation / URL state | Host — the tree is plain data |
| Running the filter | Host — the output describes conditions; it never executes them |
| AI-suggested filters | Host — app-coupled per the same split |
Anatomy
FilterBuilder is the component; the five value editors (TextValueEditor, NumberValueEditor,
NumberRangeEditor, OptionValueEditor, OptionsValueEditor) are the parts it renders a condition's
value with, exported so a host can register or reuse them.
Examples
Chip summary
readOnly collapses the builder to a flat FilterChip
row (the and/or grouping is not visualised in the summary — reopen the builder
to see structure). Chips stay removable — removing one prunes that condition
from the tree — unless the builder is also disabled, which makes the summary
inert.
One level
maxDepth={1} is a flat list of conditions: there is no "Add condition group" control, and no
reason to explain one.
Operators and value shapes
Give each operator a valueShape — none, scalar, list or range. Switching between two
operators of the same shape keeps the value; switching to another shape clears it, so a single
value never survives into "is any of". requiresValue: false is shorthand for none. A missing
value shows "Value required" only after its control was left, or when the enclosing form is
submitted — never on a row that was just added.
Condition rules (no groups)
For a rule editor — "Required when …", "Shown when …" — allowGroups={false} keeps the conditions
flat, prefix names the builder ("Required when" is the fieldset's name and leads the sentence),
and labels renames its own words (addCondition, remove, matchAll, matchAny, empty,
valueRequired, field, operator). conditionError(condition, path) returns a row's own
message: it shows under the row and is read as its value editor's description, beside the built-in
"Value required".
Required when Dimming is not Non-dimmable and Beam angle is at least 30°
Sentence summary
summary="sentence" makes the read-only summary one sentence instead of chips. The same text comes
from describeFilter(tree, vocabulary, { prefix }) — for a list cell, a tooltip or an audit line.
Option values read by their labels, numbers carry the field's unit, ranges read as 10–20 W,
≥ 10 W or ≤ 20 W (formatRange), and nested groups read in parentheses.
Required when Dimming is not Non-dimmable and Beam angle is at least 30°
Value editors
Five editors ship with the item, for the editors registry or on their own: TextValueEditor,
NumberValueEditor (a NumberField with the field's unit), NumberRangeEditor (Minimum and
Maximum; either may be empty, and a minimum above the maximum is an error on the Minimum field),
OptionValueEditor and OptionsValueEditor (one or several of the field's options — one on a
Select up to seven options and a SearchableSelect above seven, several always on a
SearchableSelect multiple; both take placeholder and searchLabel). Without an
entry in editors, a range operator gets NumberRangeEditor, a list operator on a field with
options gets OptionsValueEditor, a field with options gets OptionValueEditor, and the rest get
TextValueEditor. editors.range and editors.list override the shape defaults. A range
operator ("is between") always gets two boxes — editors.range, else NumberRangeEditor — even
when editors registers a single-value editor for the field's type, so "between" never stores one
number. A minimum above the maximum reads "Minimum can't be more than maximum." under the pair.
Required when Beam angle is between 60–24°
For a long vocabulary, fieldPicker="searchable" makes each row's field picker a
SearchableSelect; fieldPickerProps reaches it, so remote with useAsyncSearch searches the
fields on the server (every field it offers must also be in vocabulary).
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label* | string | — | Accessible name for the editor control. |
field* | FilterField<V> | — | The condition's field definition. |
onValueChange* | (value: V | undefined) => void | — | Commit a new value. |
operator* | string | — | The condition's current operator. |
value* | V | undefined | — | Current value (may be undefined). |
aria-describedby | string | — | Points at the condition's "Value required" message when invalid. |
aria-invalid | boolean | — | Set when the condition is missing a required value. |
disabled | boolean | false | Whether the surrounding builder is disabled. |
id | string | — | Stable id for the editor control (used by the condition's error wiring). |
placeholder | string | "Choose…" | Shown while nothing is chosen. |
searchLabel | string | "Search options" | Accessible name of the search field in a searchable list (more than seven options, or several values). |
Dates, people, checkboxes and links
Four more editors cover a table's field types. DateValueEditor takes an exact day from the
calendar or a relative date — Today, This week, Last N days ({ relative, days }), stored as ISO
calendar days so no time zone moves them; for a range operator it takes two days ({ from, to }).
PersonValueEditor offers the field's people (or its search) on PeoplePicker, with Me
first, stored as FILTER_ME so a saved view means whoever opens it; a list operator picks several.
CheckboxValueEditor stores true or false. LinkValueEditor searches records through the
field's search (keyset paged, debounced, race-safe) and stores { id, label }.
A <type>:range or <type>:list key in editors ("date:range", "person:list") wins over the
shape defaults for that type alone, so one editor can serve a type's every operator.
Sort and group
SortBuilder edits up to three sort keys ({ key, dir }, first one first) and GroupByPicker up
to two grouping levels (outermost first), both from the same field vocabulary. Rows reorder by drag
or SortableList's keyboard move mode, × removes one, and "Add sort" appends the next free field.
GroupByPicker's button reads "Group by" while no level is set and "Add level" once one is, so it
never shares a name with FilterBuilder's "Add condition group" (both are in labels). With readOnly both show removable FilterChips. They edit data; the host sorts and
groups.
| Prop | Type | Default | Description |
|---|---|---|---|
onChange* | (value: SortBuilderEntry[]) => void | — | Called with the next keys. |
value* | readonly SortBuilderEntry[] | — | The sort keys, first one first. |
vocabulary* | readonly Pick<FilterField<unknown>, "key" | "label">[] | — | The fields that can sort — the same vocabulary FilterBuilder takes. |
aria-label | string | "Sort" | Accessible name of the list, and the header's title. |
className | string | — | Extra classes for the root. |
disabled | boolean | false | Disable every control. |
header | boolean | true | Show the header row: the title, and "Clear" while any key is set. Off where the surface already has a title (a drill-in's header). |
labels | Partial<SortBuilderLabels> | English | The component's words. |
max | number | 3 | The most keys. |
readOnly | boolean | false | Show the keys as removable FilterChips instead of the editor. |
Data attributes and CSS variables on SortBuilder
| Attribute | Values |
|---|---|
data-disabled | "" |
data-read-only | "" |
data-slot | "sort-builder" | "sort-builder-add" | "sort-builder-clear" | "sort-builder-header" | "sort-builder-limit" |
| Prop | Type | Default | Description |
|---|---|---|---|
onChange* | (value: string[]) => void | — | Called with the next fields. |
value* | readonly string[] | — | The grouping fields, outermost first. |
vocabulary* | readonly Pick<FilterField<unknown>, "key" | "label">[] | — | The fields that can group — the same vocabulary FilterBuilder takes. |
aria-label | string | "Group by" | Accessible name of the list. |
className | string | — | Extra classes for the root. |
disabled | boolean | false | Disable every control. |
labels | Partial<GroupByPickerLabels> | English | The component's words: the add button reads "Group by" with no level set, then "Add level". |
max | number | 2 | The most levels. |
readOnly | boolean | false | Show the levels as removable FilterChips instead of the editor. |
Data attributes and CSS variables on GroupByPicker
| Attribute | Values |
|---|---|
data-disabled | "" |
data-read-only | "" |
data-slot | "group-by-picker" | "group-by-picker-add" |
Depth and condition caps
At a cap the add affordance stays focusable with aria-disabled (FRM-4), and the reason renders as
visible text beside it.
Both caps reached: the add affordances disable with a readable reason.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
onValueChange* | (value: Extract<FilterNode<V>, { type: "group"; }>) => void | — | Fired with the next tree on every edit. |
value* | { type: "group"; op: "and" | "or"; children: FilterNode<V>[]; } | — | The controlled filter tree. Must be a group node at the root. |
vocabulary* | readonly FilterField<V>[] | — | The host's field grammar. Order is menu order. |
allowGroups | boolean | true | false makes a flat list of conditions — the same as maxDepth={1}: no add-group control
and no reason. For rule editors ("required when …") that never nest. |
aria-label | string | "Filter conditions" | Accessible name for the builder. |
className | string | — | Extra classes for the root element. |
conditionError | ((condition: Extract<FilterNode<V>, { type: "condition"; }>, path: number[]) => string | undefined) | — | A row's own validation message, shown under the row and read as its value editor's
description. Runs on every render; return undefined for a valid row. |
disabled | boolean | false | Disable every control. |
editors | Record<string, React.ComponentType<FilterValueEditorProps<V>>> | — | Per-type value editors, keyed by FilterField.type. Types without an
entry fall back to a STRING-VALUED text Input — when V is not
string, every field type needs an entry here. Editors are host code — a
date field should render the host's date picker, not a text box. range and list keys
override the shape defaults; a range operator ("between") never falls back to the
field type's single-value editor — it gets range, else NumberRangeEditor. A
<type>:range or <type>:list key ("date:range", "person:list") wins over both for
that type alone. |
fieldPicker | "searchable" | "select" | "select" | The condition row's field picker: a Select, or a SearchableSelect for a long vocabulary. |
fieldPickerProps | Partial<Omit<SearchableSelectProps<FilterField<V>, false>, "aria-label" | "id" | "multiple" | "onValueChange" | "value">> | — | Props for the searchable field picker — e.g. remote with useAsyncSearch to search the
fields on the server. Every field it offers must also be in vocabulary. |
labels | Partial<FilterBuilderLabels> | English defaults | The builder's own words, for another language or a domain's vocabulary. |
maxConditions | number | 25 | Maximum total conditions across the tree. The add affordances disable at the cap. |
maxDepth | number | 3 | Maximum group nesting depth (the root group is depth 1). The add-group
affordance disables at the cap and the reason renders as visible text
beside it. 1 means a flat list: no add-group control and no reason at all. |
prefix | string | — | What the rule is for, read as the root fieldset's name ("Required when") and put before the
sentence summary. Falls back to aria-label. |
readOnly | boolean | false | Render the collapsed chip summary (via FilterChip) instead of the full
editing surface. Chips stay removable — removing one prunes that condition
from the tree — unless disabled is also set. |
ref | React.Ref<HTMLDivElement> | — | Ref forwarded to the root element. |
summary | "chips" | "sentence" | "chips" | How the read-only summary reads: removable chips, or one sentence (describeFilter). |
Data attributes and CSS variables on FilterBuilder
| Attribute | Values |
|---|---|
data-depth | mirrors a prop or state value |
data-disabled | "" |
data-invalid | "" |
data-op | mirrors a prop or state value |
data-read-only | "" |
data-slot | "filter-builder" | "filter-builder-add-group" | "filter-builder-cap-reason" | "filter-builder-condition" | "filter-builder-condition-error" | "filter-builder-group" | "filter-builder-sentence" |
Field definition
| Prop | Type | Default | Description |
|---|---|---|---|
key* | string | — | Stable field key ("stage", "amount"). |
label* | string | — | Visible label ("Stage", "Amount"). |
operators* | readonly FilterOperator[] | — | Operators this field accepts, in menu order. |
type* | string | — | Editor type for the value — keys the editors registry. Opaque to the
component: any string works as long as an editor exists for it (a built-in
text editor is the fallback). |
formatValue | ((value: V) => string) | — | Format a value for the read-only chip summary. |
options | readonly { value: string; label: string; }[] | — | The choices of an option field — what OptionValueEditor/OptionsValueEditor offer and
what describeFilter and the chip summary print for a stored value. |
people | readonly PeoplePickerOption[] | — | The people a person field offers (PersonValueEditor). A list here, or search for a
directory. |
search | AsyncSearchLoader<FilterRecordOption | PeoplePickerOption> | — | Finds people (a person field) or records (a link field) for a typed query, one keyset
page at a time. A person condition stores ids, so a person field searched this way (rather
than listed in people) needs formatValue for the chips and describeFilter to show names. |
unit | string | — | The unit a number field is measured in ("W", "°", "mm"), printed after its values. |
Value editor contract
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label* | string | — | Accessible name for the editor control. |
field* | FilterField<V> | — | The condition's field definition. |
onValueChange* | (value: V | undefined) => void | — | Commit a new value. |
operator* | string | — | The condition's current operator. |
value* | V | undefined | — | Current value (may be undefined). |
aria-describedby | string | — | Points at the condition's "Value required" message when invalid. |
aria-invalid | boolean | — | Set when the condition is missing a required value. |
disabled | boolean | false | Whether the surrounding builder is disabled. |
id | string | — | Stable id for the editor control (used by the condition's error wiring). |
Accessibility
- The tree is nested
<fieldset>/<legend>structure — deliberately notrole="tree", where editing controls inside tree items are a known screen-reader trap. Every control stays an ordinary labelled form control. - Removing a condition moves focus to the next condition in the same group, or to the group's add-condition button when it was the last — never back to the top of the page.
- A value-requiring condition without a value is flagged with visible text
("Value required") and
data-invalid, never colour alone. - A row's own error (
conditionError) is read as the field picker's description, witharia-invalid— so it is announced even for an operator that takes no value. - At the depth or condition cap the add affordances disable and the reason renders as visible text beside them.
| Key | Action |
|---|---|
| Tab | Move through field, operator, value, remove. |
| Enter / Space | Open the focused picker or activate a button. |
| Contract | States tested |
|---|---|
| Behaviour | empty, conditions, nested-groups, depth-cap, condition-cap, invalid-condition, disabled, read-only-summary, flat-rules, sentence-summary, row-error |
| Accessibility | keyboard, labeled, semantic-html |
| Visual | default, invalid, disabled, dark |