Resolved: light

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 LovelaceIntake & triageNortheastActive4 mi
Barbara McClintockCommunity outreachSoutheastPaused22 mi
Chien-Shiung WuMedicalWestActive19 mi
Dorothy VaughanIntake & triageMidwestActive9 mi
Emmy NoetherIntake & triageMidwestOnboarding7 mi
Grace HopperTransportNortheastActive28 mi
Hedy LamarrTransportSoutheastOnboarding33 mi
Katherine JohnsonMedicalSoutheastOnboarding12 mi
Lise MeitnerFoster networkNortheastActive2 mi
Mae JemisonCommunity outreachWestActive6 mi
Rosalind FranklinFoster networkMidwestPaused41 mi
Vera RubinTransportWestActive51 mi
FilterBar + SavedViewBar + filterRows → DataGrid, URL state via useQueryState
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.