Agent docs

7 · Toolbars and filters

List toolbars, facet dropdowns, search, and filter-state conventions.

List toolbars, facet dropdowns, search, and filter-state conventions. Sources: speedway components/FilterBar.tsx, kit table/filter-bar.tsx + hooks/use-table-filters.ts. Evidence registers under docs/evidence/20260807-0230-speedway-design-language/ in this repo.

Audience: an agent (or person) building a list's toolbar or filters.

Travels with: 06 (the table it filters), 04 (filtered-empty is a distinct state), 09 (composition order puts it above content), 02 (its buttons). Request-shaped bundle: AP-10.

§A Principles and mental models#

1. One toolbar anatomy. Search slot → one Dropdown trigger per filter (outline buttons; forced ! color overrides against daisyUI specificity) → a second row holding only the count line and Refresh. Facet menus grow a type-to-filter box above 8 options (SEARCH_AUTO_THRESHOLD), and their empty sub-states are worded ("Nothing to filter yet" / "No matches").

2. Facets are never bare labels. Every option carries a live count and an icon matching its entity (colored dot for stage/module, file/user glyphs). Option order follows the LIFECYCLE, not the alphabet ("Ready to be started, then In progress, then In review, then Complete, then Failed"). Boolean coverage facets use the some/none vocabulary ("Has criticals" / "No criticals").

3. Filters live in the URL, quietly. Filter keys map to query params so dashboards can deep-link filtered lists. Updates use replace: true, preventScrollReset: true so toggles neither stack history nor jump scroll. Omission vs explicit-empty are DIFFERENT states: no params at all means the default view (review opens scoped to "assigned to you", the CPRD ruling), while an explicit empty user= means everyone. Preserve that distinction in any reimplementation.

4. Selections must be visible; a chips ROW is banned, invisible filters more so (owner rulings 2026-07-30 and 2026-08-12). "The filter triggers already indicate what is selected." The kit still computes chips and supports toggle-without-removing ("tweaking a query doesn't lose the filter you built"), but speedway's product decision hides the chip row. Only count + Refresh persist. The 2026-08-12 refinement closes the loophole that ruling left open: it presumed the trigger SHOWS the selected values. A multi-select trigger that can only summarize as a count ("Job · 2") hides the selection, and a Clear-filters button next to invisible selections is the failure this section exists to prevent. So: when a trigger cannot display its selected values, each selected value renders as a removable chip whose cross removes just that value. Placement was refined again on 2026-08-12: chips gather AFTER all of the row's controls, never inline beside the one that owns them. The earlier wording said "immediately beside that trigger", and building it that way is what surfaced the problem, because a chip rendered inline pushes every later control sideways, so the row of triggers stops lining up as filters are applied. Owner: the chips "should show altogether after all the selects." Shipped through the apps at walmart FacetToolbar.tsx FacetChips, commit 5fa032c, which also gave Error management the same treatment rather than fixing only the shared toolbar. Wherever a clear-filters affordance exists, every selection it would clear must be individually visible. Single-select facets satisfy this for free, which is one reason to prefer them; a facet over values that can contain the URL list delimiter (part types with commas) must be single-select.

5. Count lines are honest and situational. "N of M jobs" only while a filter is active, otherwise the plain count label. A parts toolbar appends a usage hint after a bullet ("Open the eye icon on a row to preview"). It also shows a trailing escalation link ("Search part numbers in all N") only when a local search is active over a partial window, bridging client substring search to server prefix search.

6. Search has two tiers. FilterBar's model-bound search for browsable catalogs. A plain small Input ("Search {count} files") for short attach/picker lists. Debounce is 300ms with draft state that accepts external clears.

7. Filters can depend on filters, and one filter can be state-only. The Criticals facet hides entirely when only part-type modules are selected (disappears, not grays). A registry entry may be kind: "custom" with renderControl: () => null, participating in predicate logic while another component owns its UI. A facet may deliberately navigate instead of filtering client-side when the value "rides the URL as an indexed filter" (log's Job select).

8. Toolbars mirror across sibling surfaces. Review and Log share moduleFacetOptions/match helpers so the same facet reads identically on both. A module raised standalone and inside a workflow are separate facets ("never overlap", owner 2026-07-30).

9. A chip row switches categories. A facet narrows within one. Not the active-filter chips §A4 rules out, and not a second toolbar anatomy competing with §A1. §A4 bans a row of applied-filter chips, because the triggers already show what is selected. This is the opposite object: a category partition placed ABOVE the toolbar, showing branches you have not picked rather than echoing ones you have. When a list has ONE primary partition whose members are few, fixed, and jointly exhaustive (error categories, plus All), render it as a horizontal chip row rather than a facet select. Every branch is visible at once with its live count, and picking one is a single click instead of open-then-choose. The active chip is tinted, the resting chips read quiet, and the counts sit inside the chips so the row doubles as the distribution. Facets keep everything else, because a facet's values are many, open-ended, or orthogonal. The test is exhaustiveness: if the options do not partition the list, it is a facet.

§B Presets#

SituationPreset
Browsable listFilterBar: search + counted, iconed facets + count/Refresh row
Picker listplain Input sm + "N selected"
Deep-linkable viewfacet keys ↔ URL params, replace + preventScrollReset
Assigned-to-me defaultserver-side default scoping on param omission
Cross-search escalationtrailing link when local window < total
Admin quick togglejoin pill pair (Pending only / Show all), the bounded admin exception
One exhaustive partitionchip row above the toolbar, counts inside the chips, active tinted
Multi facet whose trigger shows a countremovable per-value chips, gathered after all controls
Static derived list (schema tables)no count line, no Refresh; the Card title carries the summary (speedway taxonomy.tsx:416-422, AttributeSchemaView.tsx:280-286)

§C Use cases through the apps#

  • Jobs list: Stage/Files/User/Module defs with live counts. Refresh wired to revalidator. Count line switches with filter activity.
  • Review: five facets (Job searchable and zero-pruned, Module split standalone/workflow, Error two-tone by severity family, User with "Assigned to you", Criticals percentage bands that vanish contextually).
  • Taxonomy/Attributes: some/none boolean facets over schema coverage; both run the static-derived preset (no count line, no Refresh; the Card title carries the summary).
  • Audit log (review/log.tsx): decision/actor multi facets plus a module OR-predicate, sharing moduleFacetOptions/match with Review per §A8; its Job control is the deliberately-navigating Select §A7 names.
  • Card toolbars (doc 5) carry card-scoped actions. List toolbars never do; they filter and refresh.
  • Walmart Error management (walmart-mvp frontend/src/components/FacetToolbar.tsx): the job multi facet renders its selected values as removable chips, and part type is single-select because Walmart part types contain commas, which a comma-encoded URL list cannot round-trip. The chips are rendered by FacetChips, deliberately separate from MultiFacetControl, so both the shared toolbar and Error management's hand-built row place them after their controls rather than inline (commit 5fa032c). Splitting the chips out of the control is what made that possible: while the control owned them, placement was forced to be inline.
@versable-git/ui · reference, canon, and method, read in place