Skip to content
Component installs need the registry setup
VegaStack Design

Filter Builder

The stateful nested and/or filter builder — host-injected field grammar, per-type value editors, depth and condition caps, FilterChip summary.

Status
stable
Since
0.4.0
Accessibility pattern
nested fieldset/legend groups

Last updated

Filter conditions
Condition group (Any condition matches)

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-managed

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/filter-bar-managed

Run 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

BehaviourWhere it lives
The field grammarHost, via vocabulary — never baked in
Value editors beyond textHost, via editors (date pickers, actor pickers, …)
Serialisation / URL stateHost — the tree is plain data
Running the filterHost — the output describes conditions; it never executes them
AI-suggested filtersHost — app-coupled per the same split

Anatomy

CheckboxValueEditor
DateValueEditor — data-slot="filter-date-editor" | "filter-date-range-editor"
FilterBuilder — 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"
GroupByPicker — data-slot="group-by-picker" | "group-by-picker-add"
LinkValueEditor
NumberRangeEditor — data-slot="filter-range-editor" | "filter-range-editor-error"
NumberValueEditor
OptionsValueEditor
OptionValueEditor
PersonValueEditor
SortBuilder — data-slot="sort-builder" | "sort-builder-add" | "sort-builder-clear" | "sort-builder-header" | "sort-builder-limit"
TextValueEditor

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.

Stage: is QualifiedAmount: greater than 10000Owner: is empty

One level

maxDepth={1} is a flat list of conditions: there is no "Add condition group" control, and no reason to explain one.

Filter conditions

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.

Filter conditions

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

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
°
°
Minimum can't be more than maximum.

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).

PropTypeDefaultDescription
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-describedbystring—Points at the condition's "Value required" message when invalid.
aria-invalidboolean—Set when the condition is missing a required value.
disabledbooleanfalseWhether the surrounding builder is disabled.
idstring—Stable id for the editor control (used by the condition's error wiring).
placeholderstring"Choose…"Shown while nothing is chosen.
searchLabelstring"Search options"Accessible name of the search field in a searchable list (more than seven options, or several values).

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.

Filter conditions

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.

Sort
Due: ascending
Group: Status
PropTypeDefaultDescription
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-labelstring"Sort"Accessible name of the list, and the header's title.
classNamestring—Extra classes for the root.
disabledbooleanfalseDisable every control.
headerbooleantrueShow 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).
labelsPartial<SortBuilderLabels>EnglishThe component's words.
maxnumber3The most keys.
readOnlybooleanfalseShow the keys as removable FilterChips instead of the editor.

Data attributes and CSS variables on SortBuilder

AttributeValues
data-disabled""
data-read-only""
data-slot"sort-builder" | "sort-builder-add" | "sort-builder-clear" | "sort-builder-header" | "sort-builder-limit"
PropTypeDefaultDescription
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-labelstring"Group by"Accessible name of the list.
classNamestring—Extra classes for the root.
disabledbooleanfalseDisable every control.
labelsPartial<GroupByPickerLabels>EnglishThe component's words: the add button reads "Group by" with no level set, then "Add level".
maxnumber2The most levels.
readOnlybooleanfalseShow the levels as removable FilterChips instead of the editor.

Data attributes and CSS variables on GroupByPicker

AttributeValues
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.

Filter conditions
Condition group (Any condition matches)
A filter can hold 3 conditions at most
A filter can hold 3 conditions at most

Both caps reached: the add affordances disable with a readable reason.

API Reference

PropTypeDefaultDescription
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.
allowGroupsbooleantruefalse 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-labelstring"Filter conditions"Accessible name for the builder.
classNamestring—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.
disabledbooleanfalseDisable every control.
editorsRecord<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.
fieldPickerPropsPartial<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.
labelsPartial<FilterBuilderLabels>English defaultsThe builder's own words, for another language or a domain's vocabulary.
maxConditionsnumber25Maximum total conditions across the tree. The add affordances disable at the cap.
maxDepthnumber3Maximum 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.
prefixstring—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.
readOnlybooleanfalseRender 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.
refReact.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

AttributeValues
data-depthmirrors a prop or state value
data-disabled""
data-invalid""
data-opmirrors 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

PropTypeDefaultDescription
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.
optionsreadonly { 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.
peoplereadonly PeoplePickerOption[]—The people a person field offers (PersonValueEditor). A list here, or search for a directory.
searchAsyncSearchLoader<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.
unitstring—The unit a number field is measured in ("W", "°", "mm"), printed after its values.

Value editor contract

PropTypeDefaultDescription
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-describedbystring—Points at the condition's "Value required" message when invalid.
aria-invalidboolean—Set when the condition is missing a required value.
disabledbooleanfalseWhether the surrounding builder is disabled.
idstring—Stable id for the editor control (used by the condition's error wiring).

Accessibility

  • The tree is nested <fieldset>/<legend> structure — deliberately not role="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, with aria-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.
KeyAction
TabMove through field, operator, value, remove.
Enter / SpaceOpen the focused picker or activate a button.
ContractStates tested
Behaviourempty, conditions, nested-groups, depth-cap, condition-cap, invalid-condition, disabled, read-only-summary, flat-rules, sentence-summary, row-error
Accessibilitykeyboard, labeled, semantic-html
Visualdefault, invalid, disabled, dark

Do / Don't

Do
Inject the grammar: keep field keys, operators, and value editors in your app and pass them as vocabulary.
Don't
Ask the component to validate field semantics or serialise the tree — that adopts one app's AST into the design system.

On this page