Skip to content

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

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

itsprade wants to merge 9 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. It's an always-visible filter rail that sits beside a DataTable in its own Layout.Column. Every filterable axis is visible up front, 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, and a range is between, gte or lte.

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

  • Full: every section type and settings. On narrow screens the Filters button is in the table toolbar.
  • Checkboxes only: includes a 34-value Tags list with value search. On narrow screens the rail shows its own fallback button.
  • Radios + custom: radio, boolean and a custom section.

Team decisions (2026-10-13)

  1. No result counts in this PR. There's no proven way yet to get distinct values and grouped counts cheaply from TailorDB, so the per-option numbers are removed. The prototype and the feasibility questions are in the spike draft spike(filter-rail): per-option result counts — TailorDB feasibility #605. Counts can come back later as an optional prop without breaking anything.
  2. The filters stay as they are, including Settings (show/hide, reorder, sort). The only change: "By count" is removed from the sort options, because it needed counts.
  3. Checkbox and radio lists are for small, fixed sets only (status, category). Fields whose values keep growing (product names, customers, suppliers) should use a search. The docs have a "Choosing a control" table, and in development the rail warns in the console when a listed section has more than 50 options.

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>

// With header, clear-all and settings
<FilterRail.Root control={control} sections={sections} layout={layout}>
  <FilterRail.Header>
    <FilterRail.ClearAll />
    <FilterRail.Settings />
  </FilterRail.Header>
  <FilterRail.Sections />
</FilterRail.Root>

Public surface:

  • FilterRail with the parts Provider, Root, Header, ClearAll, Settings, Sections and Trigger.
  • The types FilterRailRootProps and FilterRailSection.
  • One hook: useFilterRailLayout, the user's section layout, persisted.

Narrow screens follow the Sidebar pattern

Below lg, the rail turns itself into a left sheet behind a Filters (n) button, where n is the number of applied filters. Apps write no breakpoint code, and the check stays internal, like useIsMobile.

  • Placing the button: to put it somewhere specific, such as a table toolbar, wrap the page in FilterRail.Provider and place <FilterRail.Trigger /> there.
  • Default: if no Trigger is placed, Root renders a fallback button in its own spot, so the filters are never unreachable.
<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>

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

Also in this PR: a Layout fix (separate commit)

fix(layout): don't squash stacked columns in fill mode is a pre-existing bug that this PR surfaced.

The bug: below the breakpoint where columns stack, <Layout fill> still bounded its rows. Every column except the last got 0px of height, and the next column painted over it. The fill docs already say a stacked layout "scrolls as usual", so the code didn't match its own docs.

The fix:

  • The row bound now applies only from the breakpoint where columns sit side by side: always for one column, lg for two, xl for three.
  • data-layout-fill carries that breakpoint, so the AppShell content area clips only while the page is actually bounded.
  • Side-by-side behaviour is unchanged.
  • Covered by tests, with its own patch changeset.

Changes since the draft

  • Counts: removed and moved to the spike spike(filter-rail): per-option result counts — TailorDB feasibility #605 (see Team decisions).
  • Saved views: moved to a follow-up. A "view" is mostly DataTable state, and team-shared storage needs an async design. The code is kept on the feat/filter-rail-saved-views branch.
  • Narrow screens: reworked to the Sidebar pattern above. FilterRail.Sheet and the public useFilterRailCompact are removed.
  • No more useEffect prop mirroring:
    • The text and number-range controls reconcile during render instead.
    • Text debounces in its change handler, so keys typed after the input's own commit survive.
  • Number range:
    • A half-open range commits gte or lte.
    • Invalid input is ignored.
    • Ends typed the wrong way round are swapped.
  • Value search now appears on any checkbox list long enough to be truncated (more than 8 options by default).
  • Type tests prove that a misspelled field fails against a typed control, and that Root's config rules are enforced.

Checks

Run locally after merging the latest main:

  • type-check, lint, fmt:check and test all pass (2,229 core tests).
  • docs:sync and docs:check pass.
  • In the browser, I checked the wide layout, the narrow layout with the Trigger in the toolbar, the narrow fallback button, and the stacked Layout fill page.

Commits are unsigned (local GPG prompt issue) and will be re-signed.

🤖 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>
Comment thread packages/core/src/components/filter-rail/use-filter-rail-counts.ts Fixed
@interacsean

Copy link
Copy Markdown
Contributor

Questions:

The FilterSettings: Reordering/hiding the fields / updating sort. If the user changes this, then saves a filter, do these save as well? I wasn't clear on if it was the settings as well as the selected values

Something feels a bit odd with the FilterSettings bit.

I think my clearest thought is the sorting: This might be unnecessary to allow the user to update. It could just be in the code, where at code-level you specify for each field that can be filtered it's sort order.  I suspect it will be pretty fixed.  (If we decide we want to allow sorting, maybe it would be better in each section to have an option.

Along the same lines I'm not sure the user needs to be able to re-order the sections. I could be convinced that they do.  We would see something like this on an AirTable type app, but AirTable is a generic builder, whereas we provide bespoke screens.

It would be great for the user to be able to search for a value. This UX seems to preference a list of options; so for a long list, they would have to scroll unless they can use a text-search to quickly front-end filter down to matching values, then they can select which values they want to include in their filter search

And I'm wondering how in-depth this POC is, is it more of a mock-up, or is it working? I am wondering what the best way is to get all the values for something like color, where we want to show all the options available.  For a dataset with a lot of rows, we may need to check if there's a way to select all distinct values (like you would with a traditional sql db), or if this won't work in TailorDB and to support this we'd need some CDC events to keep some cache tables

Likewise, I'd be keen to discuss if it is possible to show the numbers. As you select items, e.g. black, all the other numbers would need to change right? Because now there are fewer matching 'Accessories', 'Tops' that are black.  Probably do-able since queries can return totals, but this would be a lot of queries to fire off to get these numbers

itsprade and others added 4 commits October 12, 2026 12:55
…veView)

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

- Narrow screens follow the Sidebar pattern: below lg the rail turns itself
  into a left sheet behind a "Filters" button. New FilterRail.Provider lets
  FilterRail.Trigger sit anywhere (e.g. a table toolbar); with no Trigger,
  Root renders a fallback button. Removes FilterRail.Sheet and the public
  useFilterRailCompact hook (breakpoint check is internal, like useIsMobile).
- Move saved views out to a follow-up (branch feat/filter-rail-saved-views).
- Fix CodeQL prototype pollution: null-prototype count maps, and countOf()
  reads only own keys so an option value like "constructor" counts as 0.
- Replace useEffect prop mirroring in the text and number-range controls with
  render-time reconciliation; debounce text in the change handler, keeping
  keys typed after the input's own commit.
- Number range ignores invalid input and swaps ends typed the wrong way round.
- Value search appears whenever a checkbox list is long enough to truncate.
- Layout hook re-hydrates on key change during render, not in an effect.
- Add type tests for field typing and Root/Provider config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`<Layout fill>` bounded its rows at every width, so below the breakpoint
where columns stack (lg for two, xl for three) every column but the last got
zero height and the next column painted over it. The fill docs already say a
stacked layout scrolls as usual; this makes the code match.

The fill bound now applies only from the breakpoint where columns sit side by
side, and `data-layout-fill` carries that breakpoint (always | lg | xl) so the
AppShell content area clips only while the page is actually bounded.

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

# Conflicts:
#	docs-manifest.json
#	examples/vite-app/src/App.tsx
@itsprade
itsprade marked this pull request as ready for review October 12, 2026 08:10
@itsprade
itsprade requested a review from a team as a code owner October 12, 2026 08:10
@itsprade
itsprade requested a review from interacsean October 12, 2026 08:10
@github-actions

Copy link
Copy Markdown
Contributor

Code Metrics Report

main (403eebe) #585 (907e396) +/-
Coverage 88.1% 88.0% -0.2%
Test Execution Time 2m18s 1m36s -42s
Details
  |                     | main (403eebe) | #585 (907e396) |  +/-  |
  |---------------------|----------------|----------------|-------|
- | Coverage            |          88.1% |          88.0% | -0.2% |
  |   Files             |            207 |            221 |   +14 |
  |   Lines             |           6178 |           6784 |  +606 |
+ |   Covered           |           5448 |           5972 |  +524 |
+ | Test Execution Time |          2m18s |          1m36s |  -42s |

Code coverage of files in pull request scope (100.0% → 87.2%, patch 86.4%)

Files Coverage +/- Patch Coverage Status
packages/core/src/components/filter-rail/apply-layout.ts 100.0% +100.0% 100.0% added
packages/core/src/components/filter-rail/controls.tsx 66.1% +66.1% 66.1% added
packages/core/src/components/filter-rail/filter-rail-context.ts 100.0% +100.0% 100.0% added
packages/core/src/components/filter-rail/filter-rail.tsx 95.7% +95.7% 95.7% added
packages/core/src/components/filter-rail/i18n.ts 50.0% +50.0% 50.0% added
packages/core/src/components/filter-rail/index.ts 100.0% +100.0% 100.0% added
packages/core/src/components/filter-rail/internals.tsx 93.5% +93.5% 93.5% added
packages/core/src/components/filter-rail/operators.ts 100.0% +100.0% 100.0% added
packages/core/src/components/filter-rail/responsive.tsx 100.0% +100.0% 100.0% added
packages/core/src/components/filter-rail/sections.tsx 86.6% +86.6% 86.6% added
packages/core/src/components/filter-rail/settings.tsx 67.6% +67.6% 67.6% added
packages/core/src/components/filter-rail/storage.ts 100.0% +100.0% 100.0% added
packages/core/src/components/filter-rail/use-filter-rail-counts.ts 100.0% +100.0% 100.0% added
packages/core/src/components/filter-rail/use-filter-rail-layout.ts 100.0% +100.0% 100.0% added
packages/core/src/components/layout/Layout.tsx 100.0% 0.0% 100.0% modified
packages/core/src/components/sidebar/content-container.tsx 100.0% 0.0% - modified
packages/core/src/index.ts 0.0% 0.0% - modified

Reported by octocov

itsprade and others added 2 commits October 13, 2026 16:57
…earch

Agreed with the team (2026-10-13):

- Remove per-option result counts. There is no proven way yet to get
  distinct values and grouped counts cheaply from TailorDB, so `counts`,
  `zeroBehavior`, `useFilterRailCounts`, the empty-result hint and the
  count-based option sorts ("By count" in Settings) go. The prototype is kept
  on feat/filter-rail-counts for a spike; counts can return as an additive,
  optional prop.
- Leave the filters as they are otherwise.
- Checkbox and radio sections are for small, fixed sets. Docs gain a
  "Choosing a control" table steering fields whose values keep growing
  (names, customers, suppliers) to a search, and in development the rail
  warns when a listed section has more than 50 options.

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

# Conflicts:
#	docs-manifest.json
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.

3 participants