Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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} layout={layout}>
<FilterRail.Header>
<FilterRail.ClearAll />
<FilterRail.Settings />
</FilterRail.Header>
<FilterRail.Sections />
</FilterRail.Root>
```

Companion hook: `useFilterRailLayout` (persisted section layout). Checkbox and radio sections are for small, fixed sets of values; the docs explain which control to use for fields whose values keep growing.
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.
67 changes: 67 additions & 0 deletions decisions/filter-rail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# 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 just to learn what is filterable.

A demo on `demo/filter-rail` proved out an always-visible rail. 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.

### No result counts (for now)

The prototype showed how many rows each option would return. That needs one aggregate query per filtered section (each excluding its own filter), plus distinct values for every listed field. There is no proven way to get either cheaply from TailorDB yet, so counts are **removed from this change** (agreed with the team, 2026-10-13). A spike will establish whether TailorDB can serve distinct values and grouped counts, or whether CDC-maintained cache tables are needed; the prototype code is kept on `feat/filter-rail-counts` for it. Counts can return later as an additive, optional prop.

### Listed options are for small, fixed sets

Checkbox and radio sections take a static `options` array and list every value. They are for small, fixed sets (status, category). A field whose values keep growing (product names, customers, suppliers) must use a search instead — a `text` section, or a `custom` section rendering a searchable picker. The docs carry a "Choosing a control" table, and in development the rail warns when a listed section has more than 50 options. Loading options asynchronously into a checkbox section is a possible follow-up, not part of this change.

### Storage belongs to the consumer

The rail never sees rows. 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.

### 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`, `useFilterRailLayout`.
- Follow-ups: result counts (spike first), saved views, and possibly async option loading.
- 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).
20 changes: 19 additions & 1 deletion docs-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,23 @@
"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", "useFilterRailLayout"],
"hashes": {
"typeSurface": "f80feb4bebee066f",
"outline": "d2114b5403a2cdf2",
"snapshot": "21267134f12858cf",
"outputMd": "5b6feade4643dde3",
"examples": null
}
},
"form": {
"slug": "form",
"kind": "code-backed",
Expand Down Expand Up @@ -1700,6 +1717,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": "5b6feade4643dde3",
"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 +1775,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": "4650734e91fbc858"
}
}
146 changes: 146 additions & 0 deletions docs-src/components/filter-rail.docs.outline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
---
kind: code-backed
group: filter-rail
title: FilterRail
description: An always-visible faceted filter rail with 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 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.

| Part | Renders |
| --------------------- | ----------------------------------------------------------------------------- |
| `FilterRail.Root` | The rail. Takes `control`, `sections`, `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} 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.

## 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 any | `checkbox` |
| A small fixed set, pick one | `radio` (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 records | a `custom` section rendering a searchable picker (e.g. an async `Combobox`) |
| Numbers or dates | `numberRange`, `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>
```
Loading
Loading