The filter row
Every filtered page has exactly one filter row — a plain flex line of pills. This recipe is the placement law: where the row sits, what order the pills come in, and where page actions go. For the pills themselves, see Filter.
Where the row lives
Its place is fixed by whether the page has tabs:
- No tabs → the first child inside
PageBody, scrolling with the content. No border, no own padding —PageBodyowns thep-4gutter and thegap-4rhythm down to the content. It is not a pinned band: never betweenPageHeaderandPageBody, never with aborder-b. - Tabs → under the
TabsList. A row of filters shared across tabs sits betweenTabsListandTabsContent; a per-tab toolbar (its own search/filters) sits as the first child inside that tab'sTabsContent. Either way it carries no ownpx— thePageBodygutter already insets it. (A leftoverpx-4on tab content is a pre-PageBodyrelic; drop it so every tab's row lines up under the tablist.)
The reporting-year selector is a filter — it lives in this row and never in PageHeader / PageHeaderActions.
Order in the row
Left to right, the row always reads:
FilterSearchleads — the keyword pill is always the first element.- The reporting-year selector — the leading filter pill, right after search.
- The remaining filters — single-/multi-select, boolean, combobox.
- A trailing element pushed right with
ml-auto— a "Filters" drawer trigger or a primary action ("Create …", "Upload …") that isn't itself a filter. It lives in this row, never in a band of its own above it.
vue
<PageBody>
<div class="flex flex-wrap items-center gap-2">
<FilterSearch v-model="query" placeholder="Search assets" /> <!-- always first -->
<ReportingYearSelector /> <!-- leading filter pill -->
<!-- …other filter pills… -->
<UploadButton class="ml-auto" /> <!-- optional, pushed right -->
</div>
<!-- content -->
</PageBody>The row is a plain <div class="flex flex-wrap items-center gap-2"> of pills — there is no dedicated row component.
Incorrect / Correct
Incorrect (a page action in its own band pushes the filters off the tablist edge):
vue
<TabsList … />
<div class="flex justify-end border-b px-4 py-2">
<Button variant="primary">Upload data</Button>
</div>
<div class="flex flex-wrap items-center gap-2">
<FilterSearch v-model="query" />
…
</div>Correct (the action joins the filter row, right-aligned — the row stays first under the tabs):
vue
<TabsList … />
<div class="flex flex-wrap items-center gap-2">
<FilterSearch v-model="query" />
<ReportingYearSelector />
…
<Button variant="primary" class="ml-auto">Upload data</Button>
</div>When the action lives in a different component from the filters (the row is inside a *Table.vue, say, while the page owns the button), expose a trailing ml-auto action slot on the component that owns the row rather than rendering a separate band.
Gotchas
- The reporting-year pill in
PageHeaderActionslooks fine and is wrong — it's a filter; it belongs right afterFilterSearchin this row. - A pinned, bordered toolbar looks like the old design and is wrong on padded pages — the row scrolls with the content as
PageBody's first child. (Flush table pages pin their toolbar rows; see the layout docs.) - A leftover legacy search input is the migration tell — a
FormInputor hand-rolledmagnifying-glassbox that isn'tFilterSearchmeans the page hasn't been migrated.