Skip to content
Draft
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
20 changes: 20 additions & 0 deletions .changeset/filter-rail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@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`, `Settings` (reorder / hide / sort), `SavedViews`, and `Trigger` + `Sheet` for narrow screens.

```tsx
<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>
```

Companion hooks: `useFilterRailCounts` (in-memory facet counts), `useFilterRailLayout` (persisted section layout), `useSavedViews` (named DataTable views — filters, sort, page size and column layout) and `useFilterRailCompact`.
46 changes: 46 additions & 0 deletions decisions/filter-rail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# 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.Root / Header / ClearAll / Settings / SavedViews / Sections / Trigger / Sheet`).

- 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 (saved views in a page header) 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 and saved-view storage are bindings with localStorage defaults (`useFilterRailLayout`, `useSavedViews`) and a seam for a backend store — team-shared views and a default view for everyone are product decisions, not component ones.

### 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`, `useSavedViews`, `useFilterRailCompact`.
- 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).
28 changes: 27 additions & 1 deletion docs-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,31 @@
"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",
"useFilterRailCompact",
"useFilterRailCounts",
"useFilterRailLayout",
"useSavedViews"
],
"hashes": {
"typeSurface": "ab5ce723db3dee73",
"outline": "8369adf1c11980ce",
"snapshot": "e113b988f35c6e46",
"outputMd": "139eaf4f874e8002",
"examples": null
}
},
"form": {
"slug": "form",
"kind": "code-backed",
Expand Down Expand Up @@ -1680,6 +1705,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": "139eaf4f874e8002",
"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 @@ -1736,6 +1762,6 @@
"packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "8062ea0df9403fbb",
"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": "814325f4ea06ffae"
"packages/core/skills/app-shell-patterns/SKILL.md": "78416714e79e4594"
}
}
156 changes: 156 additions & 0 deletions docs-src/components/filter-rail.docs.outline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
---
kind: code-backed
group: filter-rail
title: FilterRail
description: An always-visible faceted filter rail with counts, user layout settings and saved views, 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 landmark. 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` on `Root`. |
| `FilterRail.SavedViews` | A saved-view picker and Save button. Also works outside `Root`. |
| `FilterRail.Sections` | The sections, in the user's layout. Scrolls while the header stays put. |
| `FilterRail.Trigger` | A toolbar button carrying the active count, for narrow screens. |
| `FilterRail.Sheet` | A left sheet to hold the rail on narrow screens. |

```tsx
<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>
```

### 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 }` | Commits on blur / Enter; an empty end uses `min` / `max` |
| `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 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.

## 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.

## Saved views

`useSavedViews(table, { storageKey })` saves a `DataTable` **view** — filters plus sort, page size and column order, visibility and pinning — and returns the binding `FilterRail.SavedViews` renders. Pass `storage: { load, save }` to keep views in a backend store instead of localStorage.

```tsx
const table = useDataTable({ columns, data, control });
const views = useSavedViews(table, { storageKey: "products" });

<FilterRail.SavedViews {...views} />;
```

## 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.

Below the `lg` breakpoint there is no room for a side column. `useFilterRailCompact()` reports that, and the same rail moves into `FilterRail.Sheet` behind a `FilterRail.Trigger`.

```tsx
const compact = useFilterRailCompact();
const [open, setOpen] = useState(false);
const rail = (
<FilterRail.Root control={control} sections={sections}>
<FilterRail.Sections />
</FilterRail.Root>
);

<Layout fill>
{!compact && <Layout.Column area="left">{rail}</Layout.Column>}
<Layout.Column area="main">
<DataTable.Root value={table}>
<DataTable.Toolbar>
{compact && (
<FilterRail.Trigger control={control} sections={sections} onClick={() => setOpen(true)} />
)}
</DataTable.Toolbar>
<DataTable.Table />
</DataTable.Root>
</Layout.Column>
{compact && (
<FilterRail.Sheet open={open} onOpenChange={setOpen}>
{rail}
</FilterRail.Sheet>
)}
</Layout>;
```
Loading
Loading