Filter Bar
block / data / filter
Faceted filters whose state lives in the URL: single select, multi-select, debounced text and a native date range, with removable chips and a shareable link.
- type
- block
- files
- 2
- registry deps
- badge, button, input, label, popover, @automindz/combobox, @automindz/empty-state, @automindz/multi-select
- npm deps
- lucide-react
installs
- lib/automindz/facets.ts
- components/automindz/filter-bar.tsx
- Facets are data — id, label, kind, options or loadOptions
- The URL is the state: shareable, reload-proof, replace-only writes
- Controls are @automindz/combobox and @automindz/multi-select, not copies
- One chip per active value, and the chip is the button
- Narrow: every facet folds into one Filters popover
Facets are data: { id, label, kind } — select, multi, text or date-range, with options or loadOptions. **id is the URL key.** The bar owns the URL: read it with parseFilters(facets, searchParams) and pass the values to your table as rows, so the two never fight. Multi values repeat their key, empty ones are dropped, writes are replace. Calls useSearchParams — wrap it in <Suspense>. Its lib is facets.ts, not filter-bar.ts: shadcn add rewrites imports by basename.
Variations
one state per section
- 01Filters in the address bar
- 02Driving a real data table
- 03Four control types, one contract
- 04Active filters, visible and reversible
- 05Mid-debounce
- 06Options still loading
- 07Options failed to load
- 08Blocked facet, with the reason
- 09Nothing matched
- 10Narrow container
Filters in the address bar
Pick a status, then look at the address bar: ?status=active. Nothing is mirrored into component state — the bar derives from useSearchParams() and writes back with router.replace, so there is no second copy of the truth to drift. Copy the URL into a new tab and the same filter set comes back, which is the whole point: a filtered view is a link you can send someone. The dashed line under the bar prints the query string this bar owns.
Driving a real data table
The pairing this item exists for. The bar owns the URL; the table receives the resolved rows as a prop — parseFilters → your own matching → rows — and its own searchable is deliberately off, because two search boxes over the same rows is exactly how the two end up fighting. Sorting, pagination and selection stay the table's business: they are view state, not a shareable filter. Narrow the set while on page 2 and the pager clamps rather than showing an empty body.
Four control types, one contract
One Facet shape covers all four kinds. select renders @automindz/combobox and multi renders @automindz/multi-select — neither is reimplemented here, which is the single-source rule and also dogfoods the registry. text debounces. date-range is two native <input type="date">: the platform already ships a keyboard-operable, localised date control, and a range filter is not worth a picker library. Owner fetches its options through loadOptions; the others are inline arrays.
Active filters, visible and reversible
One chip per active value — not per facet: two plans are two chips, and a date range is its two bounds, each removable on its own. The chip *is* the button — nothing interactive nests inside it, which is the invalid, keyboard-unreachable markup multi-select shipped with first. Load the example, then remove chips one at a time: the count follows, and the last removal returns to the bare path rather than leaving a dangling ?.
Mid-debounce
The text facet at debounceMs: 900, so the gap is visible. A keystroke lands in local state, the input goes aria-busy with a spinner, and only the settled value reaches the URL — that is what stops the address bar taking a write per character. Enter or blur flushes it early. Every write is a replace, so history.length does not move however long you type.
Options still loading
Owner's loadOptions has not resolved yet: the control keeps its slot at its real size, shows a spinner in place of the caret and says so when opened, so nothing jumps when the list lands. The other facets stay usable — one slow facet does not hold up the bar.
Options failed to load
A rejected loadOptions renders in place, with the reason and a retry, rather than an empty dropdown that looks like “no owners exist”. The facet keeps its slot and goes blocked instead of vanishing — a filter that disappears makes people ask where it went. Announced through role="alert", and the failure is carried by text and an icon, never by the colour alone. Press “Try again”: this one succeeds.
Blocked facet, with the reason
Different from loading and different from failed: disabled plus disabledReason. The control is really disabled — no hover, no press — and the reason sits under it in the reading order, because a greyed-out control with no explanation is a support ticket.
Nothing matched
A filter set with no rows behind it. The table's body renders @automindz/empty-state's search variant through FilterNoResults, which ships with this item so every consumer says the same thing — and its action actually clears the filters instead of telling you to. Distinct from “nothing created yet”, because the fix is different.
Narrow container
Held at 320px. Below the collapse width every facet folds into one “Filters” popover carrying the active count, and the chips stay outside it. A popover rather than a drawer: a drawer is a modal, and covering the rows while you filter them hides the only feedback the controls have — and it needs no dependency the controls did not already bring. It collapses on the bar's own container width, so a bar in a narrow column collapses on a wide screen too.
