Page.Toolbar — the one control bar for list pages
Every dashboard list/collection page (Catalog, MCP, Costs, Agent Sessions, Employees, Risk Events, the Observe pages) puts its search + filters + sort + view controls in a single compound component: Page.Toolbar. Do NOT hand-roll a search <input>, a filter sidebar/popover, a sort <select>, a ViewToggle, or filter chips — compose the toolbar pieces. This keeps every page visually identical (one contained grey bar, uniform 40px controls, full width, search+filters left / sort+view right) and changeable in one place.
How to use it
Import Page from @/components/page-layout and render the toolbar on its own row below the page title/description:
<Page.Toolbar>
<Page.Toolbar.Search value={q} placeholder="Search…" debounceMs={300} />
<Page.Toolbar.Filters
schema={FILTERS} values={values} optionsById={optionsById}
/>
<Page.Toolbar.SortBy
value={sort}
options={[{ value: "recent", label: "Recently Added" }]}
direction={dir} {/* optional asc/desc toggle */}
/>
<Page.Toolbar.Count>{count} items</Page.Toolbar.Count>
<Page.Toolbar.ViewAs value={view} />
<Page.Toolbar.Actions>{/* page-specific extras, e.g. a SegmentedControl */}</Page.Toolbar.Actions>
<Page.Toolbar.Refresh => void refetch()} isRefreshing={isFetching} />
</Page.Toolbar>
The pieces (all optional, written in any order — the toolbar sorts them):
| Piece | Side | What it is |
|---|---|---|
Search |
left | Debounced white search box (debounceMs optional, built-in clear button) |
Filters |
left | Filter chips + "More filters" sheet + "Reset to default" |
Leading |
left | Page-specific left-aligned extras that narrow or re-cut the collection (e.g. a segmented axis track, a scope selector) |
SortBy |
right | Sort dropdown, optionally with a built-in asc/desc direction toggle (one bordered box) |
Count |
right | Result-count text |
ViewAs |
right | Grid/table toggle (grid/table only) |
Actions |
right | Page-specific right-aligned extras |
Refresh |
right | Manual refresh button (onRefresh required, isRefreshing optional); spins/disables while refreshing and enforces a ~2s minimum visible spin so a fast/cached refetch doesn't look like a no-op |
Layout, height (40px), the grey bar, the search↔filters divider, and the left/right justify-between split are all handled by the component — don't re-create them.
A bar with too many controls for one line composes explicit rows inside the same shell instead of hand-rolling a second bar — each Page.Toolbar.Row takes the same pieces and lays out the same left/right clusters (see the costs page's BreakdownBar):
<Page.Toolbar>
<Page.Toolbar.Row>
<Page.Toolbar.Search value={q} />
<Page.Toolbar.Actions>{axisTrack}</Page.Toolbar.Actions>
</Page.Toolbar.Row>
<Page.Toolbar.Row>
<Page.Toolbar.Leading>{scopeControls}</Page.Toolbar.Leading>
<Page.Toolbar.Actions>{exportButton}</Page.Toolbar.Actions>
</Page.Toolbar.Row>
</Page.Toolbar>
Declaring filters
Filters are a pure const schema via defineFilters (from @/components/filters), driven by useFilterState(SCHEMA) (URL-param backed). Kinds: multiselect | select | text | boolean | daterange.
import { defineFilters, useFilterState } from "@/components/filters";
const FILTERS = defineFilters([
{
id: "date",
label: "Date range",
kind: "daterange",
pinned: true,
defaultPreset: "30d",
},
{ id: "status", label: "Status", kind: "multiselect" },
{ id: "policy", label: "Policy", kind: "select" },
]);
const { values, setValue, clearValue, clearAll } = useFilterState(FILTERS);
- The schema must be a pure literal (no hooks/fetched data) so
FilterValues<typeof FILTERS>can derive the typed value object. pinneddimensions always render a chip (with an "All …" default); others appear only when active and live behind "More filters".- Pass dynamic option lists (servers, policies, agents) at render via
optionsById(aRecord<id, {label,value}[]>) — never bake data into the schema. - Reuse existing schemas as references:
pages/catalog/catalog-filter-schema.ts,pages/mcp/mcp-filter-schema.ts, and the inlineCOST_FILTERS/SESSION_FILTERS/EMPLOYEE_FILTERS/RISK_FILTERS/OBSERVE_FILTERS.
Bridging to existing query logic
useFilterState returns URL-persisted values keyed by dimension id. If the page already has its own query/state shape, bridge the unified values back to it rather than rewiring every consumer (see catalog's toCatalogFilterValues). For pages whose filters already live in the URL (the Observe pages), build a values object from the existing reads and route onChange to the existing setters.
Rules
- Never hand-roll: a bare
<input>search, a sort<select>, aViewToggleoutside the toolbar, or filter chips. Use the pieces. - Never set per-control heights — the toolbar enforces a uniform 40px.
- Two-option mode toggles (Tokens/Cost, an Employees/Unknown scope switch, etc.) go in
Page.Toolbar.Actionsusing the sharedSegmentedControl(@/components/ui/segmented-control) — give each option atooltip.ViewAsis for grid/table only. onClearAllmust reset filters in a singlesetSearchParams/clearAllcall. Firing one setter per filter clobbers in react-router (it reads a memoized snapshot, so the lastnavigatewins) —useFilterState.clearAllalready does this correctly.- The "Reset to default" button is built into
Filters; don't add your own clear button.
Where things live
components/ui/toolbar.tsx—Page.Toolbar+ all pieces (the only place to change layout/height/styling).components/ui/segmented-control.tsx— shared two-or-more option mode toggle.components/filters/—filter-schema.ts(defineFilters, types,chipLabel,isDimensionActive),useFilterState.ts, and the chip/sheet/control primitives.
Run aube run -F dashboard type-check after changes.