Table, list and card-list pages: one shell whose defaults ARE the rules, a canonical order for everything around the rows, and a view that survives a refresh.
Page-class laws·docs/data-view.md·262 lines
Kit src/components/data-view/Living example · Data View
How every table / card-list page in this repo is built. The kit lives in src/components/data-view/; the living example is /labs/layout/data-view. This document is the contract between the two: the kit enforces what it can with component defaults, and this file records the rules that live in judgement.
01
Ten data-view modules were built here by hand before the kit existed. They converged on the same anatomy — and still drifted: two different count predicates, four different "which direction does a fresh sort start in" semantics, three Reset behaviours, and ten byte-similar copies of the options panel. A written convention decays one copy-paste at a time; a component whose defaults are the rules cannot. So the canonical order below is not a style guide to follow — it is what DataViewToolbar and ViewOptionsPanel render, and deviating requires visibly opting out.
02
<DataViewShell>
<KpiStrip>…</KpiStrip> {/* optional */}
<DataViewToolbar search={…} filters={…} count={…} clear={…} actions={…} options={…} />
<DataViewChips chips={…} /> {/* only while a panel filter narrows */}
<ScopeTabs items={…} value={…} onChange={…} /> {/* optional */}
<DataViewBody>…grid | table | empty…</DataViewBody>
<DataViewPagination … /> {/* only when paginated */}
</DataViewShell>| Region | Component | Rule |
|---|---|---|
| KPI strip | KpiStrip + KpiTile | Figures derive from the same dataset as the rows — they must reconcile. Tones are earned status colours, not decoration. |
| Toolbar | DataViewToolbar | Left: search → toolbar filters (DataViewFilters) → count → Clear filters (DataViewClearFilters). Right: module actions → view-options trigger (always outermost). Slots enforce this. |
| Filter chips | DataViewChips | Every panel filter currently narrowing the set renders as a removable chip; the × sets that filter back to its neutral value (not the view default — Reset does that). The row only exists while a chip does. Chips exist to rescue the visibility of hidden filters, so two boundaries keep them from becoming duplicate controls: a chip can only remove, never edit (the panel stays the single place a value is set — an editable chip is a second editor, and two editors of one value is the anti-pattern); and a self-displaying control never gets echoed — search and tabs show their own state, and a filter later promoted from the panel to a flat toolbar control leaves the chip roster at that moment and signals its narrowing state on the control itself instead — the filled trigger and its own ✕ (see Filters). |
| Scope tabs | ScopeTabs | One row, own region, counts per tab. A filter appears here or in the panel, never both. |
| Body | DataViewBody | p-4; holds AnimatedGrid, a <Table> with SortHeads, or DataViewEmpty. |
| Footer | DataViewPagination | Range readout left; page-size select + windowed pager right. Unpaginated views render no footer. |
The page above the shell owns the single <h1> (PageHeader). The shell has no title of its own — one page, one heading; the card repeating the page title was removed deliberately (f96dfc5) and must not come back.
03
Every condition that narrows the set is placed by how often it is used, and every place a narrowing is shown carries its own undo. Those two rules are the whole design; the table below is what they produce.
| Frequency | Home | Control |
|---|---|---|
| Constant — the reader always has it in hand | Toolbar | DataViewSearch |
| The set's main partition, a handful of named values | Own row | ScopeTabs |
| High — reached for most visits | Toolbar | DataViewFilter |
| Low — reached for occasionally | View-options panel | SelectRow (+ a chip while it narrows) |
Sort, layout and column visibility are not filters — they never narrow the set — and always live in the panel. A condition lives in exactly one home; promoting a filter to the toolbar removes it from the panel in the same change.
The clear belongs to the state, not to the control. Wherever a narrowing condition is visible, one click next to it puts that condition back to its neutral value ("All teams", an empty query). A control at its neutral value shows no ✕ — an ✕ on "All teams" is noise, and its absence is what tells the reader nothing is filtered there.
| Condition | At neutral | While narrowing | Undo |
|---|---|---|---|
| Search | Empty field | Text in the field | ✕ in the field, or Escape |
| Toolbar filter | Plain select, chevron | Filled trigger, ✕ in the chevron's slot | ✕ — back to neutral, focus returns to the trigger |
| Scope tabs | "All" selected | Another tab selected | The "All" tab — no ✕ |
| Panel filter | Nothing outside the panel | A chip under the toolbar | The chip's × |
| Several of the above | — | Two or more narrowing | One Clear filters |
DataViewClearFilters in the clear slot) — the count is the result, the clear is its remedy, and beside the last filter it would read as clearing only the toolbar when it also clears the chips below. Otherwise it closes the chip row (DataViewChips onClearAll); never both. Its count includes the search query (active), because a query plus one filter is two narrowings.DataViewFilters, and the view's Clear filters then closes that row — still counting the search above it. The row exists for room, not emphasis: a toolbar with space keeps its filters in the filters slot.+ Field pills that turn into Field: value ⊗ once set), worn by a select so a high-frequency filter stays one click away instead of behind "Add filter". The ✕ is a sibling laid over the trigger, never a child of it (a button inside a button is invalid, and would open the popup on its way to clearing).DataViewCount is a polite live region, so a narrowing that changes the result reaches a screen reader as well as the eye.An app screen with several views (Support, Content, Observability, the operations apps) has two layers of conditions, and they never clear each other.
| Layer | Lives in | Holds | Its Clear filters |
|---|---|---|---|
| Screen scope | The page band's toolbar (FamilyPageHeader toolbar) | Conditions every view honours and that survive a tab switch — team, customer, warehouse, environment | Closes the band's control row; counts and clears band conditions only |
| View conditions | The view's card (toolbar, chips) | Conditions of this view alone | After the card's count; counts and clears card conditions only |
DataViewSearch or a DataViewFilter like any other — filled with its own ✕ while it narrows, nothing at neutral.n / N).04
Card (the shell provides it — a data view must never render bare on the page background). A page with several features shows several cards; never merge two features into one card or split one feature across two.border-b border-dashed). Toolbar, tabs, KPI strip, footer — every seam within the feature is dashed, which keeps it visually quieter than the card edge.05
const { view, patch, reset, page, setPage } = useDataView<ViewState>({
id: "integrations", // localStorage key — omit to disable persistence
defaults: DEFAULT_VIEW,
version: 1, // bump when ViewState changes shape
});view-state.ts: the ViewState type, DEFAULT_VIEW, LAYOUT_OPTIONS, SORT_OPTIONS, PAGE_SIZE. Those are module facts; the machine that runs them is the hook.ViewState — layout, sort, filters, visibility — so persistence and Reset cover it automatically. The page number stays outside (a return visit starts on page 1) and search stays outside (a stored query is a trap: the user forgets it and thinks data lost).SortOption carries defaultAsc: names start ascending, numbers and dates start descending. toggleSort reads it; never hand-roll the else-branch.reset() restores defaults, clears the page, and removes the stored copy in one move. Wire the panel's Reset to it and nothing else.06
List is the default layout. A module earns a Grid layout only when all three hold:
Finance-style many-column data fails 2 and 3 — give it List only, and put the column effort into good SortHeads. The card-list family passes (it exists to showcase cards); new pages must justify themselves against this list, not inherit the habit.
07
Exactly two moves, both built in: the ScopeTabs thumb slides between pills (spring 400/32), and AnimatedGrid items scale in/out at 180ms ease-out when a filter changes. Don't add a third to a data view — the data is the show.
08
{name}-data.ts — deterministic mock data (no Math.random, no new Date()).view-state.ts — ViewState, DEFAULT_VIEW, LAYOUT_OPTIONS, SORT_OPTIONS (with defaultAsc), PAGE_SIZE if paginated.useDataView + comparators + the anatomy skeleton above.DataViewFilters in the toolbar's filters slot, the rest in the panel with a DataViewChips row echoing them, one DataViewClearFilters in the toolbar's clear slot counting every narrowing condition.ViewOptionsPanel's slots (layout / sort / filters / toggles), wire onReset={reset}. A control the vocabulary lacks composes inside an OptionRow — never fork the panel.PageHeader (title + description + actions) + PageContainer + the shell. No second heading anywhere in the card.nav-data.ts and add/extend the feature-ledger entry.09
ViewOptionsPanel, SortHead, KpiStrip, pagination, or the empty state. The kit exists; extend it there.layoutId strings on tab thumbs (collisions across instances).