Skip to content
Draft
19 changes: 19 additions & 0 deletions .changeset/filter-rail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@tailor-platform/app-shell": minor
---

Add `FilterRail`, an always-visible faceted filter rail that sits beside a `DataTable` and writes the same `CollectionControl`.

Sections are config — any mix of `checkbox`, `radio`, `boolean`, `text`, `numberRange`, `date`, `dateRange` and `custom` — and every other feature is an optional part: `Header`, `ClearAll` and `Settings` (show / hide, reorder, sort). Below `lg` the rail turns itself into a left sheet behind a "Filters" button, like the Sidebar; wrap the page in `FilterRail.Provider` to place that button (`FilterRail.Trigger`) anywhere.

```tsx
<FilterRail.Root control={control} sections={sections} counts={counts} layout={layout}>
<FilterRail.Header>
<FilterRail.ClearAll />
<FilterRail.Settings />
</FilterRail.Header>
<FilterRail.Sections />
</FilterRail.Root>
```

Companion hooks: `useFilterRailCounts` (in-memory facet counts) and `useFilterRailLayout` (persisted section layout).
5 changes: 5 additions & 0 deletions .changeset/layout-fill-stacked-columns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tailor-platform/app-shell": patch
---

Fix `<Layout fill>` with several columns on narrow screens. Below the breakpoint where the columns stack (`lg` for two columns, `xl` for three), the layout no longer bounds its rows, so no column is squashed to zero height and the content area scrolls as usual — as the `fill` docs already describe. Side-by-side behaviour is unchanged.
58 changes: 58 additions & 0 deletions decisions/filter-rail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Decision: `FilterRail` — an exposed faceted filter rail

> Status: **Proposed — ships `FilterRail` as a new public component.**
> Scope: a second filter surface beside `DataTable`. `DataTable.Filters` is unchanged.

## Context

`DataTable.Filters` hides filterable fields behind an "Add filter" panel. That suits the long tail of ad-hoc filters, but ERP list screens usually have a handful of axes people use constantly (category, status, availability), and for those a dropdown costs a click to learn what is even filterable and gives no hint of how many rows a choice returns.

A demo on `demo/filter-rail` proved out an always-visible rail with counts. This decision covers promoting it into the package.

## Decision

### Config sections + optional parts

Sections are a config array (`sections`, like `DataTable` `columns` or `DescriptionCard` `fields`); assembly is a compound namespace (`FilterRail.Provider / Root / Header / ClearAll / Settings / Sections / Trigger`).

- The rail must know every section to offer Clear all, reorder/hide settings and the "hiding clears its filter" rule. Pure JSX sections would need a registration protocol for that.
- A single component with a prop per feature cannot place pieces elsewhere (the narrow-screen button in a table toolbar) and grows a flag per feature. Parts are included or left out.
- A `custom` section type covers anything the built-in controls do not, and declares the `fields` it owns so it still takes part in Clear all.

### Not `DataTable.FilterRail`

The rail lives in a different `Layout.Column`, so it can never be a descendant of `DataTable.Root`. It therefore takes `control` as a prop rather than reading context.

### Same filter model, no new operators

Checkbox = `in`/`nin`, radio = `eq`/`ne`, boolean = `eq`, ranges = `between` (a field holds one filter, so `gte` + `lte` is not expressible). The operator is fixed per section; users pick values, not operators.

### Counts and storage belong to the consumer

The rail never sees rows. `counts` is a prop; `useFilterRailCounts` covers the in-memory case, a backend returns the same shape from an aggregation. Layout storage is a binding with a localStorage default (`useFilterRailLayout`) and a seam for a backend store — a team-shared layout is a product decision, not a component one.

### Narrow screens: the Sidebar pattern

Below `lg` the rail turns itself into a left sheet behind a "Filters" button, as the Sidebar does on mobile. The breakpoint check stays internal — AppShell exports no breakpoint hooks (`useIsMobile` / `useIsTablet` are private too), so apps write no breakpoint code.

The app still owns where the button goes: `FilterRail.Trigger`, inside `FilterRail.Provider`, can sit anywhere (a table toolbar, a page header). If none is mounted, `Root` renders a fallback button in its own spot, so the filters are never unreachable on a screen whose author never tested narrow widths.

`Root` takes the config directly in the common case; `Provider` is only needed when the Trigger lives outside the rail. Inside a Provider, `Root` takes no config (enforced by its prop types).

### Saved views are a separate change

Named views (filters plus sort, page size and column layout) were prototyped here and moved to a follow-up. A view is mostly `DataTable` state, its storage needs an async design for team-shared views, and keeping it out lets this review focus on the rail.

### No hidden state

Sections do not collapse; a selected option is never truncated, disabled or searched away; hiding a section clears its filter; zero-count options are disabled rather than hidden (there is no `"hide"` — removing rows reshuffles the list on every click and loses the "this has nothing" answer).

### Radio stays private

app-shell has no radio control. The rail uses a native radio in a `<fieldset>` internally; a public `RadioGroup` can be promoted later without changing the rail's API.

## Consequences

- New public surface: `FilterRail`, `FilterRailRootProps`, `FilterRailSection`, `useFilterRailCounts`, `useFilterRailLayout`.
- A field should belong to one surface: do not render `DataTable.Filters` for fields the rail owns.
- Known limit: the URL serializer stringifies scalars, so a backend must coerce `"true"`/numeric strings as it would any input (the rail itself reads `"true"` back correctly).
26 changes: 25 additions & 1 deletion docs-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,29 @@
"examples": null
}
},
"filter-rail": {
"slug": "filter-rail",
"kind": "code-backed",
"outline": "docs-src/components/filter-rail.docs.outline.md",
"output": "docs/components/filter-rail.md",
"examples": null,
"sources": ["packages/core/src/components/filter-rail/**"],
"claims": [],
"symbols": [
"FilterRail",
"FilterRailRootProps",
"FilterRailSection",
"useFilterRailCounts",
"useFilterRailLayout"
],
"hashes": {
"typeSurface": "f00196ddf9879362",
"outline": "15562a48f49aabae",
"snapshot": "5c1ae1fe48b89541",
"outputMd": "14b9a7a6c16fd50e",
"examples": null
}
},
"form": {
"slug": "form",
"kind": "code-backed",
Expand Down Expand Up @@ -1700,6 +1723,7 @@
"packages/core/skills/app-shell-patterns/references/components/description-card.md": "a347fd1f4ccd433e",
"packages/core/skills/app-shell-patterns/references/components/dialog.md": "ec6a0ac098d1d87a",
"packages/core/skills/app-shell-patterns/references/components/document-progress-card.md": "d0fb940f685f8869",
"packages/core/skills/app-shell-patterns/references/components/filter-rail.md": "14b9a7a6c16fd50e",
"packages/core/skills/app-shell-patterns/references/components/form.md": "91d901b17aed7781",
"packages/core/skills/app-shell-patterns/references/components/global-header-layout.md": "dd2b0da3702cf8d9",
"packages/core/skills/app-shell-patterns/references/components/grid.md": "9051eb53dde4179f",
Expand Down Expand Up @@ -1757,6 +1781,6 @@
"packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "916baa3cd2b9cbf1",
"packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "717ef4e7ae835aff",
"packages/core/skills/app-shell-patterns/references/migrations.md": "013c3fef2303b096",
"packages/core/skills/app-shell-patterns/SKILL.md": "526decbfd2db6555"
"packages/core/skills/app-shell-patterns/SKILL.md": "5da601f440475b77"
}
}
140 changes: 140 additions & 0 deletions docs-src/components/filter-rail.docs.outline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
---
kind: code-backed
group: filter-rail
title: FilterRail
description: An always-visible faceted filter rail with counts and user layout settings, driven by the same CollectionControl as DataTable
sources:
- packages/core/src/components/filter-rail/**
---

# FilterRail

`FilterRail` shows every filterable axis beside the results instead of hiding it behind a filter dropdown. Each section displays its options, and optionally how many rows each option would return, before the user clicks.

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.

| Part | Renders |
| --------------------- | ----------------------------------------------------------------------------- |
| `FilterRail.Root` | The rail. Takes `control`, `sections`, `counts`, `layout`. |
| `FilterRail.Header` | A title row with a slot for actions. |
| `FilterRail.ClearAll` | Clears the rail's own filters only. Hidden while none are active. |
| `FilterRail.Settings` | Show / hide, reorder and sort sections. Needs `layout`. |
| `FilterRail.Sections` | The sections, in the user's layout. Scrolls while the header stays put. |
| `FilterRail.Provider` | Holds the config when a `Trigger` sits outside the rail (see Narrow screens). |
| `FilterRail.Trigger` | The narrow-screen "Filters" button, placed anywhere inside `Provider`. |

```tsx
<FilterRail.Root control={control} sections={sections} counts={counts} 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.

| `control` | Commits | Notes |
| ------------- | ------------------------------------------ | ----------------------------------------------------------- |
| `checkbox` | `in` (or `nin`) with the ticked values | `sort`, `truncate` (default 8), `pinned`, `searchThreshold` |
| `radio` | `eq` (or `ne`) with one value | An "Any" row removes the filter; `anyLabel: null` hides it |
| `boolean` | `eq` with `true` / `false` | Three states: Any, `trueLabel`, `falseLabel` |
| `text` | `contains`, `hasPrefix` or `eq` | Debounced (`debounceMs`, default 250) |
| `numberRange` | `between` `{ min, max }`, or `gte` / `lte` | Commits on blur / Enter; one end alone is half-open |
| `date` | `eq`, `gte` or `lte` a `YYYY-MM-DD` | Uses `DatePicker` |
| `dateRange` | `between` `{ min, max }` | Uses `DateRangePicker`; optional `presets` |
| `custom` | Whatever `render` writes | Declares 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.

```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, and sections do not collapse.

## Counts

Pass `counts` to show a number beside each option and disable options that would return nothing (`zeroBehavior="show"` keeps them enabled). The rail never sees rows, so counts come from you. For rows held in memory, `useFilterRailCounts` does standard faceting: each section's count excludes its own filter, so ticking one box never zeroes its neighbours.

```tsx
const counts = useFilterRailCounts(rows, sections, control.filters, { baseline: true });
```

For a server-side collection, return the same shape from an aggregation query: `{ live: { [sectionId]: { [value]: count } }, baseline?, total?, withoutSection? }`. With `total` and `withoutSection`, an empty result shows which section to clear to get rows back. Counts are optional: without them the rail still works, it just shows no numbers.

## 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} counts={counts}>
<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>
```
Loading
Loading