Filters (faceted)
The sophisticated, faceted filter the roster needs — assembled entirely from @phauna/ds/grid primitives, exactly as the live grants index does it. A controlled FilterBar (one FilterField per facet, mixed types) sits above a SavedViewBar of preset tabs; both feed a single pure function, filterRows(rows, conditions, accessor), whose output is sorted and handed to a DataGrid. The active preset and any ad-hoc conditions live in the URL (useQueryState over the History API), so a filtered view is a shareable link. Nothing here is custom: the design system ships the whole pipeline.
- FilterBar
- Stateless + controlled. Renders one removable condition chip per active FilterCondition (click to edit field / operator / value in a popover) plus a trailing “+ Filter”. Holds NO condition state — value in, onChange out.
- SavedViewBar
- A row of preset tabs (system first, then user) plus a leading reset tab (allLabel). Controlled by activeKey (null = the reset tab); selection flows through onSelect. Optional onSave renders a trailing “+ Save view”.
- filterRows(rows, conditions, accessor)
- The pure AND-filter. accessor(row, field) maps your row shape to a field key’s value, so the engine never knows your data model. Empty condition values pass (a half-built filter never blanks the table).
- DataGrid
- Renders the filtered+sorted rows from a typed Column<T>[]. Sort, column show/hide/reorder/pin and the empty state are all built in; here sort + columnState are controlled.
- useQueryState(key)
- One URL search param as [value, setValue], via the History API (no router dependency). We round-trip two: “view” (active preset key) and “filters” (ad-hoc condition JSON).
- FilterField.type
- text · enum · entity · number · date. entity → searchable Combobox, enum → Select, text → free text, number → numeric input — the bar picks the right editor per facet.
The assembled system (interactive)
The complete pipeline, live. Add a filter from the “+ Filter” control — Name is free text, Team is a searchable entity picker, Region and Status are enums, and Distance is numeric (try the gte operator). Pick a preset tab to swap in a saved condition set; editing a filter clears the preset and writes ad-hoc conditions. Sort by clicking a column header; reorder / hide columns from “Columns”. Watch the address bar: the ?view= and ?filters= params update so the exact view is shareable. Flip the theme in the top bar — every control re-skins via the semantic tokens.
12 of 12 volunteers
| Ada Lovelace | Intake & triage | Northeast | Active | 4 mi |
| Barbara McClintock | Community outreach | Southeast | Paused | 22 mi |
| Chien-Shiung Wu | Medical | West | Active | 19 mi |
| Dorothy Vaughan | Intake & triage | Midwest | Active | 9 mi |
| Emmy Noether | Intake & triage | Midwest | Onboarding | 7 mi |
| Grace Hopper | Transport | Northeast | Active | 28 mi |
| Hedy Lamarr | Transport | Southeast | Onboarding | 33 mi |
| Katherine Johnson | Medical | Southeast | Onboarding | 12 mi |
| Lise Meitner | Foster network | Northeast | Active | 2 mi |
| Mae Jemison | Community outreach | West | Active | 6 mi |
| Rosalind Franklin | Foster network | Midwest | Paused | 41 mi |
| Vera Rubin | Transport | West | Active | 51 mi |
- view vs filters
- “filters” (ad-hoc conditions) overrides a preset. Selecting a preset sets ?view= and clears ?filters=; editing the bar sets ?filters= and clears ?view= — they never both apply at once.
- accessor()
- A stable, props-free function (no closures) so it sits in filterRows’ deps cleanly. One switch maps each FilterField key to the row value it compares against.
- presets (SavedView[])
- System presets are plain serializable ViewState — { filters, sort?, columns? }. Here: “Active transport”, “Onboarding”, and “Long-haul (≥ 25mi)” (a number gte condition).
- empty state
- When no row matches, the grid shows an <EmptyState variant="no-results"> — never a blank table. Clear a condition or choose “All” to recover.
Field types & operators
Each facet is a FilterField with a type that picks its editor and its sensible default operators. The demo above exercises one of each filterable kind; date is the fifth supported type (a DateInput-backed editor) and follows the same numeric-style operators.
- type: "text"
- Free-text editor. Default op is contains (substring, case-insensitive) — used here for Name.
- type: "entity"
- A searchable Combobox over options[{value,label}] — for high-cardinality references like Team. Default op is/isNot (and in for multi-select).
- type: "enum"
- A Select over a small fixed option set — Region and Status. Default op is/isNot.
- type: "number"
- A numeric editor with comparison ops gt / lt / gte / lte / between — Distance. The “Long-haul” preset uses gte 25.
- type: "date"
- A DateInput editor with the same comparison/between operators as number (not shown in this dataset).
- ops override
- Pass ops?: FilterOp[] on a field to constrain the operator menu; omit it to accept the per-type defaults.
URL state & shareable views
The filter state is the URL — no client store, no server round-trip to read a filter. useQueryState writes through the History API, so the back button walks your filter history and a copied link reopens the same subset. In the live app this also drives SSR first-paint (a curl of ?view= returns the right rows before hydration) and is the seam where legacy deep links fold into an initial condition set.
- ?view=<key>
- The selected preset’s key (absent = the All reset tab). Set by SavedViewBar’s onSelect.
- ?filters=<json>
- Ad-hoc conditions as a JSON-encoded FilterCondition[]. Set by FilterBar’s onChange; present only when the user has hand-edited filters.
- replace option
- useQueryState(key, { replace: true }) uses history.replaceState instead of pushState — handy for high-frequency edits you don’t want to flood the back stack.
- no router dep
- useQueryState is framework-agnostic (History API only), so this pattern is portable across any DS host, not tied to Next’s router.