Skip to content
View as Markdown

FilterRail ​

FilterRail shows every filterable axis beside the results instead of hiding it behind a filter dropdown. Each section shows its options up front, so users see what they can filter by without opening anything.

It writes the same CollectionControl a DataTable reads, so there is no second filter engine: a checkbox section is the in operator, a radio section is eq, a range is between.

tsx
import { FilterRail, type FilterRailSection } from "@tailor-platform/app-shell";

const sections: FilterRailSection[] = [
  {
    id: "category",
    label: "Category",
    control: "checkbox",
    field: "category",
    options: categories,
  },
  { id: "status", label: "Status", control: "radio", field: "status", options: statuses },
];

<FilterRail.Root control={control} sections={sections}>
  <FilterRail.Sections />
</FilterRail.Root>;

Sections and parts ​

What the rail shows is config: sections, one entry per axis, in screen order. How the rail is assembled is parts: leave a part out and that feature is not rendered.

PartRenders
FilterRail.RootThe rail. Takes control, sections, layout.
FilterRail.HeaderA title row with a slot for actions.
FilterRail.ClearAllClears the rail's own filters only. Hidden while none are active.
FilterRail.SettingsShow / hide, reorder and sort sections. Needs layout.
FilterRail.SectionsThe sections, in the user's layout. Scrolls while the header stays put.
FilterRail.ProviderHolds the config when a Trigger sits outside the rail (see Narrow screens).
FilterRail.TriggerThe narrow-screen "Filters" button, placed anywhere inside Provider.
tsx
<FilterRail.Root control={control} sections={sections} layout={layout}>
  <FilterRail.Header>
    <FilterRail.ClearAll />
    <FilterRail.Settings />
  </FilterRail.Header>
  <FilterRail.Sections />
</FilterRail.Root>

Section types ​

Every section has an id, a label and an optional hint. The operator is fixed per section — the user picks values, never operators.

controlCommitsNotes
checkboxin (or nin) with the ticked valuessort, truncate (default 8), pinned, searchThreshold
radioeq (or ne) with one valueAn "Any" row removes the filter; anyLabel: null hides it
booleaneq with true / falseThree states: Any, trueLabel, falseLabel
textcontains, hasPrefix or eqDebounced (debounceMs, default 250)
numberRangebetween { min, max }, or gte / lteCommits on blur / Enter; one end alone is half-open
dateeq, gte or lte a YYYY-MM-DDUses DatePicker
dateRangebetween { min, max }Uses DateRangePicker; optional presets
customWhatever render writesDeclares the fields it owns so Clear all includes them

A checkbox list long enough to be truncated also gets a search box, so a value can be found without scrolling (searchThreshold overrides when it appears).

A field holds one filter, so two sections must not share a field; the rail warns in the console when they do.

Typing fields ​

Give FilterRailSection the row's keys so a field that is not on the table is a type error, the same way createColumnHelper<Row>() types columns. This works with a typed control or a plain one:

tsx
const sections: FilterRailSection<keyof Product & string>[] = [
  { id: "category", label: "Category", control: "checkbox", field: "category", options },
  // field: "categroy" → type error
];

With a typed control (from useCollectionVariables({ tableMetadata })), a section whose field the control does not have is also rejected.

tsx
const stockLevel: FilterRailSection = {
  id: "stockLevel",
  label: "Stock level",
  control: "custom",
  fields: ["stock"],
  render: ({ getFilter, setFilter }) => (
    <Button
      variant="outline"
      size="xs"
      onClick={() => setFilter("stock", { operator: "between", value: { min: 1, max: 20 } })}
    >
      Low stock
    </Button>
  ),
};

The rail never hides state: a selected option is never truncated away, never disabled and never hidden by a section's search box.

Collapsing sections ​

Each section collapses and expands from the chevron by its title. Sections start expanded; set defaultCollapsed: true to start one collapsed (for a rarely used axis). While a section is collapsed, its header still shows how many values are selected and keeps its Clear, so a collapsed section never hides an applied filter. The open / closed state is kept while the page is open, including across the narrow-screen sheet opening and closing.

tsx
{ id: "warehouse", label: "Warehouse", control: "checkbox", field: "warehouse", options, defaultCollapsed: true }

Choosing a control ​

Checkbox and radio sections list every option, so they are only for small, fixed sets whose values you know up front — status, category, size, a handful of warehouses. Their options are a static array; the rail does not fetch them.

Do not use a checkbox or radio list for a field whose values keep growing — product names, SKUs, customers, suppliers, order numbers. The list gets longer with every new record, takes over the rail, and becomes a scroll hunt. Give those fields a search instead:

The field's values are…Use
A small fixed set (≈ up to 20), pick anycheckbox
A small fixed set, pick oneradio (or boolean for yes / no)
Open-ended and growing (names, codes, customers)text — a search that commits contains, hasPrefix or eq
Open-ended, but users must pick exact recordsa custom section rendering a searchable picker (e.g. an async Combobox)
Numbers or datesnumberRange, date, dateRange

In development the rail warns in the console when a checkbox or radio section lists more than 50 options, as a nudge towards a search.

User layout ​

useFilterRailLayout(storageKey) persists the user's section order, hidden sections and option sort to localStorage. Pass it as layout and add FilterRail.Settings. Hiding a section that carries a filter also clears that filter. To share a layout across a team, pass your own { value, onChange, onReset } binding instead.

Beside a DataTable ​

The rail sits in its own Layout.Column, outside DataTable.Root, which is why control is a prop. Do not also render DataTable.Filters for the same fields — one fact in two places can drift.

Narrow screens ​

Below the lg breakpoint there is no room for a side column, so the rail turns itself into a left sheet behind a Filters button carrying the active count — the same way the Sidebar handles small screens. Apps write no breakpoint code.

By default the button appears where the rail would have been. To put it somewhere else — a table toolbar, a page header — wrap the page in FilterRail.Provider, move the config there, and place FilterRail.Trigger wherever you want it. The Trigger renders nothing on wide screens, and while one is mounted the rail skips its own button.

tsx
<FilterRail.Provider control={control} sections={sections}>
  <Layout fill>
    <Layout.Column area="left">
      <FilterRail.Root>
        <FilterRail.Sections />
      </FilterRail.Root>
    </Layout.Column>
    <Layout.Column area="main">
      <DataTable.Root value={table}>
        <DataTable.Toolbar>
          <FilterRail.Trigger />
        </DataTable.Toolbar>
        <DataTable.Table />
      </DataTable.Root>
    </Layout.Column>
  </Layout>
</FilterRail.Provider>