FilterBar
An agent-native filter system — a headless `useFilters<T>` engine plus a `FilterBar` of editable `field · operator · value` pills, where the operator set is derived from each field's type so impossible queries can't be authored. Ships type-dispatched value editors, faceted counts, a rolling-vs-calendar relative-date engine, nested AND/OR groups, saved views, URL round-trip, a natural-language Ask bar, and a `FilterApi` handle an agent or MCP tool can drive.
@garn/ui/filtersDefault
A self-contained FilterBar over a records array — `+ Filter` → field → operator → value, keyboard-operable chips.
Prefilled
A controlled model seeded with rules — the editable-pill state, per-segment editing.
Live Results
The engine's `predicate` wired to a live result list (`engine.matches`).
- Refactor auth modulein_progress
- Update dependenciesdone
- Add dark mode toggletodo
- Investigate flaky testin_progress
- Write onboarding docsdone
Full Matrix
Every field type (text/number/date/select/multi/user) with its operators + value editor, incl. relative dates and deadline mode.
Nested Groups
Nested AND/OR groups — the group-outline querybuilder tree with one conjunction per group.
Ask
The natural-language Ask bar parsing a phrase into editable chips (custom layout via compound children).
Try: “amount > 10000 and close date this month”
Saved Views
Saved filter views from a localStorage store (createLocalViewStore).
Url Round Trip
serialize/deserialize round-trip through a URL query string, with validate-and-repair on restore.
?filters={"id":"root","conjunction":"and","rules":[{"id":"r1","field":"status","operator":"is","value":"done"}]}Installation
garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.
npx garn-ui add filters- Registry
filters- Source
- filters.tsx
- Deps
lucide-react- Registry deps
utilsdate-iouse-controllable-stateuse-announceuse-debounced-valuetagpopovercommandinputnumber-fielddate-pickerswitchspinner
Import
import { FilterBar } from "@garn/ui/filters";Props
Generated from the component's TypeScript types.
The root has no props of its own — it's a thin wrapper over its native element. Its parts carry the API:
FilterBar
apiRef- Description
- Imperative handle (agent-native).
- Type
React.Ref<FilterApi>
defaultFilters- Description
- No description.
- Type
FilterGroup
engine- Description
- Adopt an externally-owned engine (data-table / CollectionView pass theirs).
- Type
UseFiltersReturn<T>
fields- Description
- Field schema — required unless an `engine` is supplied.
- Type
FilterField<T>[]
filters- Description
- No description.
- Type
FilterGroup
onFiltersChange- Description
- No description.
- Type
(model: FilterModel) => void
records- Description
- Records for the client pipeline (drives the result count).
- Type
readonly T[]
size- Description
- No description.
- Type
"md" | "sm"
Plus 279 inherited native HTML attributes.
FilterBar.Ask
placeholder- Description
- No description.
- Type
string
Plus 307 inherited native HTML attributes.
FilterBar.Error
onRetry- Description
- No description.
- Type
() => void
Plus 280 inherited native HTML attributes.
Styling
Target these data-slots and remap these tokens to restyle without forking the component.
[data-slot="filter-bar"][data-slot="filter-chips"][data-slot="filter-chip"][data-slot="filter-chip-operator"][data-slot="filter-chip-value"][data-slot="filter-chip-remove"][data-slot="filter-value-editor"][data-slot="filter-operator-menu"][data-slot="filter-conjunction"][data-slot="filter-group"][data-slot="filter-add"][data-slot="filter-result-count"][data-slot="filter-clear"][data-slot="filter-saved-views"][data-slot="filter-ask"][data-slot="filter-empty"][data-slot="filter-loading"][data-slot="filter-error"]| Property | Token | Tier |
|---|---|---|
gap | --garn-gap-inline | semantic · air |
Private instance vars (never externalize): --t-soft-fg--t-line
Overview
Filters is garn's agent-native filter system — a headless useFilters<T> engine plus a FilterBar compound, the same shape as Sortable (an engine + a composed UI that re-skins nothing). The reliable unit is an editable pill: field · operator · value, where the operator set is a pure function of the field's type, so an impossible query — a comparison on an enum, contains on a number — simply can't be authored. Below the pills, useFilters owns a serializable, nestable FilterModel ({ id, conjunction, rules }, recursive), compiles it to a memoized pure predicate for the client (or a query object for a server seam), and round-trips through the URL with validate-and-repair so a shared link that references a since-removed field drops the stale rule instead of crashing.
On top of the pills sit the layers best-in-class products ship: faceted counts (count-annotated multi-selects, recomputed with the disjunctive two-query trick so OR-siblings don't zero out), a relative-date engine that ships rolling windows AND calendar buckets labelled distinctly (the reliability win — a rolling "past week" is 7 days; a calendar "this week" is Mon–Sun), first-class date between, saved views from an injectable store, nested AND/OR groups (one conjunction per group, ~3-level cap), and the 2026 headline — a natural-language Ask bar that returns editable chips, never a black-box answer. A FilterApi handle (addRule / setModel / fromNaturalLanguage / toQuery / …) is the seam an agent, an MCP tool, or the Ask parser drives.
Guidelines
Give it a field schema, and let type derive the rest. Each FilterField is { id, label, type, … } — the type picks the operator set and the value editor automatically. Reach for the escapes (operators to restrict by id, getOperators to derive functionally) only when the type defaults don't fit; the whole point is that impossible queries are unrepresentable.
Two ways to wire it. For a self-contained bar, pass fields (+ records for the live count) to <FilterBar> and it owns its own engine. For a data-table or CollectionView, create the engine yourself with useFilters and pass it via engine, then read engine.matches / engine.predicate to drive the rows — the bar and the table share one model.
A pending chip doesn't filter. A rule with no valid value renders neutral + dashed (data-state="pending") and is excluded from the predicate; it earns its field tone and starts filtering only once complete. That neutral rendering is deliberate — tone means "this condition is live."
Label rolling vs calendar dates for your users, and don't promise exact counts at scale. The engine ships both relative families distinctly on purpose. Faceted counts are the highest-leverage feature (they prevent zero-result dead-ends) but degrade to approximate on large sets — surface that, don't imply precision.
The URL is the source of truth for the active filter. Use the serialize/deserialize helpers (or the optional useFilterUrlState hook) so a filtered view is shareable and back-button-safe; saved views are named objects that set it.
Date rule values are yyyy-MM-dd calendar dates. The date editors (and the NL parser) commit zoneless yyyy-MM-dd strings — the same shape the engine's own asDate coercion local-parses back — never UTC ISO instants, so a stored or shared filter can't drift a day across timezones. Values you inject via addRule/setModel should use the same format (a Date or epoch number is also accepted on input).
Best practices
- Describe each column as a
FilterFieldwith the righttype(text/number/date/select/multi/boolean/user) and let the operator set + value editor derive from it. - Pass
records(client mode) so the result count and faceted counts compute; give date fields with a status pairing adeadlineconfig to unlock overdue/upcoming operators. - Share one model with a table/CollectionView by creating the engine via
useFiltersand passing it asengine(readengine.matches/engine.predicate). - Persist and share the active filter through the URL (
serialize/deserializeoruseFilterUrlState); useSavedViewswith an injectable store for named presets. - Drive it programmatically (agents, the Ask bar) through
apiRef/ theFilterApi—addRule,setModel,fromNaturalLanguage,toQuery.
- Don't hand-author a rule value the operator can't validate — an
is_withindate needs a{ preset }RelativeSpec, not a bare string; an invalid value renders as a (harmless) pending chip that doesn't filter. - Don't expect a pending chip (no value yet) to affect results — it's neutral/dashed and excluded until complete; that's the intended "not live" cue.
- Don't override the chip roles to a listbox — interactive-child chips require the role=grid TagGroup pattern.
- Don't nest groups past the cap (default 3) —
Add groupgrays out; escalate to a text expression (backlog) rather than ever-deeper nesting. - Don't promise an exact live count on very large datasets — the count/facets degrade to approximate at scale.
Accessibility
- Role
grid- ARIA APG
- Grid (editable chips) + Combobox/Listbox (authoring) + Status message (result count)
- Focus
- The chip grid holds one roving tab stop; the active chip carries tabIndex 0 and self-heals when chips are added/removed (focus lands on the removed chip's neighbour). Segment buttons are tabIndex -1 in the grid (reached by drill + arrows) and Tab-focusable standalone. Don't strip the focus rings.
Enforceable contracts
Chips carrying interactive controls must be a `role="grid"` with roving tabindex (row + gridcell segments), NOT a listbox — the React Aria TagGroup pattern. FilterBar.Chips does this; don't override the roles.
when the chip row renders editable pills with remove/segment buttons
Removing a chip must move focus to its neighbour (never lose focus to the body), so keyboard users stay in the bar.
when a chip is removed via Backspace/Delete or the ✕ button
The operator, value, and remove segments each need a descriptive accessible name that includes the field/value context (not a bare "button" or icon-only ✕).
when any chip is rendered
The result count must be a `role="status"` (aria-live=polite) live region so the match count is announced as filters change.
when the live result count is shown
Keyboard
| ArrowLeftArrowRightArrowUpArrowDown | On a chip row: rove to the previous/next chip. On a segment: move to the previous/next segment within the chip. |
| HomeEnd | Focus the first / last chip in the row. |
| EnterSpace | On a chip row: drill in — focus the operator segment. On a segment: open its menu/editor. |
| Escape | From a segment: exit back to the chip row (or close an open menu/editor). |
| BackspaceDelete | On a chip row: remove that filter and move focus to its neighbour. |
- • The chip row is `role="grid"` with `aria-label="Active filters"`, a single tab stop, and roving focus — because each chip carries interactive segment + remove buttons, grid is the correct semantics (a listbox is wrong for interactive-child chips; this is the React Aria TagGroup pattern). Each chip is a `role="row"`; its field/operator/value/remove segments are `role="gridcell"`.
- • Keyboard model on the flat bar: arrows rove between chips (Home/End jump); Enter/Space drills into a chip (focus its operator segment); arrows then move between segments and Escape exits back to the row; Backspace/Delete removes the focused chip and moves focus to its neighbour. Standalone chips inside a nested group are `role="group"` and their buttons are directly Tab-focusable.
- • Every interactive segment has a descriptive accessible name: the operator button `Operator: <label>. Change`, the value button `Value: <summary>. Change` (or `Set value` while pending), the remove button `Remove filter: <field> <operator> <value>`, the conjunction `Match all/any of the filters — change`.
- • The result count is a `role="status"` aria-live=polite region so screen readers hear the match count update as filters change; the loading state is also role=status (keep-previous, no empty flash) and the error state is role=alert with a labelled retry.
- • Authoring surfaces (the `+ Filter` field menu, the operator menu, select/multi/user/relative-date value editors) are Command comboboxes/listboxes with a text filter, following the WAI-ARIA combobox pattern; the natural-language Ask input has aria-label `Ask in natural language` and announces `Filters updated` on parse.
- Status
- Stable
- Version
v0.3.0- Introduced
v0.2.0
garn doesn't yet record a per-release version history. This reflects the component's current lifecycle state; full changelog entries will appear once the metadata pipeline emits a lifecycle.history field.