Skip to content

feat(core): add FilterRail — an always-visible faceted filter rail - #585

Draft
itsprade wants to merge 3 commits into
mainfrom
claude/new-filter-component-da0c71
Draft

itsprade wants to merge 3 commits into
mainfrom
claude/new-filter-component-da0c71

Conversation

@itsprade

@itsprade itsprade commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

What

A new public component, FilterRail: an always-visible faceted filter rail that sits beside a DataTable, in its own Layout.Column. Every filterable axis is visible, with optional counts per option, instead of being hidden behind the "Add filter" panel.

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

This promotes the prototype from the demo/filter-rail branch (examples-only, never meant to merge) into packages/core.

Demo: /showcase/filter-rail in the vite example has three variants:

  • Full: every section type, with counts, settings, saved views, and a sheet below lg.
  • Checkboxes only: Root + Sections and nothing else.
  • Radios + custom: no counts and no saved views.

API: config sections + optional parts

This follows the DataTable model:

  • What it shows is config. sections works like columns. Each section is checkbox, radio, boolean, text, numberRange, date, dateRange or custom.
  • How it's assembled is parts. Leave a part out and that feature doesn't render.
// Minimal
<FilterRail.Root control={control} sections={sections}>
  <FilterRail.Sections />
</FilterRail.Root>

// Full
<FilterRail.Root control={control} sections={sections} counts={counts} layout={layout}>
  <FilterRail.Header>
    <FilterRail.ClearAll />
    <FilterRail.Settings />
  </FilterRail.Header>
  <FilterRail.SavedViews {...views} />
  <FilterRail.Sections />
</FilterRail.Root>

Parts:

  • Root, Header and Sections: the rail itself.
  • ClearAll: clears only the rail's own fields.
  • Settings: show/hide, reorder and sort sections.
  • SavedViews: filters plus sort, page size and column layout.
  • Trigger + Sheet: the narrow-screen layout.

Hooks:

  • useFilterRailCounts: in-memory faceting.
  • useFilterRailLayout: the user's section layout, persisted.
  • useSavedViews(table, { storageKey, storage? }): saved views, with a pluggable store.
  • useFilterRailCompact: true below lg.

The rail stays backend-agnostic. It never sees rows: counts are a prop, with a backend returning the same shape from an aggregation query. Layout and saved views are bindings that default to localStorage and can be backed by a server store.

The reasoning is in decisions/filter-rail.md. Highlights:

  • Why not DataTable.FilterRail: the rail lives outside DataTable.Root.
  • No hidden state: sections never collapse, and a selected option is never truncated, disabled or searched away.
  • Radio stays private for now.

Relation to DataTable.Filters

The two complement each other. Use the rail for the 4–6 everyday fields and DataTable.Filters for the long tail. A field should belong to one surface, not both.

Changed vs the demo

  • Half-open number range: typing one end commits gte / lte. The demo filled the empty end from the section bounds, which made the boxes impossible to clear one at a time.
  • Option counts and screen readers: each option is announced as its name plus a description ("Apparel", "1,380 items"). Before, the name was read twice.
  • Custom sections: two setFilter calls in one handler now both stick.
  • Settings popover: uses @base-ui/react/popover, the same as DataTable column settings, instead of a hand-rolled portal.

Open questions for review

  • Keep useFilterRailCompact public, or leave breakpoint detection to apps?
  • Is the saved-view delete icon OK inside the menu item? It's a span role="button" with a lint exception. The alternative is a separate delete action.
  • Follow-up: should a FilterRail.fromColumns(columns, ids) helper derive sections from column.filter, so a field isn't configured twice?

Checks

The following all pass locally after merging main:

  • type-check, lint, fmt:check, test (2,145 core tests, 122 of them for FilterRail).
  • docs:sync and docs:check.

Notes:

  • Commits are unsigned (local GPG prompt issue) and will be re-signed.
  • Draft until the UI has been reviewed in the local demo.

🤖 Generated with Claude Code

itsprade and others added 3 commits October 8, 2026 11:22
Promotes the `demo/filter-rail` prototype into packages/core as a public
component. It writes the same CollectionControl a DataTable reads, so there
is no second filter engine.

- Config sections (checkbox, radio, boolean, text, numberRange, date,
  dateRange, custom) + optional compound parts: Root, Header, ClearAll,
  Settings, SavedViews, Sections, Trigger, Sheet.
- Hooks: useFilterRailCounts, useFilterRailLayout, useSavedViews,
  useFilterRailCompact.
- Showcase page /showcase/filter-rail with three variants, docs outline,
  decision record and changeset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ponent-da0c71

# Conflicts:
#	docs-manifest.json
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

const add = (index: number, target: Record<string, Record<string, number>>) => {
const bucket = target[sections[index]!.id]!;
for (const value of contributions[index]!) bucket[value] = (bucket[value] ?? 0) + 1;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants