Skip to content

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 — PageBody owns the p-4 gutter and the gap-4 rhythm down to the content. It is not a pinned band: never between PageHeader and PageBody, never with a border-b.
  • Tabs → under the TabsList. A row of filters shared across tabs sits between TabsList and TabsContent; a per-tab toolbar (its own search/filters) sits as the first child inside that tab's TabsContent. Either way it carries no own px — the PageBody gutter already insets it. (A leftover px-4 on tab content is a pre-PageBody relic; 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:

  1. FilterSearch leads — the keyword pill is always the first element.
  2. The reporting-year selector — the leading filter pill, right after search.
  3. The remaining filters — single-/multi-select, boolean, combobox.
  4. 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 PageHeaderActions looks fine and is wrong — it's a filter; it belongs right after FilterSearch in 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 FormInput or hand-rolled magnifying-glass box that isn't FilterSearch means the page hasn't been migrated.