diff --git a/.changeset/filter-rail.md b/.changeset/filter-rail.md
new file mode 100644
index 00000000..06351d22
--- /dev/null
+++ b/.changeset/filter-rail.md
@@ -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
+
+
+
+
+
+
+
+
+```
+
+Companion hooks: `useFilterRailCounts` (in-memory facet counts), `useFilterRailLayout` (persisted section layout), `useSavedViews` (named DataTable views — filters, sort, page size and column layout) and `useFilterRailCompact`.
diff --git a/decisions/filter-rail.md b/decisions/filter-rail.md
new file mode 100644
index 00000000..4fa0a5eb
--- /dev/null
+++ b/decisions/filter-rail.md
@@ -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 `
` 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).
diff --git a/docs-manifest.json b/docs-manifest.json
index fca243a6..d097e85f 100644
--- a/docs-manifest.json
+++ b/docs-manifest.json
@@ -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",
@@ -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",
@@ -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"
}
}
diff --git a/docs-src/components/filter-rail.docs.outline.md b/docs-src/components/filter-rail.docs.outline.md
new file mode 100644
index 00000000..01618170
--- /dev/null
+++ b/docs-src/components/filter-rail.docs.outline.md
@@ -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 },
+];
+
+
+
+ ;
+```
+
+## 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
+
+
+
+
+
+
+
+
+```
+
+### 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 }) => (
+ setFilter("stock", { operator: "between", value: { min: 1, max: 20 } })}
+ >
+ Low stock
+
+ ),
+};
+```
+
+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" });
+
+ ;
+```
+
+## 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 = (
+
+
+
+);
+
+
+ {!compact && {rail} }
+
+
+
+ {compact && (
+ setOpen(true)} />
+ )}
+
+
+
+
+ {compact && (
+
+ {rail}
+
+ )}
+ ;
+```
diff --git a/docs/components/filter-rail.md b/docs/components/filter-rail.md
new file mode 100644
index 00000000..8ba0c8ae
--- /dev/null
+++ b/docs/components/filter-rail.md
@@ -0,0 +1,154 @@
+---
+title: FilterRail
+description: An always-visible faceted filter rail with counts, user layout settings and saved views, driven by the same CollectionControl as DataTable
+---
+
+
+
+# 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 },
+];
+
+
+
+ ;
+```
+
+## 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
+
+
+
+
+
+
+
+
+```
+
+### 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 }) => (
+ setFilter("stock", { operator: "between", value: { min: 1, max: 20 } })}
+ >
+ Low stock
+
+ ),
+};
+```
+
+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" });
+
+ ;
+```
+
+## 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 = (
+
+
+
+);
+
+
+ {!compact && {rail} }
+
+
+
+ {compact && (
+ setOpen(true)} />
+ )}
+
+
+
+
+ {compact && (
+
+ {rail}
+
+ )}
+ ;
+```
diff --git a/examples/vite-app/src/App.tsx b/examples/vite-app/src/App.tsx
index 353ec56f..df81237a 100644
--- a/examples/vite-app/src/App.tsx
+++ b/examples/vite-app/src/App.tsx
@@ -78,6 +78,7 @@ const AppInner = () => {
+
diff --git a/examples/vite-app/src/pages/showcase/filter-rail/page.tsx b/examples/vite-app/src/pages/showcase/filter-rail/page.tsx
new file mode 100644
index 00000000..e76d3399
--- /dev/null
+++ b/examples/vite-app/src/pages/showcase/filter-rail/page.tsx
@@ -0,0 +1,572 @@
+import { useMemo, useState, type ReactNode } from "react";
+import {
+ Badge,
+ DataTable,
+ FilterRail,
+ Layout,
+ Tabs,
+ createColumnHelper,
+ useCollectionVariables,
+ useDataTable,
+ useFilterRailCompact,
+ useFilterRailCounts,
+ useFilterRailLayout,
+ useSavedViews,
+ useURLCollectionVariables,
+ type AppShellPageProps,
+ type CollectionControl,
+ type CollectionVariables,
+ type DataTableData,
+ type FilterRailSection,
+ type PageInfo,
+} from "@tailor-platform/app-shell";
+import { PanelLeft } from "lucide-react";
+
+import { allProducts, type Product } from "../../../mock-products";
+
+// ─── FilterRail showcase ────────────────────────────────────────────────────────
+//
+// An always-visible faceted filter rail beside a DataTable. The rail and the
+// table share one `CollectionControl`, so there is no second filter engine.
+//
+// Three variants prove the parts are optional:
+// Full — every section type, counts, settings, saved views, sheet below lg
+// Checkboxes — Root + Sections only
+// Radios — radio + boolean + a custom section, no counts, no saved views
+
+// ─── Rows ───────────────────────────────────────────────────────────────────────
+
+// 🧪 Dummy Data: Replace with real data later
+type Row = Product & {
+ inStock: boolean;
+ /** `publishedAt` is an ISO datetime; date sections commit `YYYY-MM-DD`. */
+ publishedOn: string;
+ priceBand: string;
+};
+
+const band = (price: number) =>
+ price < 25 ? "Under $25" : price < 75 ? "$25 – $75" : price < 150 ? "$75 – $150" : "$150+";
+
+const ROWS: Row[] = allProducts.map((product) => ({
+ ...product,
+ inStock: product.stock > 0,
+ publishedOn: product.publishedAt.slice(0, 10),
+ priceBand: band(product.price),
+}));
+
+const distinct = (pick: (row: Row) => string) =>
+ [...new Set(ROWS.map(pick))].sort().map((value) => ({ value, label: value }));
+
+const range = (pick: (row: Row) => number) => {
+ const values = ROWS.map(pick);
+ return { min: Math.floor(Math.min(...values)), max: Math.ceil(Math.max(...values)) };
+};
+
+const PRICE = range((row) => row.price);
+const STOCK = range((row) => row.stock);
+const PUBLISHED = {
+ min: ROWS.reduce((a, r) => (r.publishedOn < a ? r.publishedOn : a), ROWS[0].publishedOn),
+ max: ROWS.reduce((a, r) => (r.publishedOn > a ? r.publishedOn : a), ROWS[0].publishedOn),
+};
+
+// ─── Local data source (stands in for a GraphQL query) ─────────────────────────
+
+function compare(a: unknown, b: unknown) {
+ if (typeof a === "number" || typeof b === "number") return Number(a) - Number(b);
+ return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
+}
+
+function matches(value: unknown, op: string, operand: unknown): boolean {
+ // The URL round-trip stringifies scalars, as a backend's input parsing would undo.
+ const scalar =
+ typeof value === "boolean" && typeof operand === "string" ? operand === "true" : operand;
+ switch (op) {
+ case "eq":
+ return value === scalar;
+ case "ne":
+ return value !== scalar;
+ case "gte":
+ return compare(value, operand) >= 0;
+ case "lte":
+ return compare(value, operand) <= 0;
+ case "between": {
+ const { min, max } = operand as { min: unknown; max: unknown };
+ return compare(value, min) >= 0 && compare(value, max) <= 0;
+ }
+ case "in":
+ return Array.isArray(operand) && operand.map(String).includes(String(value));
+ case "nin":
+ return Array.isArray(operand) && !operand.map(String).includes(String(value));
+ case "contains":
+ return String(value).toLowerCase().includes(String(operand).toLowerCase());
+ default:
+ return true;
+ }
+}
+
+function queryRows(variables: CollectionVariables): DataTableData {
+ const query = variables.query ?? {};
+ let rows = ROWS.filter((row) =>
+ Object.entries(query).every(([field, ops]) =>
+ Object.entries(ops as Record).every(([op, operand]) =>
+ matches(row[field as keyof Row], op, operand),
+ ),
+ ),
+ );
+
+ if (variables.order?.length) {
+ const [{ field, direction }] = variables.order;
+ const dir = direction === "Desc" ? -1 : 1;
+ rows = [...rows].sort((a, b) => compare(a[field as keyof Row], b[field as keyof Row]) * dir);
+ }
+
+ const total = rows.length;
+ const { first, after, last, before } = variables.pagination;
+ let start: number;
+ let end: number;
+ if (last != null) {
+ end = before != null ? Number(before) : total;
+ start = Math.max(0, end - last);
+ } else {
+ start = after != null ? Number(after) + 1 : 0;
+ end = start + (first ?? 25);
+ }
+ const page = rows.slice(start, end);
+ const pageInfo: PageInfo = {
+ startCursor: page.length ? String(start) : null,
+ endCursor: page.length ? String(start + page.length - 1) : null,
+ hasPreviousPage: start > 0,
+ hasNextPage: start + page.length < total,
+ };
+ return { rows: page, pageInfo, total };
+}
+
+// ─── Columns ────────────────────────────────────────────────────────────────────
+
+const money = (value: number) => `$${value.toFixed(2)}`;
+const showDate = (value: string) =>
+ new Date(`${value}T00:00:00Z`).toLocaleDateString("en-GB", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ timeZone: "UTC",
+ });
+
+const { column } = createColumnHelper();
+
+const COLUMNS = [
+ column({
+ id: "name",
+ label: "Product",
+ accessor: (row) => row.name,
+ type: "text",
+ width: 220,
+ sort: { field: "name", type: "string" },
+ pin: "left",
+ }),
+ column({
+ id: "category",
+ label: "Category",
+ accessor: (row) => row.category,
+ type: "text",
+ width: 140,
+ sort: { field: "category", type: "string" },
+ }),
+ column({
+ id: "status",
+ label: "Status",
+ render: (row) => (
+ {row.status}
+ ),
+ width: 110,
+ }),
+ column({
+ id: "price",
+ label: "Price",
+ render: (row) => {money(row.price)} ,
+ accessor: (row) => row.price,
+ type: "number",
+ align: "right",
+ width: 110,
+ sort: { field: "price", type: "number" },
+ }),
+ column({
+ id: "stock",
+ label: "Stock",
+ accessor: (row) => row.stock,
+ type: "number",
+ align: "right",
+ width: 90,
+ sort: { field: "stock", type: "number" },
+ }),
+ column({
+ id: "publishedOn",
+ label: "Published",
+ render: (row) => {showDate(row.publishedOn)} ,
+ width: 130,
+ sort: { field: "publishedOn", type: "string" },
+ }),
+ column({
+ id: "availableOn",
+ label: "Available",
+ render: (row) => {showDate(row.availableOn)} ,
+ width: 130,
+ sort: { field: "availableOn", type: "string" },
+ }),
+];
+
+// ─── Sections ───────────────────────────────────────────────────────────────────
+
+/** Full: one of every built-in section type. Array order is screen order. */
+const FULL_SECTIONS: FilterRailSection[] = [
+ {
+ id: "name",
+ label: "Product name",
+ control: "text",
+ field: "name",
+ operator: "contains",
+ placeholder: "Search…",
+ },
+ {
+ id: "status",
+ label: "Status",
+ control: "radio",
+ field: "status",
+ options: distinct((row) => row.status),
+ },
+ {
+ id: "category",
+ label: "Category",
+ control: "checkbox",
+ field: "category",
+ options: distinct((row) => row.category),
+ // Biggest first, on the baseline count so the order never moves under the cursor.
+ sort: "baselineCount",
+ truncate: 6,
+ },
+ {
+ id: "inStock",
+ label: "Availability",
+ control: "boolean",
+ field: "inStock",
+ trueLabel: "In stock",
+ falseLabel: "Out of stock",
+ },
+ {
+ id: "price",
+ label: "Price",
+ control: "numberRange",
+ field: "price",
+ min: PRICE.min,
+ max: PRICE.max,
+ step: 1,
+ unit: "$",
+ },
+ {
+ id: "stock",
+ label: "Stock on hand",
+ control: "numberRange",
+ field: "stock",
+ min: STOCK.min,
+ max: STOCK.max,
+ step: 1,
+ },
+ {
+ id: "publishedOn",
+ label: "Published",
+ control: "dateRange",
+ field: "publishedOn",
+ min: PUBLISHED.min,
+ max: PUBLISHED.max,
+ },
+ {
+ id: "availableOn",
+ label: "Available on or after",
+ control: "date",
+ field: "availableOn",
+ operator: "gte",
+ },
+];
+
+/** Checkboxes only. */
+const CHECKBOX_SECTIONS: FilterRailSection[] = [
+ {
+ id: "category",
+ label: "Category",
+ control: "checkbox",
+ field: "category",
+ options: distinct((row) => row.category),
+ truncate: false,
+ },
+ {
+ id: "priceBand",
+ label: "Price band",
+ control: "checkbox",
+ field: "priceBand",
+ // A curated vocabulary — authored order, never re-sorted.
+ options: ["Under $25", "$25 – $75", "$75 – $150", "$150+"].map((value) => ({
+ value,
+ label: value,
+ })),
+ },
+ {
+ id: "status",
+ label: "Status",
+ control: "checkbox",
+ field: "status",
+ options: distinct((row) => row.status),
+ },
+];
+
+/** Radios, boolean and a custom section. No counts. */
+const RADIO_SECTIONS: FilterRailSection[] = [
+ {
+ id: "status",
+ label: "Status",
+ control: "radio",
+ field: "status",
+ options: distinct((row) => row.status),
+ },
+ {
+ id: "inStock",
+ label: "Availability",
+ control: "boolean",
+ field: "inStock",
+ trueLabel: "In stock",
+ falseLabel: "Out of stock",
+ anyLabel: "All products",
+ },
+ {
+ id: "stockLevel",
+ label: "Stock level",
+ hint: "A custom section writing its own filter.",
+ control: "custom",
+ fields: ["stock"],
+ render: ({ getFilter, setFilter }) => {
+ const current = getFilter("stock")?.value as { min: number; max: number } | undefined;
+ const levels = [
+ { label: "Low (1–20)", min: 1, max: 20 },
+ { label: "Healthy (21–100)", min: 21, max: 100 },
+ { label: "Overstocked (100+)", min: 101, max: STOCK.max },
+ ];
+ return (
+
+ {levels.map((level) => {
+ const active = current?.min === level.min && current?.max === level.max;
+ return (
+
+ setFilter(
+ "stock",
+ active
+ ? null
+ : { operator: "between", value: { min: level.min, max: level.max } },
+ )
+ }
+ className={
+ active
+ ? "rounded-md border border-transparent bg-accent px-2 py-1 text-xs"
+ : "rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
+ }
+ >
+ {level.label}
+
+ );
+ })}
+
+ );
+ },
+ },
+];
+
+// ─── Shared table ───────────────────────────────────────────────────────────────
+
+const ProductsTable = ({
+ control,
+ variables,
+ tableId,
+}: {
+ control: CollectionControl;
+ variables: CollectionVariables;
+ tableId: string;
+}) => {
+ const data = useMemo(() => queryRows(variables), [variables]);
+ const table = useDataTable({ columns: COLUMNS, data, control, tableId });
+ return (
+
+ {/* No DataTable.Filters: a field belongs to one surface, not both. */}
+
+
+
+
+
+
+ );
+};
+
+// ─── Variant: Full ──────────────────────────────────────────────────────────────
+
+const FullVariant = ({ header }: { header: ReactNode }) => {
+ const { variables, control } = useURLCollectionVariables({ params: { pageSize: 25 } });
+ const counts = useFilterRailCounts(ROWS, FULL_SECTIONS, control.filters, { baseline: true });
+ const layout = useFilterRailLayout("showcase:products");
+ const compact = useFilterRailCompact();
+ const [sheetOpen, setSheetOpen] = useState(false);
+
+ // Saved views need the table, which lives in the main column.
+ const data = useMemo(() => queryRows(variables), [variables]);
+ const table = useDataTable({
+ columns: COLUMNS,
+ data,
+ control,
+ tableId: "filter-rail:full",
+ });
+ const views = useSavedViews(table, { storageKey: "showcase:products" });
+
+ const rail = (
+
+
+
+
+
+
+
+
+ );
+
+ return (
+
+ {header}
+ {!compact && {rail} }
+
+
+
+ {compact && (
+ setSheetOpen(true)}
+ />
+ )}
+
+
+
+
+
+
+
+ {compact && (
+
+ {rail}
+
+ )}
+
+ );
+};
+
+// ─── Variant: Checkboxes only ───────────────────────────────────────────────────
+
+const CheckboxVariant = ({ header }: { header: ReactNode }) => {
+ const { variables, control } = useCollectionVariables({ params: { pageSize: 25 } });
+ const counts = useFilterRailCounts(ROWS, CHECKBOX_SECTIONS, control.filters);
+ return (
+
+ {header}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+// ─── Variant: Radios + custom ───────────────────────────────────────────────────
+
+const RadioVariant = ({ header }: { header: ReactNode }) => {
+ const { variables, control } = useCollectionVariables({ params: { pageSize: 25 } });
+ return (
+
+ {header}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+// ─── Page ───────────────────────────────────────────────────────────────────────
+
+const VARIANTS = [
+ { key: "full", label: "Full" },
+ { key: "checkbox", label: "Checkboxes only" },
+ { key: "radio", label: "Radios + custom" },
+] as const;
+
+type VariantKey = (typeof VARIANTS)[number]["key"];
+
+const FilterRailPage = () => {
+ const [variant, setVariant] = useState("full");
+ // Layout reads its Header / Column children directly, so each variant renders
+ // its own Layout and receives the shared header.
+ const header = (
+
+ setVariant(next as VariantKey)}
+ variant="capsule"
+ size="sm"
+ >
+
+ {VARIANTS.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+
+ );
+ if (variant === "checkbox") return ;
+ if (variant === "radio") return ;
+ return ;
+};
+
+FilterRailPage.appShellPageProps = {
+ meta: {
+ title: "Filter rail",
+ icon: ,
+ },
+} satisfies AppShellPageProps;
+
+export default FilterRailPage;
diff --git a/examples/vite-app/src/routes.generated.ts b/examples/vite-app/src/routes.generated.ts
index 82218a12..d05c5d16 100644
--- a/examples/vite-app/src/routes.generated.ts
+++ b/examples/vite-app/src/routes.generated.ts
@@ -35,6 +35,7 @@ export type GeneratedRouteParams = {
"/showcase/date-picker": {};
"/showcase/document-progress": {};
"/showcase/dropdown": {};
+ "/showcase/filter-rail": {};
"/showcase/forms": {};
"/showcase/forms/zod-rhf": {};
"/showcase/grid": {};
diff --git a/packages/core/__snapshots__/src__components__filter-rail__filter-rail.test.tsx.snap b/packages/core/__snapshots__/src__components__filter-rail__filter-rail.test.tsx.snap
new file mode 100644
index 00000000..448775eb
--- /dev/null
+++ b/packages/core/__snapshots__/src__components__filter-rail__filter-rail.test.tsx.snap
@@ -0,0 +1,19 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`FilterRail > SavedViews > renders the Saved views trigger and hides Save while clean 1`] = `""`;
+
+exports[`FilterRail > Trigger > shows the active rail filter count 1`] = `" Filters2 "`;
+
+exports[`FilterRail > snapshots > checkbox section with counts 1`] = `"[] "`;
+
+exports[`FilterRail > snapshots > comfortable density 1`] = `"[] "`;
+
+exports[`FilterRail > snapshots > compact density 1`] = `"[] "`;
+
+exports[`FilterRail > snapshots > numberRange section 1`] = `"[{"field":"price","operator":"between","value":{"min":10,"max":200}}] "`;
+
+exports[`FilterRail > snapshots > radio and boolean sections 1`] = `"[{"field":"status","operator":"eq","value":"active"}] "`;
+
+exports[`FilterRail > snapshots > text section 1`] = `"[] "`;
+
+exports[`FilterRail > snapshots > with Header and ClearAll 1`] = `"[{"field":"category","operator":"in","value":["apparel"]}] "`;
diff --git a/packages/core/src/components/filter-rail/apply-layout.test.ts b/packages/core/src/components/filter-rail/apply-layout.test.ts
new file mode 100644
index 00000000..92fc5689
--- /dev/null
+++ b/packages/core/src/components/filter-rail/apply-layout.test.ts
@@ -0,0 +1,140 @@
+import { describe, expect, it } from "vitest";
+import {
+ applyRailLayout,
+ EMPTY_RAIL_LAYOUT,
+ findRailProblems,
+ orderSections,
+ railFields,
+} from "./apply-layout";
+import type { FilterRailSection } from "./types";
+
+// 🧪 Dummy Data: one section of each shape the layout logic cares about
+const sections: FilterRailSection[] = [
+ {
+ id: "category",
+ label: "Category",
+ control: "checkbox",
+ field: "category",
+ options: [],
+ },
+ { id: "status", label: "Status", control: "radio", field: "status", options: [] },
+ { id: "search", label: "Search", control: "text", field: "name", operator: "contains" },
+ {
+ id: "supplier",
+ label: "Supplier",
+ control: "custom",
+ fields: ["supplierId", "supplierRegion"],
+ render: () => null,
+ },
+];
+
+const ids = (list: readonly { id: string }[]) => list.map((section) => section.id);
+
+describe("applyRailLayout", () => {
+ it("returns a copy of the authored sections without a layout", () => {
+ const result = applyRailLayout(sections, undefined);
+ expect(result).toEqual(sections);
+ expect(result).not.toBe(sections);
+ });
+
+ it("returns authored order for an empty layout", () => {
+ expect(ids(applyRailLayout(sections, EMPTY_RAIL_LAYOUT))).toEqual([
+ "category",
+ "status",
+ "search",
+ "supplier",
+ ]);
+ });
+
+ it("orders ranked sections first, unranked keep authored order after them", () => {
+ const result = applyRailLayout(sections, { order: ["search", "status"], hidden: [], sort: {} });
+ expect(ids(result)).toEqual(["search", "status", "category", "supplier"]);
+ });
+
+ it("ignores unknown ids in order", () => {
+ const result = applyRailLayout(sections, {
+ order: ["gone", "supplier"],
+ hidden: [],
+ sort: {},
+ });
+ expect(ids(result)).toEqual(["supplier", "category", "status", "search"]);
+ });
+
+ it("removes hidden sections", () => {
+ const result = applyRailLayout(sections, {
+ order: ["status"],
+ hidden: ["category", "status"],
+ sort: {},
+ });
+ expect(ids(result)).toEqual(["search", "supplier"]);
+ });
+
+ it("applies a sort override only to checkbox sections", () => {
+ const result = applyRailLayout(sections, {
+ order: [],
+ hidden: [],
+ sort: { category: "label", status: "label", search: "baselineCount" },
+ });
+ const category = result.find((section) => section.id === "category");
+ const status = result.find((section) => section.id === "status");
+ const search = result.find((section) => section.id === "search");
+ expect(category).toMatchObject({ sort: "label" });
+ expect(status).not.toHaveProperty("sort");
+ expect(search).not.toHaveProperty("sort");
+ // The authored section object is not mutated.
+ expect(sections[0]).not.toHaveProperty("sort");
+ });
+});
+
+describe("orderSections", () => {
+ it("is stable for sections sharing the same (missing) rank", () => {
+ const list = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }];
+ expect(ids(orderSections(list, ["c"]))).toEqual(["c", "a", "b", "d"]);
+ });
+});
+
+describe("railFields", () => {
+ it("collects every field, including a custom section's declared fields", () => {
+ expect(railFields(sections)).toEqual(
+ new Set(["category", "status", "name", "supplierId", "supplierRegion"]),
+ );
+ });
+});
+
+describe("findRailProblems", () => {
+ it("reports nothing for a clean rail", () => {
+ expect(findRailProblems(sections)).toEqual([]);
+ });
+
+ it("reports duplicate section ids", () => {
+ const problems = findRailProblems([
+ sections[0]!,
+ { id: "category", label: "Again", control: "radio", field: "other", options: [] },
+ ]);
+ expect(problems).toEqual(['FilterRail: duplicate section id "category".']);
+ });
+
+ it("reports two sections on one field", () => {
+ const problems = findRailProblems([
+ sections[0]!,
+ { id: "category2", label: "Category 2", control: "radio", field: "category", options: [] },
+ ]);
+ expect(problems).toHaveLength(1);
+ expect(problems[0]).toContain('sections "category" and "category2" both filter "category"');
+ });
+
+ it("reports a custom section claiming another section's field", () => {
+ const problems = findRailProblems([
+ sections[1]!,
+ {
+ id: "custom",
+ label: "Custom",
+ control: "custom",
+ fields: ["status"],
+ render: () => null,
+ },
+ ]);
+ expect(problems).toHaveLength(1);
+ expect(problems[0]).toContain('both filter "status"');
+ });
+});
diff --git a/packages/core/src/components/filter-rail/apply-layout.ts b/packages/core/src/components/filter-rail/apply-layout.ts
new file mode 100644
index 00000000..afafa3a5
--- /dev/null
+++ b/packages/core/src/components/filter-rail/apply-layout.ts
@@ -0,0 +1,83 @@
+import type { CheckboxSection, FilterRailSection, RadioSection, RailLayout } from "./types";
+
+export const EMPTY_RAIL_LAYOUT: RailLayout = { order: [], hidden: [], sort: {} };
+
+/** The fields a section writes. A custom section may own several. */
+export const sectionFields = (
+ section: FilterRailSection,
+): readonly TField[] => (section.control === "custom" ? section.fields : [section.field]);
+
+/**
+ * Every field the rail owns, so Clear all never touches filters it does not
+ * own (a page search box, a scope set elsewhere).
+ *
+ * Always computed from the **authored** sections: a hidden section still owns
+ * its field.
+ */
+export const railFields = (
+ sections: readonly FilterRailSection[],
+): Set => new Set(sections.flatMap((section) => sectionFields(section)));
+
+/** Sections whose options can be counted — checkbox and radio. */
+export const hasOptions = (
+ section: FilterRailSection,
+): section is CheckboxSection | RadioSection =>
+ section.control === "checkbox" || section.control === "radio";
+
+/** Sections in the user's order. Unranked ids keep authored order at the end. */
+export const orderSections = (
+ sections: readonly TSection[],
+ order: readonly string[],
+): TSection[] => {
+ const rank = new Map(order.map((id, index) => [id, index]));
+ return sections
+ .map((section, index) => ({ section, index }))
+ .toSorted((a, b) => {
+ const ra = rank.get(a.section.id) ?? Number.POSITIVE_INFINITY;
+ const rb = rank.get(b.section.id) ?? Number.POSITIVE_INFINITY;
+ // `Infinity - Infinity` is NaN, so compare for equality first.
+ return ra === rb ? a.index - b.index : ra - rb;
+ })
+ .map(({ section }) => section);
+};
+
+/** The authored sections, reordered / filtered / re-sorted by the user's layout. */
+export const applyRailLayout = (
+ sections: readonly FilterRailSection[],
+ layout: RailLayout | undefined,
+): FilterRailSection[] => {
+ if (!layout) return [...sections];
+ const hidden = new Set(layout.hidden);
+ return orderSections(
+ sections.filter((section) => !hidden.has(section.id)),
+ layout.order,
+ ).map((section) => {
+ const chosen = layout.sort[section.id];
+ return chosen && section.control === "checkbox" ? { ...section, sort: chosen } : section;
+ });
+};
+
+/**
+ * Two sections on one field overwrite each other, because a field holds one
+ * filter. Returns the problems so the caller can warn once.
+ */
+export const findRailProblems = (sections: readonly FilterRailSection[]): string[] => {
+ const problems: string[] = [];
+ const seenId = new Set();
+ const fieldOwner = new Map();
+ for (const section of sections) {
+ if (seenId.has(section.id)) problems.push(`FilterRail: duplicate section id "${section.id}".`);
+ seenId.add(section.id);
+ for (const field of sectionFields(section)) {
+ const owner = fieldOwner.get(field);
+ if (owner && owner !== section.id) {
+ problems.push(
+ `FilterRail: sections "${owner}" and "${section.id}" both filter "${field}". ` +
+ "A field holds one filter, so they will overwrite each other.",
+ );
+ }
+ fieldOwner.set(field, section.id);
+ }
+ }
+ return problems;
+};
diff --git a/packages/core/src/components/filter-rail/controls.tsx b/packages/core/src/components/filter-rail/controls.tsx
new file mode 100644
index 00000000..1550dc8b
--- /dev/null
+++ b/packages/core/src/components/filter-rail/controls.tsx
@@ -0,0 +1,199 @@
+// Date, date-range and number-range controls.
+//
+// A field holds exactly one filter, so `gte` AND `lte` on one field is not
+// expressible — a two-ended range commits a single `between` with
+// `{ min, max }`, which also survives the URL round-trip unchanged.
+
+import { useEffect, useState, type KeyboardEvent } from "react";
+import { parseDate, type DateValue } from "@internationalized/date";
+import { cn } from "@/lib/utils";
+import type { Filter } from "@/types/collection";
+import { DatePicker, DateRangePicker } from "../date-field";
+import { useFilterRailContext, sectionName } from "./filter-rail-context";
+import { useFilterRailT } from "./i18n";
+import { inputClass } from "./internals";
+import { filterFor } from "./sections";
+import type { DateRangeSection, DateSection, NumberRangeSection } from "./types";
+
+/** `YYYY-MM-DD` ⇄ `DateValue`. A malformed stored value renders empty rather than throwing. */
+const toDateValue = (iso: unknown): DateValue | null => {
+ if (typeof iso !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null;
+ try {
+ return parseDate(iso);
+ } catch {
+ return null;
+ }
+};
+
+const isoOf = (value: DateValue | null | undefined) => (value ? value.toString() : null);
+
+// ─── Date ────────────────────────────────────────────────────────────────────
+
+export const DateControl = ({ section }: { section: DateSection }) => {
+ const { control, commit } = useFilterRailContext("Sections");
+ const value = toDateValue(filterFor(control.filters, section.field)?.value);
+
+ return (
+ {
+ const iso = isoOf(next);
+ commit(
+ section.field,
+ iso ? { field: section.field, operator: section.operator, value: iso } : null,
+ );
+ }}
+ />
+ );
+};
+
+// ─── Date range ──────────────────────────────────────────────────────────────
+
+export const DateRangeControl = ({ section }: { section: DateRangeSection }) => {
+ const { control, commit } = useFilterRailContext("Sections");
+ const bounds = filterFor(control.filters, section.field)?.value as
+ | { min?: string; max?: string }
+ | undefined;
+ const start = toDateValue(bounds?.min);
+ const end = toDateValue(bounds?.max);
+
+ const apply = (from: string | null, to: string | null) =>
+ commit(
+ section.field,
+ from && to
+ ? { field: section.field, operator: "between", value: { min: from, max: to } }
+ : null,
+ );
+
+ return (
+
+ {section.presets && section.presets.length > 0 && (
+
+ {section.presets.map((preset) => {
+ const active = bounds?.min === preset.from && bounds?.max === preset.to;
+ return (
+ (active ? apply(null, null) : apply(preset.from, preset.to))}
+ className={cn(
+ "astw:cursor-pointer astw:rounded-md astw:border astw:px-1.5 astw:py-0.5 astw:text-xs",
+ active
+ ? "astw:border-transparent astw:bg-accent astw:text-foreground"
+ : "astw:border-border astw:text-muted-foreground astw:hover:text-foreground",
+ )}
+ >
+ {preset.label}
+
+ );
+ })}
+
+ )}
+
apply(isoOf(next?.start), isoOf(next?.end))}
+ />
+
+ );
+};
+
+// ─── Number range ────────────────────────────────────────────────────────────
+
+type Bounds = { min?: number; max?: number };
+
+const num = (value: unknown) => {
+ if (value === undefined || value === null || value === "") return undefined;
+ const parsed = Number(value);
+ return Number.isNaN(parsed) ? undefined : parsed;
+};
+
+const show = (value: number | undefined) => (value === undefined ? "" : String(value));
+
+/** `between` carries both ends; a half-open range is a single `gte` or `lte`. */
+const readBounds = (filter: Filter | undefined): Bounds => {
+ if (!filter) return {};
+ if (filter.operator === "gte") return { min: num(filter.value) };
+ if (filter.operator === "lte") return { max: num(filter.value) };
+ const value = (filter.value ?? {}) as { min?: unknown; max?: unknown };
+ return { min: num(value.min), max: num(value.max) };
+};
+
+const rangeFilter = (field: string, min?: number, max?: number): Filter | null => {
+ if (min !== undefined && max !== undefined) {
+ return { field, operator: "between", value: { min, max } };
+ }
+ if (min !== undefined) return { field, operator: "gte", value: min };
+ if (max !== undefined) return { field, operator: "lte", value: max };
+ return null;
+};
+
+export const NumberRangeControl = ({ section }: { section: NumberRangeSection }) => {
+ const t = useFilterRailT();
+ const { control, commit } = useFilterRailContext("Sections");
+ const bounds = readBounds(filterFor(control.filters, section.field));
+
+ // Local drafts: commit on blur / Enter, not per keystroke.
+ const [from, setFrom] = useState(show(bounds.min));
+ const [to, setTo] = useState(show(bounds.max));
+
+ useEffect(() => {
+ setFrom(show(bounds.min));
+ setTo(show(bounds.max));
+ }, [bounds.min, bounds.max]);
+
+ const apply = () => {
+ const lo = from.trim() === "" ? undefined : Number(from);
+ const hi = to.trim() === "" ? undefined : Number(to);
+ if (lo === bounds.min && hi === bounds.max) return;
+ commit(section.field, rangeFilter(section.field, lo, hi));
+ };
+
+ const name = sectionName(section);
+ const shared = {
+ type: "number",
+ inputMode: "decimal" as const,
+ min: section.min,
+ max: section.max,
+ step: section.step,
+ onBlur: apply,
+ onKeyDown: (event: KeyboardEvent) => {
+ if (event.key === "Enter") apply();
+ },
+ className: inputClass,
+ };
+
+ return (
+
+ setFrom(event.target.value)}
+ />
+
+ {t("rangeTo")}
+
+ setTo(event.target.value)}
+ />
+ {section.unit && (
+
+ {section.unit}
+
+ )}
+
+ );
+};
diff --git a/packages/core/src/components/filter-rail/filter-rail-context.ts b/packages/core/src/components/filter-rail/filter-rail-context.ts
new file mode 100644
index 00000000..f7935350
--- /dev/null
+++ b/packages/core/src/components/filter-rail/filter-rail-context.ts
@@ -0,0 +1,44 @@
+import { createContext, useContext } from "react";
+import type { CollectionControl, Filter } from "@/types/collection";
+import type { FacetCounts, FilterRailSection, RailLayoutBinding } from "./types";
+
+/**
+ * Everything the parts share. String-keyed on purpose: `TField` is narrowed at
+ * the `Root` boundary, and nothing inside varies on it.
+ */
+export type FilterRailContextValue = {
+ control: CollectionControl;
+ /** The authored sections, including ones the user has hidden. */
+ sections: readonly FilterRailSection[];
+ counts: FacetCounts | undefined;
+ layout: RailLayoutBinding | undefined;
+ zeroBehavior: "disable" | "show";
+ density: "compact" | "comfortable";
+ /** Fields the rail owns. */
+ owned: ReadonlySet;
+ /** Active filters on owned fields. */
+ activeCount: number;
+ /** Section ids that currently carry a filter. */
+ filteredSections: ReadonlySet;
+ /** Replace (or remove) the filter on one field — the rail's single mutation path. */
+ commit: (field: string, next: Filter | null) => void;
+ /** Remove the filters on these fields in one `setFilters`. */
+ clearFields: (fields: readonly string[]) => void;
+ clearAll: () => void;
+};
+
+export const FilterRailContext = createContext(null);
+
+export function useFilterRailContext(part: string): FilterRailContextValue {
+ const context = useContext(FilterRailContext);
+ if (!context) {
+ throw new Error(` must be rendered inside .`);
+ }
+ return context;
+}
+
+/** A plain-text name for a section, for aria-labels and messages. */
+export const sectionName = (section: FilterRailSection): string =>
+ typeof section.label === "string" || typeof section.label === "number"
+ ? String(section.label)
+ : section.id;
diff --git a/packages/core/src/components/filter-rail/filter-rail.test.tsx b/packages/core/src/components/filter-rail/filter-rail.test.tsx
new file mode 100644
index 00000000..6525682b
--- /dev/null
+++ b/packages/core/src/components/filter-rail/filter-rail.test.tsx
@@ -0,0 +1,953 @@
+import { useMemo, useState, type ReactNode } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { CollectionControl, Filter } from "@/types/collection";
+import { ClearAll, Header, Root, Sections } from "./filter-rail";
+import { Trigger } from "./responsive";
+import { SavedViews } from "./saved-views";
+import { Settings } from "./settings";
+import type {
+ FacetCounts,
+ FilterRailRootProps,
+ FilterRailSection,
+ RailLayout,
+ SavedView,
+} from "./types";
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+// ─── Harness ─────────────────────────────────────────────────────────────────
+
+const makeControl = (
+ filters: Filter[],
+ setFilters: (next: Filter[]) => void,
+): CollectionControl => ({
+ filters,
+ setFilters,
+ addFilter: vi.fn(),
+ removeFilter: vi.fn(),
+ clearFilters: vi.fn(),
+ sortStates: [],
+ setSort: vi.fn(),
+ clearSort: vi.fn(),
+ pageSize: 20,
+ setPageSize: vi.fn(),
+ goToNextPage: vi.fn(),
+ goToPrevPage: vi.fn(),
+ resetPage: vi.fn(),
+ goToFirstPage: vi.fn(),
+ goToLastPage: vi.fn(),
+ getHasPrevPage: vi.fn(() => false),
+ getHasNextPage: vi.fn(() => false),
+ resetCount: 0,
+});
+
+type HarnessProps = Omit & {
+ initial?: Filter[];
+ initialLayout?: RailLayout;
+ onLayoutChange?: (next: RailLayout) => void;
+ children?: ReactNode;
+};
+
+function Harness({
+ initial = [],
+ initialLayout,
+ onLayoutChange,
+ children,
+ ...rootProps
+}: HarnessProps) {
+ const [filters, setFilters] = useState(initial);
+ const [layoutValue, setLayoutValue] = useState(initialLayout);
+ const control = useMemo(() => makeControl(filters, setFilters), [filters]);
+ const layout = layoutValue
+ ? {
+ value: layoutValue,
+ onChange: (next: RailLayout) => {
+ onLayoutChange?.(next);
+ setLayoutValue(next);
+ },
+ }
+ : undefined;
+ return (
+ <>
+
+ {children ?? }
+
+ {JSON.stringify(filters)}
+ >
+ );
+}
+
+/**
+ * The option's checkbox. Queried by row rather than accessible name: Base UI
+ * adds `aria-labelledby` pointing at the wrapping , which folds the
+ * box's own `aria-label` in a second time (see the report on OptionRow).
+ */
+const option = (label: string): HTMLElement => {
+ const row = Array.from(
+ document.querySelectorAll('[data-slot="filter-rail-option"]'),
+ ).find((candidate) => candidate.querySelector(".astw\\:truncate")?.textContent === label);
+ if (!row) throw new Error(`No option row "${label}"`);
+ return row.querySelector('[role="checkbox"]')!;
+};
+const hasOption = (label: string) => {
+ try {
+ option(label);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+const currentFilters = (): Filter[] =>
+ JSON.parse(screen.getByTestId("filters").textContent ?? "[]") as Filter[];
+
+// ─── Fixtures ────────────────────────────────────────────────────────────────
+
+// 🧪 Dummy Data: sample sections for the rail
+const categorySection: FilterRailSection = {
+ id: "category",
+ label: "Category",
+ control: "checkbox",
+ field: "category",
+ options: [
+ { value: "apparel", label: "Apparel" },
+ { value: "shoes", label: "Shoes" },
+ { value: "bags", label: "Bags" },
+ ],
+};
+
+const statusSection: FilterRailSection = {
+ id: "status",
+ label: "Status",
+ control: "radio",
+ field: "status",
+ options: [
+ { value: "active", label: "Active" },
+ { value: "archived", label: "Archived" },
+ ],
+};
+
+const inStockSection: FilterRailSection = {
+ id: "inStock",
+ label: "In stock",
+ control: "boolean",
+ field: "inStock",
+};
+
+const searchSection: FilterRailSection = {
+ id: "search",
+ label: "Search",
+ control: "text",
+ field: "name",
+ operator: "contains",
+ placeholder: "Name…",
+ debounceMs: 300,
+ minLength: 3,
+};
+
+const priceSection: FilterRailSection = {
+ id: "price",
+ label: "Price",
+ control: "numberRange",
+ field: "price",
+ min: 0,
+ max: 1000,
+ unit: "$",
+};
+
+const categoryCounts: FacetCounts = {
+ live: { category: { apparel: 1380, shoes: 0, bags: 12 } },
+};
+
+// 🧪 Dummy Data: saved views
+const view = (id: string, name: string): SavedView => ({
+ id,
+ name,
+ createdAt: "2026-09-30T00:00:00.000Z",
+ state: { filters: [], sort: [], pageSize: 20, columnOrder: [], hidden: [], pinned: {} },
+});
+
+// ─── Snapshots ───────────────────────────────────────────────────────────────
+
+describe("FilterRail", () => {
+ describe("snapshots", () => {
+ it("checkbox section with counts", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it("radio and boolean sections", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it("text section", () => {
+ const { container } = render( );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it("numberRange section", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it("compact density", () => {
+ const { container } = render( );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it("comfortable density", () => {
+ const { container } = render( );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it("with Header and ClearAll", () => {
+ const { container } = render(
+
+
+
+ ,
+ );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+ });
+
+ // ─── Checkbox ──────────────────────────────────────────────────────────────
+
+ describe("checkbox section", () => {
+ it("commits an `in` filter and removes it when the last option is unticked", async () => {
+ const user = userEvent.setup();
+ const onFilterChange = vi.fn();
+ render( );
+
+ await user.click(option("Apparel"));
+ expect(currentFilters()).toEqual([{ field: "category", operator: "in", value: ["apparel"] }]);
+ expect(onFilterChange).toHaveBeenLastCalledWith(
+ [{ field: "category", operator: "in", value: ["apparel"] }],
+ ["category"],
+ );
+
+ await user.click(option("Bags"));
+ expect(currentFilters()).toEqual([
+ { field: "category", operator: "in", value: ["apparel", "bags"] },
+ ]);
+
+ await user.click(option("Apparel"));
+ await user.click(option("Bags"));
+ expect(currentFilters()).toEqual([]);
+ });
+
+ it("respects the `nin` operator", async () => {
+ const user = userEvent.setup();
+ render( );
+ await user.click(option("Shoes"));
+ expect(currentFilters()).toEqual([{ field: "category", operator: "nin", value: ["shoes"] }]);
+ });
+
+ it("truncates to `Show N more` and keeps a selected option beyond the limit visible", async () => {
+ const user = userEvent.setup();
+ const section: FilterRailSection = {
+ id: "size",
+ label: "Size",
+ control: "checkbox",
+ field: "size",
+ truncate: 2,
+ options: ["XS", "S", "M", "L", "XL"].map((value) => ({ value, label: value })),
+ };
+ render(
+ ,
+ );
+
+ expect(option("XS")).toBeTruthy();
+ expect(option("S")).toBeTruthy();
+ expect(hasOption("M")).toBe(false);
+ expect(option("XL")).toBeTruthy();
+ const more = screen.getByRole("button", { name: "Show 2 more" });
+
+ await user.click(more);
+ expect(option("M")).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Show less" })).toBeTruthy();
+ });
+
+ it("shows `Show N more` with the hidden count", () => {
+ const section: FilterRailSection = {
+ id: "size",
+ label: "Size",
+ control: "checkbox",
+ field: "size",
+ truncate: 2,
+ options: ["XS", "S", "M", "L", "XL"].map((value) => ({ value, label: value })),
+ };
+ render( );
+ expect(screen.getByRole("button", { name: "Show 3 more" })).toBeTruthy();
+ });
+
+ it("marks a zero-count option aria-disabled and ignores clicks on it", async () => {
+ const user = userEvent.setup();
+ render( );
+ const shoes = option("Shoes");
+ // The label is the name, announced once; the count is the description.
+ expect(screen.getByRole("checkbox", { name: "Shoes", description: "0 items" })).toBe(shoes);
+ expect(screen.getByRole("checkbox", { name: "Apparel", description: "1,380 items" })).toBe(
+ option("Apparel"),
+ );
+ expect(shoes.getAttribute("aria-disabled")).toBe("true");
+ await user.click(shoes);
+ expect(currentFilters()).toEqual([]);
+ expect(option("Apparel").getAttribute("aria-disabled")).toBeNull();
+ });
+
+ it("keeps a selected zero-count option enabled so it can be unselected", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ const shoes = option("Shoes");
+ expect(shoes.getAttribute("aria-disabled")).toBeNull();
+ await user.click(shoes);
+ expect(currentFilters()).toEqual([]);
+ });
+
+ it('keeps zero-count options enabled with zeroBehavior "show"', async () => {
+ const user = userEvent.setup();
+ render( );
+ const shoes = option("Shoes");
+ expect(shoes.getAttribute("aria-disabled")).toBeNull();
+ await user.click(shoes);
+ expect(currentFilters()).toEqual([{ field: "category", operator: "in", value: ["shoes"] }]);
+ });
+ });
+
+ // ─── Radio / boolean ───────────────────────────────────────────────────────
+
+ describe("radio section", () => {
+ it("commits eq and removes the filter on Any", async () => {
+ const user = userEvent.setup();
+ render( );
+ expect((screen.getByRole("radio", { name: "Any" }) as HTMLInputElement).checked).toBe(true);
+
+ await user.click(screen.getByRole("radio", { name: "Archived" }));
+ expect(currentFilters()).toEqual([{ field: "status", operator: "eq", value: "archived" }]);
+
+ await user.click(screen.getByRole("radio", { name: "Any" }));
+ expect(currentFilters()).toEqual([]);
+ });
+ });
+
+ describe("boolean section", () => {
+ it("commits true and false", async () => {
+ const user = userEvent.setup();
+ render( );
+ await user.click(screen.getByRole("radio", { name: "Yes" }));
+ expect(currentFilters()).toEqual([{ field: "inStock", operator: "eq", value: true }]);
+ await user.click(screen.getByRole("radio", { name: "No" }));
+ expect(currentFilters()).toEqual([{ field: "inStock", operator: "eq", value: false }]);
+ await user.click(screen.getByRole("radio", { name: "Any" }));
+ expect(currentFilters()).toEqual([]);
+ });
+
+ it('reads the string "true" as checked', () => {
+ render(
+ ,
+ );
+ expect((screen.getByRole("radio", { name: "Yes" }) as HTMLInputElement).checked).toBe(true);
+ expect((screen.getByRole("radio", { name: "Any" }) as HTMLInputElement).checked).toBe(false);
+ });
+ });
+
+ // ─── Number range ──────────────────────────────────────────────────────────
+
+ describe("numberRange section", () => {
+ const minInput = () => screen.getByRole("spinbutton", { name: "Minimum Price" });
+ const maxInput = () => screen.getByRole("spinbutton", { name: "Maximum Price" });
+
+ it("commits between {min, max} on blur, not per keystroke", () => {
+ render( );
+ fireEvent.change(minInput(), { target: { value: "10" } });
+ fireEvent.change(maxInput(), { target: { value: "200" } });
+ expect(currentFilters()).toEqual([]);
+ fireEvent.blur(maxInput());
+ expect(currentFilters()).toEqual([
+ { field: "price", operator: "between", value: { min: 10, max: 200 } },
+ ]);
+ });
+
+ it("commits only a maximum as lte on Enter", () => {
+ render( );
+ fireEvent.change(maxInput(), { target: { value: "500" } });
+ fireEvent.keyDown(maxInput(), { key: "Enter" });
+ expect(currentFilters()).toEqual([{ field: "price", operator: "lte", value: 500 }]);
+ expect((minInput() as HTMLInputElement).value).toBe("");
+ });
+
+ it("commits only a minimum as gte and leaves the other end empty", () => {
+ render( );
+ fireEvent.change(minInput(), { target: { value: "10" } });
+ fireEvent.blur(minInput());
+ expect(currentFilters()).toEqual([{ field: "price", operator: "gte", value: 10 }]);
+ expect((maxInput() as HTMLInputElement).value).toBe("");
+ });
+
+ it("clears when the ends are emptied one blur at a time", () => {
+ render(
+ ,
+ );
+ fireEvent.change(minInput(), { target: { value: "" } });
+ fireEvent.blur(minInput());
+ expect(currentFilters()).toEqual([{ field: "price", operator: "lte", value: 200 }]);
+ fireEvent.change(maxInput(), { target: { value: "" } });
+ fireEvent.blur(maxInput());
+ expect(currentFilters()).toEqual([]);
+ });
+
+ it("removes the filter when both ends are emptied before a blur", () => {
+ render(
+ ,
+ );
+ expect((minInput() as HTMLInputElement).value).toBe("10");
+ fireEvent.change(minInput(), { target: { value: "" } });
+ fireEvent.change(maxInput(), { target: { value: "" } });
+ fireEvent.blur(maxInput());
+ expect(currentFilters()).toEqual([]);
+ });
+ });
+
+ // ─── Text ──────────────────────────────────────────────────────────────────
+
+ describe("text section", () => {
+ it("debounces the commit by debounceMs", () => {
+ vi.useFakeTimers();
+ render( );
+ const input = screen.getByRole("searchbox", { name: "Search" });
+
+ fireEvent.change(input, { target: { value: "shirt" } });
+ act(() => {
+ vi.advanceTimersByTime(299);
+ });
+ expect(currentFilters()).toEqual([]);
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(currentFilters()).toEqual([{ field: "name", operator: "contains", value: "shirt" }]);
+ });
+
+ it("removes the filter below minLength", () => {
+ vi.useFakeTimers();
+ render(
+ ,
+ );
+ const input = screen.getByRole("searchbox", { name: "Search" }) as HTMLInputElement;
+ expect(input.value).toBe("shirt");
+
+ fireEvent.change(input, { target: { value: "sh" } });
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+ expect(currentFilters()).toEqual([]);
+ });
+ });
+
+ // ─── Clear / ClearAll ──────────────────────────────────────────────────────
+
+ describe("clearing", () => {
+ const foreign: Filter = { field: "warehouse", operator: "eq", value: "tokyo" };
+
+ it("per-section Clear removes only that section's filter", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ const category = document.querySelector('[data-section="category"]')!;
+ await user.click(within(category).getByRole("button", { name: "Clear" }));
+ expect(currentFilters()).toEqual([
+ foreign,
+ { field: "status", operator: "eq", value: "active" },
+ ]);
+ });
+
+ it("hides the per-section Clear when clearable is false", () => {
+ render(
+ ,
+ );
+ expect(screen.queryByRole("button", { name: "Clear" })).toBeNull();
+ });
+
+ it("ClearAll removes rail-owned filters only", async () => {
+ const user = userEvent.setup();
+ render(
+
+
+
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Clear all" }));
+ expect(currentFilters()).toEqual([foreign]);
+ });
+
+ it("ClearAll renders nothing without an active rail filter", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByRole("button", { name: "Clear all" })).toBeNull();
+ });
+ });
+
+ // ─── Custom ────────────────────────────────────────────────────────────────
+
+ describe("custom section", () => {
+ it("composes two setFilter calls made in one handler", async () => {
+ const user = userEvent.setup();
+ const custom: FilterRailSection = {
+ id: "supplier",
+ label: "Supplier",
+ control: "custom",
+ fields: ["supplierId", "supplierRegion"],
+ render: ({ setFilter }) => (
+ {
+ setFilter("supplierId", { operator: "eq", value: "s-1" });
+ setFilter("supplierRegion", { operator: "eq", value: "apac" });
+ }}
+ >
+ Pick both
+
+ ),
+ };
+ render( );
+ await user.click(screen.getByRole("button", { name: "Pick both" }));
+ expect(currentFilters()).toEqual([
+ { field: "supplierId", operator: "eq", value: "s-1" },
+ { field: "supplierRegion", operator: "eq", value: "apac" },
+ ]);
+ });
+
+ it("passes setFilter / getFilter, and ClearAll clears its declared fields", async () => {
+ const user = userEvent.setup();
+ const renderSpy = vi.fn();
+ const custom: FilterRailSection = {
+ id: "supplier",
+ label: "Supplier",
+ control: "custom",
+ fields: ["supplierId", "supplierRegion"],
+ render: (context) => {
+ renderSpy(context);
+ const current = context.getFilter("supplierId");
+ return (
+ <>
+ {String(current?.value ?? "none")}
+ context.setFilter("supplierId", { operator: "eq", value: "s-1" })}
+ >
+ Pick supplier
+
+ >
+ );
+ },
+ };
+ render(
+
+
+
+ ,
+ );
+
+ const context = renderSpy.mock.calls[0]![0];
+ expect(typeof context.setFilter).toBe("function");
+ expect(typeof context.getFilter).toBe("function");
+ expect(context.control).toBeTruthy();
+ expect(screen.getByTestId("custom-value").textContent).toBe("none");
+
+ await user.click(screen.getByRole("button", { name: "Pick supplier" }));
+ expect(currentFilters()).toEqual([
+ { field: "supplierRegion", operator: "eq", value: "apac" },
+ { field: "supplierId", operator: "eq", value: "s-1" },
+ ]);
+ expect(screen.getByTestId("custom-value").textContent).toBe("s-1");
+
+ await user.click(screen.getByRole("button", { name: "Clear all" }));
+ expect(currentFilters()).toEqual([]);
+ });
+ });
+
+ // ─── Layout ────────────────────────────────────────────────────────────────
+
+ describe("layout", () => {
+ it("hides a section but ClearAll still clears its field", async () => {
+ const user = userEvent.setup();
+ render(
+
+
+
+ ,
+ );
+ expect(document.querySelector('[data-section="category"]')).toBeNull();
+ expect(document.querySelector('[data-section="status"]')).toBeTruthy();
+ await user.click(screen.getByRole("button", { name: "Clear all" }));
+ expect(currentFilters()).toEqual([]);
+ });
+
+ it("orders sections by layout.order", () => {
+ render(
+ ,
+ );
+ const ids = Array.from(document.querySelectorAll("[data-section]")).map((node) =>
+ node.getAttribute("data-section"),
+ );
+ expect(ids).toEqual(["inStock", "category", "status"]);
+ });
+ });
+
+ // ─── Recovery ──────────────────────────────────────────────────────────────
+
+ describe("empty-result recovery", () => {
+ it("suggests clearing a section when total is 0", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ const status = screen.getByRole("status");
+ expect(status.textContent).toContain("Nothing matches these filters.");
+ expect(within(status).queryByRole("button", { name: /Status/ })).toBeNull();
+ await user.click(within(status).getByRole("button", { name: "Clear Category to see 42" }));
+ expect(currentFilters()).toEqual([{ field: "status", operator: "eq", value: "archived" }]);
+ });
+
+ it("renders nothing when rows remain", () => {
+ render(
+ ,
+ );
+ expect(screen.queryByRole("status")).toBeNull();
+ });
+ });
+
+ // ─── Guards ────────────────────────────────────────────────────────────────
+
+ describe("guards", () => {
+ it("throws a helpful error when a part renders outside Root", () => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ expect(() => render( )).toThrow(
+ " must be rendered inside .",
+ );
+ expect(() => render( )).toThrow(
+ " must be rendered inside .",
+ );
+ expect(() => render()).toThrow("");
+ expect(() => render( )).toThrow("");
+ });
+
+ it("warns once about two sections on one field", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const duplicate: FilterRailSection = { ...statusSection, id: "status2", field: "category" };
+ const { rerender } = render( );
+ rerender( );
+ expect(warn).toHaveBeenCalledTimes(1);
+ expect(warn.mock.calls[0]![0]).toContain('both filter "category"');
+ });
+ });
+
+ // ─── Settings ──────────────────────────────────────────────────────────────
+
+ describe("Settings", () => {
+ const emptyLayout: RailLayout = { order: [], hidden: [], sort: {} };
+
+ it("renders nothing without a layout binding", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByRole("button", { name: "Filter settings" })).toBeNull();
+ });
+
+ it("unchecking a section hides it", async () => {
+ const user = userEvent.setup();
+ const onLayoutChange = vi.fn();
+ render(
+
+
+
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Filter settings" }));
+ await user.click(await screen.findByRole("checkbox", { name: "Hide Status" }));
+ expect(onLayoutChange).toHaveBeenLastCalledWith({ ...emptyLayout, hidden: ["status"] });
+ expect(document.querySelector('[data-section="status"]')).toBeNull();
+ expect(await screen.findByRole("checkbox", { name: "Show Status" })).toBeTruthy();
+ });
+
+ it("hiding a filtered section also clears its filter", async () => {
+ const user = userEvent.setup();
+ const onLayoutChange = vi.fn();
+ render(
+
+
+
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Filter settings" }));
+ await user.click(
+ await screen.findByRole("checkbox", {
+ name: "Hide Category, which also clears its filter",
+ }),
+ );
+ expect(onLayoutChange).toHaveBeenLastCalledWith({ ...emptyLayout, hidden: ["category"] });
+ expect(currentFilters()).toEqual([{ field: "status", operator: "eq", value: "active" }]);
+ });
+
+ it("ArrowDown on the reorder handle moves the section down", async () => {
+ const user = userEvent.setup();
+ const onLayoutChange = vi.fn();
+ render(
+
+
+
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Filter settings" }));
+ const handle = await screen.findByRole("button", {
+ name: "Reorder Category. Use arrow up and down.",
+ });
+ fireEvent.keyDown(handle, { key: "ArrowDown" });
+ expect(onLayoutChange).toHaveBeenLastCalledWith({
+ ...emptyLayout,
+ order: ["status", "category", "inStock"],
+ });
+ const ids = Array.from(document.querySelectorAll("[data-section]")).map((node) =>
+ node.getAttribute("data-section"),
+ );
+ expect(ids).toEqual(["status", "category", "inStock"]);
+ });
+
+ it("ArrowUp on the first row does nothing", async () => {
+ const user = userEvent.setup();
+ const onLayoutChange = vi.fn();
+ render(
+
+
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Filter settings" }));
+ const handle = await screen.findByRole("button", {
+ name: "Reorder Category. Use arrow up and down.",
+ });
+ fireEvent.keyDown(handle, { key: "ArrowUp" });
+ expect(onLayoutChange).not.toHaveBeenCalled();
+ });
+ });
+
+ // ─── SavedViews ────────────────────────────────────────────────────────────
+
+ describe("SavedViews", () => {
+ const binding = (overrides: Partial[0]> = {}) => ({
+ items: [view("v1", "Low stock"), view("v2", "Wide view")],
+ activeId: null,
+ dirty: false,
+ onSave: vi.fn(),
+ onApply: vi.fn(),
+ onDelete: vi.fn(),
+ ...overrides,
+ });
+
+ it("renders the Saved views trigger and hides Save while clean", () => {
+ const { container } = render( );
+ expect(container.innerHTML).toMatchSnapshot();
+ expect(screen.getByRole("button", { name: /Saved views/ })).toBeTruthy();
+ expect(screen.queryByRole("button", { name: "Save view" })).toBeNull();
+ });
+
+ it("shows the active view's name on the trigger", () => {
+ render( );
+ expect(screen.getByRole("button", { name: /Wide view/ })).toBeTruthy();
+ });
+
+ it("dirty shows Save view, and the dialog saves via onSave(name)", async () => {
+ const user = userEvent.setup();
+ const props = binding({ dirty: true });
+ render( );
+ await user.click(screen.getByRole("button", { name: "Save view" }));
+ const input = await screen.findByRole("textbox", { name: "Save this view" });
+ const dialog = screen.getByRole("dialog");
+ const save = within(dialog).getByRole("button", { name: "Save" }) as HTMLButtonElement;
+ expect(save.disabled).toBe(true);
+ await user.type(input, " My view ");
+ await user.click(save);
+ expect(props.onSave).toHaveBeenCalledWith("My view");
+ });
+
+ it("Enter in the name input saves", async () => {
+ const user = userEvent.setup();
+ const props = binding({ dirty: true });
+ render( );
+ await user.click(screen.getByRole("button", { name: "Save view" }));
+ const input = await screen.findByRole("textbox", { name: "Save this view" });
+ await user.type(input, "Quick{Enter}");
+ expect(props.onSave).toHaveBeenCalledWith("Quick");
+ });
+
+ it("clicking an item calls onApply", async () => {
+ const user = userEvent.setup();
+ const props = binding();
+ render( );
+ await user.click(screen.getByRole("button", { name: /Saved views/ }));
+ const item = await screen.findByRole("menuitem", { name: /Wide view/ });
+ await user.click(item);
+ expect(props.onApply).toHaveBeenCalledWith(props.items[1]);
+ expect(props.onDelete).not.toHaveBeenCalled();
+ });
+
+ it("delete calls onDelete and not onApply", async () => {
+ const user = userEvent.setup();
+ const props = binding();
+ render( );
+ await user.click(screen.getByRole("button", { name: /Saved views/ }));
+ const remove = await screen.findByRole("button", { name: "Delete Low stock" });
+ await user.click(remove);
+ expect(props.onDelete).toHaveBeenCalledWith(props.items[0]);
+ expect(props.onApply).not.toHaveBeenCalled();
+ });
+
+ it("shows an empty state", async () => {
+ const user = userEvent.setup();
+ render( );
+ await user.click(screen.getByRole("button", { name: /Saved views/ }));
+ expect(await screen.findByText("No saved views yet")).toBeTruthy();
+ });
+ });
+
+ // ─── Trigger ───────────────────────────────────────────────────────────────
+
+ describe("Trigger", () => {
+ it("shows the active rail filter count", async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ const control = makeControl(
+ [
+ { field: "category", operator: "in", value: ["apparel"] },
+ { field: "status", operator: "eq", value: "active" },
+ { field: "warehouse", operator: "eq", value: "tokyo" },
+ ],
+ vi.fn(),
+ );
+ const { container } = render(
+ ,
+ );
+ expect(container.innerHTML).toMatchSnapshot();
+ const button = screen.getByRole("button", { name: "Filters, 2 active" });
+ expect(button.textContent).toContain("2");
+ await user.click(button);
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("has a plain label without active filters", () => {
+ const control = makeControl([], vi.fn());
+ render( );
+ expect(screen.getByRole("button", { name: "Filters" })).toBeTruthy();
+ });
+ });
+});
diff --git a/packages/core/src/components/filter-rail/filter-rail.tsx b/packages/core/src/components/filter-rail/filter-rail.tsx
new file mode 100644
index 00000000..a7be7bfe
--- /dev/null
+++ b/packages/core/src/components/filter-rail/filter-rail.tsx
@@ -0,0 +1,311 @@
+import { useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
+import { cn } from "@/lib/utils";
+import type { CollectionControl, Filter } from "@/types/collection";
+import { applyRailLayout, findRailProblems, railFields, sectionFields } from "./apply-layout";
+import { DateControl, DateRangeControl, NumberRangeControl } from "./controls";
+import {
+ FilterRailContext,
+ sectionName,
+ useFilterRailContext,
+ type FilterRailContextValue,
+} from "./filter-rail-context";
+import { useFilterRailT } from "./i18n";
+import { ClearLink, RailSection } from "./internals";
+import {
+ BooleanControl,
+ CheckboxControl,
+ CustomControl,
+ filterFor,
+ RadioControl,
+ TextControl,
+} from "./sections";
+import type { FilterRailRootProps, FilterRailSection } from "./types";
+
+// ─── Root ────────────────────────────────────────────────────────────────────
+
+function Root({
+ control,
+ sections,
+ counts,
+ layout,
+ zeroBehavior = "disable",
+ density = "compact",
+ "aria-label": ariaLabel,
+ onFilterChange,
+ className,
+ children,
+}: FilterRailRootProps) {
+ const t = useFilterRailT();
+
+ // Widen once at the boundary — every internal is string-keyed by nature.
+ const anyControl = control as unknown as CollectionControl;
+ const anySections = sections as readonly FilterRailSection[];
+ const onChangeRef = useRef(onFilterChange);
+ onChangeRef.current = onFilterChange;
+
+ // Dev guard: two sections on one field silently overwrite each other.
+ const warned = useRef(new Set());
+ useEffect(() => {
+ for (const problem of findRailProblems(anySections)) {
+ if (warned.current.has(problem)) continue;
+ warned.current.add(problem);
+ console.warn(problem);
+ }
+ }, [anySections]);
+
+ const owned = useMemo(() => railFields(anySections), [anySections]);
+ const filters = anyControl.filters;
+ // The list as of the last write, so two commits in one handler (a custom
+ // section setting two fields) compose instead of the second overwriting the
+ // first. Re-synced from the control on every render.
+ const latest = useRef(filters);
+ latest.current = filters;
+
+ // One `setFilters` computed from the live list — a loop of `addFilter` calls
+ // would all resolve against the same snapshot, and only the last would land.
+ const setFiltersWith = useCallback(
+ (fields: readonly string[], next: Filter[]) => {
+ latest.current = next;
+ anyControl.setFilters(next);
+ onChangeRef.current?.(next as Filter[], fields as readonly TField[]);
+ },
+ [anyControl],
+ );
+
+ const commit = useCallback(
+ (field: string, next: Filter | null) => {
+ const without = latest.current.filter((filter) => filter.field !== field);
+ setFiltersWith([field], next ? [...without, next] : without);
+ },
+ [setFiltersWith],
+ );
+
+ const clearFields = useCallback(
+ (fields: readonly string[]) => {
+ const drop = new Set(fields);
+ const current = latest.current;
+ if (!current.some((filter) => drop.has(filter.field))) return;
+ setFiltersWith(
+ fields,
+ current.filter((filter) => !drop.has(filter.field)),
+ );
+ },
+ [setFiltersWith],
+ );
+
+ // Must NOT call `clearFilters()` — that would also wipe filters the rail
+ // does not own.
+ const clearAll = useCallback(() => clearFields([...owned]), [clearFields, owned]);
+
+ const value = useMemo(
+ () => ({
+ control: anyControl,
+ sections: anySections,
+ counts,
+ layout,
+ zeroBehavior,
+ density,
+ owned,
+ activeCount: filters.filter((filter) => owned.has(filter.field)).length,
+ filteredSections: new Set(
+ anySections
+ .filter((section) =>
+ sectionFields(section).some((field) => filterFor(filters, field) !== undefined),
+ )
+ .map((section) => section.id),
+ ),
+ commit,
+ clearFields,
+ clearAll,
+ }),
+ [
+ anyControl,
+ anySections,
+ counts,
+ layout,
+ zeroBehavior,
+ density,
+ owned,
+ filters,
+ commit,
+ clearFields,
+ clearAll,
+ ],
+ );
+
+ return (
+
+
+
+ );
+}
+Root.displayName = "FilterRail.Root";
+
+// ─── Header ──────────────────────────────────────────────────────────────────
+
+type HeaderProps = {
+ /** Default "Filters". */
+ title?: ReactNode;
+ /** Actions on the right — `FilterRail.ClearAll`, `FilterRail.Settings`, or your own. */
+ children?: ReactNode;
+ className?: string;
+};
+
+function Header({ title, children, className }: HeaderProps) {
+ const t = useFilterRailT();
+ useFilterRailContext("Header");
+ return (
+
+
{title ?? t("title")}
+ {children && (
+
{children}
+ )}
+
+ );
+}
+Header.displayName = "FilterRail.Header";
+
+// ─── ClearAll ────────────────────────────────────────────────────────────────
+
+type ClearAllProps = {
+ /** Default "Clear all". */
+ children?: ReactNode;
+ className?: string;
+};
+
+/** Clears only the rail's own fields. Renders nothing while none are active. */
+function ClearAll({ children, className }: ClearAllProps) {
+ const t = useFilterRailT();
+ const { activeCount, clearAll } = useFilterRailContext("ClearAll");
+ if (activeCount === 0) return null;
+ return (
+
+ {children ?? t("clearAll")}
+
+ );
+}
+ClearAll.displayName = "FilterRail.ClearAll";
+
+// ─── Sections ────────────────────────────────────────────────────────────────
+
+const SectionBody = ({ section }: { section: FilterRailSection }) => {
+ switch (section.control) {
+ case "text":
+ return ;
+ case "radio":
+ return ;
+ case "checkbox":
+ return ;
+ case "boolean":
+ return ;
+ case "date":
+ return ;
+ case "dateRange":
+ return ;
+ case "numberRange":
+ return ;
+ case "custom":
+ return ;
+ }
+};
+
+/**
+ * The way out of an empty result: name the one or two sections whose clearing
+ * would bring rows back. Needs `counts.total` and `counts.withoutSection`.
+ */
+const Recovery = () => {
+ const t = useFilterRailT();
+ const { counts, activeCount, sections, filteredSections, clearFields } =
+ useFilterRailContext("Sections");
+ const suggestions = useMemo(() => {
+ const without = counts?.withoutSection;
+ if (!without || counts?.total !== 0 || activeCount === 0) return [];
+ return sections
+ .filter((section) => filteredSections.has(section.id) && (without[section.id] ?? 0) > 0)
+ .map((section) => ({ section, rows: without[section.id]! }))
+ .toSorted((a, b) => b.rows - a.rows)
+ .slice(0, 2);
+ }, [counts, activeCount, sections, filteredSections]);
+
+ if (suggestions.length === 0) return null;
+ return (
+
+ {t("nothingMatches")}
+
+ {suggestions.map(({ section, rows }) => (
+ clearFields(sectionFields(section))}
+ className="astw:font-medium astw:text-foreground astw:underline"
+ >
+ {t("clearToSee", {
+ section: sectionName(section),
+ count: rows.toLocaleString(),
+ })}
+
+ ))}
+
+
+ );
+};
+
+type SectionsProps = { className?: string };
+
+/** Every section, in the user's layout. Scrolls; the header stays put. */
+function Sections({ className }: SectionsProps) {
+ const t = useFilterRailT();
+ const { sections, layout, filteredSections, clearFields } = useFilterRailContext("Sections");
+ const visible = useMemo(() => applyRailLayout(sections, layout?.value), [sections, layout]);
+
+ return (
+ // A form so Enter inside a text input does not submit an enclosing form.
+
+ );
+}
+Sections.displayName = "FilterRail.Sections";
+
+export { Root, Header, ClearAll, Sections };
+export type { HeaderProps, ClearAllProps, SectionsProps };
diff --git a/packages/core/src/components/filter-rail/i18n.ts b/packages/core/src/components/filter-rail/i18n.ts
new file mode 100644
index 00000000..895992ec
--- /dev/null
+++ b/packages/core/src/components/filter-rail/i18n.ts
@@ -0,0 +1,129 @@
+import { defineI18nLabels } from "@/hooks/i18n";
+
+export const filterRailLabels = defineI18nLabels({
+ en: {
+ title: "Filters",
+ clear: "Clear",
+ clearAll: "Clear all",
+ any: "Any",
+ yes: "Yes",
+ no: "No",
+ showMore: (props: { count: number }) => `Show ${props.count} more`,
+ showLess: "Show less",
+ searchOptions: "Filter…",
+ searchOptionsLabel: (props: { section: string }) => `Filter ${props.section} options`,
+ noMatchingValues: "No matching values",
+ optionWithCount: (props: { label: string; count: string }) =>
+ `${props.label}, ${props.count} items`,
+ itemCount: (props: { count: string }) => `${props.count} items`,
+ rangeMin: "Min",
+ rangeMax: "Max",
+ rangeTo: "to",
+ rangeMinLabel: (props: { section: string }) => `Minimum ${props.section}`,
+ rangeMaxLabel: (props: { section: string }) => `Maximum ${props.section}`,
+
+ // Empty-result recovery
+ nothingMatches: "Nothing matches these filters.",
+ clearToSee: (props: { section: string; count: string }) =>
+ `Clear ${props.section} to see ${props.count}`,
+
+ // Settings
+ settings: "Filter settings",
+ settingsDescription: "Show, reorder and sort the filter sections.",
+ showSection: (props: { section: string }) => `Show ${props.section}`,
+ hideSection: (props: { section: string }) => `Hide ${props.section}`,
+ hideSectionClears: (props: { section: string }) =>
+ `Hide ${props.section}, which also clears its filter`,
+ hideSectionClearsHint: "Filtering now — hiding this section also clears its filter",
+ reorderSection: (props: { section: string }) =>
+ `Reorder ${props.section}. Use arrow up and down.`,
+ sortOptions: (props: { section: string }) => `Sort ${props.section} options`,
+ sortGiven: "Default",
+ sortLabel: "A–Z",
+ sortBaselineCount: "By count",
+ resetLayout: "Reset to default",
+
+ // Saved views
+ savedViews: "Saved views",
+ noSavedViews: "No saved views yet",
+ deleteView: (props: { name: string }) => `Delete ${props.name}`,
+ saveView: "Save view",
+ saveViewTitle: "Save this view",
+ saveViewDescription: "Filters, sort, and the column layout will be saved under this name.",
+ saveViewPlaceholder: "e.g. Low stock, wide view",
+ cancel: "Cancel",
+ save: "Save",
+ viewFilters: (props: { count: number }) =>
+ `${props.count} filter${props.count === 1 ? "" : "s"}`,
+ viewSorted: "sorted",
+ viewHidden: (props: { count: number }) => `${props.count} hidden`,
+ viewPinned: (props: { count: number }) => `${props.count} pinned`,
+ viewPageSize: (props: { count: number }) => `${props.count}/page`,
+
+ // Compact layout
+ activeCount: (props: { label: string; count: number }) =>
+ `${props.label}, ${props.count} active`,
+ sheetDescription: "Filter the results.",
+ },
+ ja: {
+ title: "フィルター",
+ clear: "クリア",
+ clearAll: "すべてクリア",
+ any: "すべて",
+ yes: "はい",
+ no: "いいえ",
+ showMore: (props: { count: number }) => `さらに${props.count}件を表示`,
+ showLess: "表示を減らす",
+ searchOptions: "絞り込み…",
+ searchOptionsLabel: (props: { section: string }) => `${props.section}の選択肢を絞り込み`,
+ noMatchingValues: "一致する値がありません",
+ optionWithCount: (props: { label: string; count: string }) =>
+ `${props.label}、${props.count}件`,
+ itemCount: (props: { count: string }) => `${props.count}件`,
+ rangeMin: "最小",
+ rangeMax: "最大",
+ rangeTo: "〜",
+ rangeMinLabel: (props: { section: string }) => `${props.section}の最小値`,
+ rangeMaxLabel: (props: { section: string }) => `${props.section}の最大値`,
+
+ nothingMatches: "条件に一致する項目がありません。",
+ clearToSee: (props: { section: string; count: string }) =>
+ `${props.section}をクリアすると${props.count}件表示`,
+
+ settings: "フィルター設定",
+ settingsDescription: "フィルターセクションの表示・並び順・並べ替えを設定します。",
+ showSection: (props: { section: string }) => `${props.section}を表示`,
+ hideSection: (props: { section: string }) => `${props.section}を非表示`,
+ hideSectionClears: (props: { section: string }) =>
+ `${props.section}を非表示にし、そのフィルターもクリア`,
+ hideSectionClearsHint: "フィルター適用中 — 非表示にするとこのフィルターもクリアされます",
+ reorderSection: (props: { section: string }) =>
+ `${props.section}の並び順を変更。上下矢印キーで移動します。`,
+ sortOptions: (props: { section: string }) => `${props.section}の選択肢の並べ替え`,
+ sortGiven: "既定",
+ sortLabel: "名前順",
+ sortBaselineCount: "件数順",
+ resetLayout: "既定に戻す",
+
+ savedViews: "保存したビュー",
+ noSavedViews: "保存したビューはまだありません",
+ deleteView: (props: { name: string }) => `${props.name}を削除`,
+ saveView: "ビューを保存",
+ saveViewTitle: "このビューを保存",
+ saveViewDescription: "フィルター、並べ替え、列レイアウトがこの名前で保存されます。",
+ saveViewPlaceholder: "例: 在庫僅少、ワイド表示",
+ cancel: "キャンセル",
+ save: "保存",
+ viewFilters: (props: { count: number }) => `フィルター${props.count}件`,
+ viewSorted: "並べ替え済み",
+ viewHidden: (props: { count: number }) => `非表示${props.count}列`,
+ viewPinned: (props: { count: number }) => `固定${props.count}列`,
+ viewPageSize: (props: { count: number }) => `${props.count}件/ページ`,
+
+ activeCount: (props: { label: string; count: number }) =>
+ `${props.label}、${props.count}件適用中`,
+ sheetDescription: "結果を絞り込みます。",
+ },
+});
+
+export const useFilterRailT = filterRailLabels.useT;
diff --git a/packages/core/src/components/filter-rail/index.ts b/packages/core/src/components/filter-rail/index.ts
new file mode 100644
index 00000000..b60b416a
--- /dev/null
+++ b/packages/core/src/components/filter-rail/index.ts
@@ -0,0 +1,41 @@
+import { ClearAll, Header, Root, Sections } from "./filter-rail";
+import { RailSheet, Trigger } from "./responsive";
+import { SavedViews } from "./saved-views";
+import { Settings } from "./settings";
+
+/**
+ * An always-visible faceted filter rail. Sections are config (`sections`), so
+ * any mix of checkbox, radio, boolean, text, date, range and custom sections
+ * works; every other feature is an optional part you include or leave out.
+ *
+ * It writes the same `CollectionControl` a `DataTable` reads, so it can sit in
+ * a separate `Layout.Column` beside the table.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * ```
+ */
+export const FilterRail = {
+ Root,
+ Header,
+ ClearAll,
+ Sections,
+ Settings,
+ SavedViews,
+ Trigger,
+ Sheet: RailSheet,
+};
+
+export type { FilterRailRootProps, FilterRailSection } from "./types";
+export { useFilterRailCounts } from "./use-filter-rail-counts";
+export { useFilterRailLayout } from "./use-filter-rail-layout";
+export { useSavedViews } from "./use-saved-views";
+export { useFilterRailCompact } from "./responsive";
diff --git a/packages/core/src/components/filter-rail/internals.tsx b/packages/core/src/components/filter-rail/internals.tsx
new file mode 100644
index 00000000..687aed4c
--- /dev/null
+++ b/packages/core/src/components/filter-rail/internals.tsx
@@ -0,0 +1,256 @@
+import { useId, useState, type ReactNode } from "react";
+import { cn } from "@/lib/utils";
+import { Checkbox } from "../checkbox";
+import { Fieldset } from "../fieldset";
+import { useFilterRailT } from "./i18n";
+
+/** 32px rows by default — density over the 44px touch minimum in a mouse-driven ERP. */
+export const rowHeightClass = (density: "compact" | "comfortable") =>
+ density === "compact" ? "astw:h-8" : "astw:h-10";
+
+export const inputClass =
+ "astw:h-7 astw:w-full astw:min-w-0 astw:rounded-md astw:border astw:border-input astw:bg-transparent astw:px-2 astw:text-sm astw:outline-none astw:focus-visible:border-ring astw:focus-visible:ring-ring/50 astw:focus-visible:ring-[3px] astw:dark:bg-input/30";
+
+// ─── Section shell ───────────────────────────────────────────────────────────
+
+/**
+ * A labelled group, rendered as a real `` / ``.
+ *
+ * Deliberately not collapsible: truncation hides options, but collapsing would
+ * hide *state* — an active filter in a panel you forgot you closed.
+ */
+export const RailSection = ({
+ id,
+ label,
+ hint,
+ action,
+ children,
+}: {
+ id: string;
+ label: ReactNode;
+ hint?: ReactNode;
+ /** Right-aligned in the header — the per-section Clear. */
+ action?: ReactNode;
+ children: ReactNode;
+}) => (
+
+
+
+
+ {label}
+
+ {action}
+
+ {hint && {hint}
}
+ {children}
+
+
+);
+
+/** Clear / Clear all. A text link, not a button — it is an undo. */
+export const ClearLink = ({
+ onClick,
+ children,
+ className,
+}: {
+ onClick: () => void;
+ children: ReactNode;
+ className?: string;
+}) => (
+
+ {children}
+
+);
+
+const Count = ({ value }: { value: string }) => (
+
+ {value}
+
+);
+
+// ─── Option rows ─────────────────────────────────────────────────────────────
+
+/**
+ * One checkbox option with its count.
+ *
+ * The label is the accessible name and the count its description ("Knitwear",
+ * "1,380 items"). An `aria-label` would lose to the `aria-labelledby` the
+ * label wrapper sets, and would repeat the name. Zero-count options use `aria-disabled`
+ * rather than `disabled`, so they stay in the tab order and a keyboard user can
+ * still learn the category is empty.
+ */
+export const OptionRow = ({
+ label,
+ icon,
+ count,
+ checked,
+ disabled,
+ title,
+ rowHeight,
+ onToggle,
+}: {
+ label: string;
+ icon?: ReactNode;
+ count?: number;
+ checked: boolean;
+ disabled?: boolean;
+ title?: string;
+ rowHeight: string;
+ onToggle: () => void;
+}) => {
+ const t = useFilterRailT();
+ const countId = useId();
+ const formatted = count === undefined ? undefined : count.toLocaleString();
+ return (
+
+ {
+ if (!disabled) onToggle();
+ }}
+ // Stretch the label so the count can push to the row's right edge.
+ className="astw:min-w-0 astw:flex-1"
+ label={
+
+ {icon}
+ {label}
+ {formatted !== undefined && }
+
+ }
+ />
+ {/* Outside the , so it describes the checkbox without joining its name. */}
+ {formatted !== undefined && (
+
+ {t("itemCount", { count: formatted })}
+
+ )}
+
+ );
+};
+
+/**
+ * One radio option. app-shell has no public radio yet; a native radio inside a
+ * ` ` gives grouping and arrow-key behaviour for free.
+ */
+export const RadioRow = ({
+ name,
+ label,
+ count,
+ checked,
+ rowHeight,
+ onSelect,
+}: {
+ name: string;
+ label: string;
+ count?: number;
+ checked: boolean;
+ rowHeight: string;
+ onSelect: () => void;
+}) => {
+ const t = useFilterRailT();
+ const formatted = count === undefined ? undefined : count.toLocaleString();
+ return (
+
+
+ {label}
+ {formatted !== undefined && }
+
+ );
+};
+
+// ─── Show more / search ──────────────────────────────────────────────────────
+
+export const ShowMore = ({
+ hidden,
+ expanded,
+ onToggle,
+}: {
+ hidden: number;
+ expanded: boolean;
+ onToggle: () => void;
+}) => {
+ const t = useFilterRailT();
+ if (hidden <= 0 && !expanded) return null;
+ return (
+
+ {expanded ? t("showLess") : t("showMore", { count: hidden })}
+
+ );
+};
+
+export const OptionSearch = ({
+ value,
+ onChange,
+ label,
+}: {
+ value: string;
+ onChange: (next: string) => void;
+ label: string;
+}) => {
+ const t = useFilterRailT();
+ return (
+ onChange(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Escape") onChange("");
+ }}
+ className={cn(inputClass, "astw:mb-1 astw:text-xs")}
+ />
+ );
+};
+
+export const useShowMore = () => {
+ const [expanded, setExpanded] = useState(false);
+ return { expanded, toggle: () => setExpanded((previous) => !previous) };
+};
diff --git a/packages/core/src/components/filter-rail/operators.ts b/packages/core/src/components/filter-rail/operators.ts
new file mode 100644
index 00000000..54fbf222
--- /dev/null
+++ b/packages/core/src/components/filter-rail/operators.ts
@@ -0,0 +1,78 @@
+// In-memory evaluation of the filter operators the rail can emit. Private to
+// `useFilterRailCounts`; a backend-driven screen never runs this.
+
+/** Numbers compare numerically; everything else (ISO dates, strings) lexically. */
+const compare = (a: unknown, b: unknown): number => {
+ if (typeof a === "number" || typeof b === "number") {
+ return Math.sign(Number(a) - Number(b));
+ }
+ const as = String(a);
+ const bs = String(b);
+ if (as === bs) return 0;
+ return as < bs ? -1 : 1;
+};
+
+/**
+ * `useURLCollectionVariables` reads `f.x:eq=true` back as the string `"true"`,
+ * so a boolean filter would match nothing after a reload without this.
+ */
+const coerce = (value: unknown, operand: unknown): unknown => {
+ if (typeof value === "boolean" && (operand === "true" || operand === "false")) {
+ return operand === "true";
+ }
+ if (typeof value === "number" && typeof operand === "string" && operand.trim() !== "") {
+ const parsed = Number(operand);
+ return Number.isNaN(parsed) ? operand : parsed;
+ }
+ return operand;
+};
+
+const lower = (value: unknown) => String(value ?? "").toLowerCase();
+
+/** Whether `value` satisfies `operator operand`. Unknown operators match. */
+export function matchesOperator(value: unknown, operator: string, operand: unknown): boolean {
+ if (Array.isArray(value) && (operator === "in" || operator === "nin")) {
+ // A list-valued cell matches `in` when any member does.
+ const hit = value.some((member) => matchesOperator(member, "in", operand));
+ return operator === "in" ? hit : !hit;
+ }
+ switch (operator) {
+ case "eq":
+ return value === coerce(value, operand);
+ case "ne":
+ return value !== coerce(value, operand);
+ case "gt":
+ return compare(value, operand) > 0;
+ case "gte":
+ return compare(value, operand) >= 0;
+ case "lt":
+ return compare(value, operand) < 0;
+ case "lte":
+ return compare(value, operand) <= 0;
+ case "between": {
+ const { min, max } = (operand ?? {}) as { min?: unknown; max?: unknown };
+ return (
+ (min === undefined || compare(value, min) >= 0) &&
+ (max === undefined || compare(value, max) <= 0)
+ );
+ }
+ case "in":
+ return Array.isArray(operand) && operand.map(String).includes(String(value));
+ case "nin":
+ return Array.isArray(operand) && !operand.map(String).includes(String(value));
+ case "contains":
+ return lower(value).includes(lower(operand));
+ case "notContains":
+ return !lower(value).includes(lower(operand));
+ case "hasPrefix":
+ return lower(value).startsWith(lower(operand));
+ case "notHasPrefix":
+ return !lower(value).startsWith(lower(operand));
+ case "hasSuffix":
+ return lower(value).endsWith(lower(operand));
+ case "notHasSuffix":
+ return !lower(value).endsWith(lower(operand));
+ default:
+ return true;
+ }
+}
diff --git a/packages/core/src/components/filter-rail/responsive.tsx b/packages/core/src/components/filter-rail/responsive.tsx
new file mode 100644
index 00000000..e1b89678
--- /dev/null
+++ b/packages/core/src/components/filter-rail/responsive.tsx
@@ -0,0 +1,118 @@
+// The rail's small-screen form: a left sheet behind a toolbar button.
+//
+// "Always visible" cannot hold below `lg` — the left column would either eat
+// the table or push every row off the fold. The compromise is paid for by the
+// active count on the trigger, so "is anything filtering?" is still answered
+// without opening anything.
+
+import { useEffect, useMemo, useState, type ReactNode } from "react";
+import { SlidersHorizontal } from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { CollectionControl } from "@/types/collection";
+import { Button } from "../button";
+import { Sheet } from "../sheet";
+import { railFields } from "./apply-layout";
+import { useFilterRailT } from "./i18n";
+import type { FilterRailSection } from "./types";
+
+const COMPACT_QUERY = "(max-width: 1023.98px)";
+
+/**
+ * True below the `lg` breakpoint, where `Layout.Column area="left"` stops
+ * sitting beside the main column. A media query, not CSS: rendering the rail
+ * twice and hiding one copy would duplicate every input in the a11y tree.
+ */
+export function useFilterRailCompact(query: string = COMPACT_QUERY): boolean {
+ const [compact, setCompact] = useState(
+ () => typeof window !== "undefined" && window.matchMedia?.(query).matches === true,
+ );
+ useEffect(() => {
+ if (typeof window === "undefined" || !window.matchMedia) return;
+ const list = window.matchMedia(query);
+ const onChange = (event: MediaQueryListEvent) => setCompact(event.matches);
+ setCompact(list.matches);
+ list.addEventListener("change", onChange);
+ return () => list.removeEventListener("change", onChange);
+ }, [query]);
+ return compact;
+}
+
+type TriggerProps = {
+ /** The same control and sections given to `FilterRail.Root`, for the active count. */
+ control: CollectionControl;
+ sections: readonly FilterRailSection[];
+ onClick: () => void;
+ /** Default "Filters". */
+ children?: ReactNode;
+ className?: string;
+};
+
+/** A toolbar button carrying the number of active rail filters. */
+function Trigger({
+ control,
+ sections,
+ onClick,
+ children,
+ className,
+}: TriggerProps) {
+ const t = useFilterRailT();
+ const owned = useMemo(() => railFields(sections), [sections]);
+ const count = control.filters.filter((filter) => owned.has(filter.field)).length;
+ const label = children ?? t("title");
+ const text = typeof label === "string" ? label : t("title");
+ return (
+ 0 ? t("activeCount", { label: text, count }) : text}
+ className={cn("astw:self-start", className)}
+ >
+
+ {label}
+ {count > 0 && (
+
+ {count}
+
+ )}
+
+ );
+}
+Trigger.displayName = "FilterRail.Trigger";
+
+type SheetProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** Accessible title. Default "Filters". */
+ title?: string;
+ /** Usually a `FilterRail.Root`. */
+ children: ReactNode;
+};
+
+/** A left sheet — the edge the rail occupies on a wide screen. */
+function RailSheet({ open, onOpenChange, title, children }: SheetProps) {
+ const t = useFilterRailT();
+ return (
+
+
+
+ {title ?? t("title")}
+ {t("sheetDescription")}
+
+ {children}
+
+
+ );
+}
+RailSheet.displayName = "FilterRail.Sheet";
+
+export { Trigger, RailSheet };
+export type { TriggerProps, SheetProps };
diff --git a/packages/core/src/components/filter-rail/saved-views.tsx b/packages/core/src/components/filter-rail/saved-views.tsx
new file mode 100644
index 00000000..3bc235a1
--- /dev/null
+++ b/packages/core/src/components/filter-rail/saved-views.tsx
@@ -0,0 +1,165 @@
+import { useState } from "react";
+import { Bookmark, Check, ChevronDown, Trash2 } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { Button } from "../button";
+import { Dialog } from "../dialog";
+import { Input } from "../input";
+import { Menu } from "../menu";
+import { useFilterRailT } from "./i18n";
+import type { SavedViewBinding } from "./types";
+
+type SavedViewsProps = SavedViewBinding & { className?: string };
+
+/**
+ * A saved-view picker plus a Save button that appears only when saving would
+ * achieve something (`dirty`).
+ *
+ * Takes its binding as props and reads nothing from the rail, so it also works
+ * outside `FilterRail.Root` — in a page header, for instance. `useSavedViews`
+ * returns a ready binding for a `DataTable`.
+ */
+function SavedViews({
+ items,
+ activeId,
+ dirty,
+ onSave,
+ onApply,
+ onDelete,
+ describe,
+ className,
+}: SavedViewsProps) {
+ const t = useFilterRailT();
+ const [naming, setNaming] = useState(false);
+ const [draftName, setDraftName] = useState("");
+ const active = items.find((item) => item.id === activeId) ?? null;
+
+ const commitName = () => {
+ const name = draftName.trim();
+ if (!name) return;
+ onSave(name);
+ setNaming(false);
+ setDraftName("");
+ };
+
+ return (
+
+
+
+
+ {active ? active.name : t("savedViews")}
+ {!active && items.length > 0 && (
+ ({items.length})
+ )}
+
+
+ }
+ />
+
+ {items.length === 0 ? (
+ {t("noSavedViews")}
+ ) : (
+
+ {items.map((item) => (
+ onApply(item)}>
+
+
+ {item.id === activeId && }
+
+
+ {item.name}
+ {describe && (
+
+ {describe(item)}
+
+ )}
+
+ cannot nest inside the menu item
+ role="button"
+ tabIndex={0}
+ aria-label={t("deleteView", { name: item.name })}
+ className="astw:shrink-0 astw:rounded astw:p-0.5 astw:text-muted-foreground astw:hover:text-destructive"
+ onClick={(event) => {
+ event.stopPropagation();
+ onDelete(item);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ event.stopPropagation();
+ onDelete(item);
+ }
+ }}
+ >
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {dirty && (
+ {
+ setDraftName("");
+ setNaming(true);
+ }}
+ >
+ {t("saveView")}
+
+ )}
+
+
+
+
+ {t("saveViewTitle")}
+ {t("saveViewDescription")}
+
+ setDraftName(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") commitName();
+ }}
+ />
+
+ setNaming(false)}>
+ {t("cancel")}
+
+
+ {t("save")}
+
+
+
+
+
+ );
+}
+SavedViews.displayName = "FilterRail.SavedViews";
+
+export { SavedViews };
+export type { SavedViewsProps };
diff --git a/packages/core/src/components/filter-rail/sections.tsx b/packages/core/src/components/filter-rail/sections.tsx
new file mode 100644
index 00000000..61d3724f
--- /dev/null
+++ b/packages/core/src/components/filter-rail/sections.tsx
@@ -0,0 +1,301 @@
+import { useEffect, useMemo, useState } from "react";
+import { cn } from "@/lib/utils";
+import type { Filter } from "@/types/collection";
+import { useFilterRailContext, sectionName } from "./filter-rail-context";
+import { useFilterRailT } from "./i18n";
+import {
+ inputClass,
+ OptionRow,
+ OptionSearch,
+ RadioRow,
+ rowHeightClass,
+ ShowMore,
+ useShowMore,
+} from "./internals";
+import type {
+ BooleanSection,
+ CheckboxSection,
+ CustomSection,
+ FacetOption,
+ RadioSection,
+ TextSection,
+} from "./types";
+
+export const filterFor = (filters: readonly Filter[], field: string) =>
+ filters.find((filter) => filter.field === field);
+
+/** Row labels are strings for the a11y name; a ReactNode label falls back to `fallback`. */
+const text = (value: unknown, fallback: string) =>
+ typeof value === "string" || typeof value === "number" ? String(value) : fallback;
+
+export const sortOptions = (
+ options: readonly FacetOption[],
+ sort: CheckboxSection["sort"],
+ live: Record | undefined,
+ baseline: Record | undefined,
+): FacetOption[] => {
+ const list = [...options];
+ const mode = sort ?? "given";
+ if (typeof mode === "function") {
+ return list.toSorted((a, b) =>
+ mode(a, b, { a: live?.[a.value] ?? 0, b: live?.[b.value] ?? 0 }),
+ );
+ }
+ if (mode === "label") return list.toSorted((a, b) => a.label.localeCompare(b.label));
+ if (mode === "baselineCount" || mode === "liveCount") {
+ const source = mode === "baselineCount" ? baseline : live;
+ return list.toSorted(
+ (a, b) =>
+ (source?.[b.value] ?? 0) - (source?.[a.value] ?? 0) || a.label.localeCompare(b.label),
+ );
+ }
+ return list;
+};
+
+// ─── Text ────────────────────────────────────────────────────────────────────
+
+export const TextControl = ({ section }: { section: TextSection }) => {
+ const { control, commit, density } = useFilterRailContext("Sections");
+ const applied = filterFor(control.filters, section.field);
+ const appliedValue = typeof applied?.value === "string" ? applied.value : "";
+ const [draft, setDraft] = useState(appliedValue);
+
+ // Follow changes made elsewhere — Clear all, a shared URL, the back button.
+ useEffect(() => setDraft(appliedValue), [appliedValue]);
+
+ // Debounced: every commit resets pagination.
+ useEffect(() => {
+ const trimmed = draft.trim();
+ if (trimmed === appliedValue) return;
+ const timer = window.setTimeout(() => {
+ const min = section.minLength ?? 1;
+ commit(
+ section.field,
+ trimmed.length >= min
+ ? { field: section.field, operator: section.operator, value: trimmed }
+ : null,
+ );
+ }, section.debounceMs ?? 250);
+ return () => window.clearTimeout(timer);
+ }, [draft, appliedValue, section, commit]);
+
+ return (
+ setDraft(event.target.value)}
+ className={cn(inputClass, rowHeightClass(density))}
+ />
+ );
+};
+
+// ─── Radio ───────────────────────────────────────────────────────────────────
+
+export const RadioControl = ({ section }: { section: RadioSection }) => {
+ const t = useFilterRailT();
+ const { control, commit, counts, density } = useFilterRailContext("Sections");
+ const live = counts?.live[section.id];
+ const applied = filterFor(control.filters, section.field);
+ const current = typeof applied?.value === "string" ? applied.value : null;
+ const operator = section.operator ?? "eq";
+ const rowHeight = rowHeightClass(density);
+
+ return (
+
+ {section.anyLabel !== null && (
+ commit(section.field, null)}
+ />
+ )}
+ {section.options.map((option) => (
+
+ commit(section.field, { field: section.field, operator, value: option.value })
+ }
+ />
+ ))}
+
+ );
+};
+
+// ─── Checkbox ────────────────────────────────────────────────────────────────
+
+export const CheckboxControl = ({ section }: { section: CheckboxSection }) => {
+ const t = useFilterRailT();
+ const { control, commit, counts, zeroBehavior, density } = useFilterRailContext("Sections");
+ const live = counts?.live[section.id];
+ const baseline = counts?.baseline?.[section.id];
+ const applied = filterFor(control.filters, section.field);
+ const selected = useMemo(
+ () => new Set(Array.isArray(applied?.value) ? (applied.value as unknown[]).map(String) : []),
+ [applied],
+ );
+ const { expanded, toggle } = useShowMore();
+ const [query, setQuery] = useState("");
+
+ const operator = section.operator ?? "in";
+ const limit = section.truncate === false ? Number.POSITIVE_INFINITY : (section.truncate ?? 8);
+ const threshold =
+ section.searchThreshold === false ? Number.POSITIVE_INFINITY : (section.searchThreshold ?? 12);
+
+ const ordered = useMemo(() => {
+ const sorted = sortOptions(section.options, section.sort, live, baseline);
+ if (!section.pinned?.length) return sorted;
+ const rank = new Map(section.pinned.map((value, index) => [value, index]));
+ return [
+ ...sorted
+ .filter((option) => rank.has(option.value))
+ .toSorted((a, b) => rank.get(a.value)! - rank.get(b.value)!),
+ ...sorted.filter((option) => !rank.has(option.value)),
+ ];
+ }, [section.options, section.sort, section.pinned, live, baseline]);
+
+ // A selected option hidden by the search query would be an active filter
+ // nowhere on screen — keep it, first.
+ const matching = useMemo(() => {
+ if (!query) return ordered;
+ const needle = query.toLowerCase();
+ const hits = ordered.filter((option) => option.label.toLowerCase().includes(needle));
+ const shown = new Set(hits.map((option) => option.value));
+ const stranded = ordered.filter(
+ (option) => selected.has(option.value) && !shown.has(option.value),
+ );
+ return [...stranded, ...hits];
+ }, [ordered, query, selected]);
+
+ // Truncation hides options, never state: a selected option outside the top N
+ // is still rendered.
+ const visible = useMemo(() => {
+ if (expanded || matching.length <= limit) return matching;
+ const head = matching.slice(0, limit);
+ const shown = new Set(head.map((option) => option.value));
+ const stranded = matching.filter(
+ (option) => selected.has(option.value) && !shown.has(option.value),
+ );
+ return [...head, ...stranded];
+ }, [matching, limit, expanded, selected]);
+
+ const toggleOption = (value: string) => {
+ const next = new Set(selected);
+ if (next.has(value)) next.delete(value);
+ else next.add(value);
+ // An empty `in` would match nothing — remove the filter instead.
+ commit(section.field, next.size ? { field: section.field, operator, value: [...next] } : null);
+ };
+
+ const rowHeight = rowHeightClass(density);
+
+ return (
+ <>
+ {ordered.length > threshold && (
+
+ )}
+ {visible.map((option) => {
+ // Once a section's map exists, a missing option has zero matches, not
+ // an unknown count.
+ const count = live ? (live[option.value] ?? 0) : undefined;
+ const isSelected = selected.has(option.value);
+ // A selected option is never disabled — it is the only way to unselect it.
+ const zero = count === 0 && !isSelected && zeroBehavior === "disable";
+ return (
+ toggleOption(option.value)}
+ />
+ );
+ })}
+ {visible.length === 0 && (
+
+ {t("noMatchingValues")}
+
+ )}
+
+ >
+ );
+};
+
+// ─── Boolean ─────────────────────────────────────────────────────────────────
+
+/** Accepts `"true"` / `"false"` too — the URL serializer stringifies booleans. */
+const asBoolean = (value: unknown): boolean | null => {
+ if (value === true || value === "true") return true;
+ if (value === false || value === "false") return false;
+ return null;
+};
+
+export const BooleanControl = ({ section }: { section: BooleanSection }) => {
+ const t = useFilterRailT();
+ const { control, commit, density } = useFilterRailContext("Sections");
+ const current = asBoolean(filterFor(control.filters, section.field)?.value);
+ const operator = section.operator ?? "eq";
+ const rowHeight = rowHeightClass(density);
+
+ const set = (next: boolean | null) =>
+ commit(section.field, next === null ? null : { field: section.field, operator, value: next });
+
+ return (
+
+ {section.anyLabel !== null && (
+ set(null)}
+ />
+ )}
+ set(true)}
+ />
+ set(false)}
+ />
+
+ );
+};
+
+// ─── Custom ──────────────────────────────────────────────────────────────────
+
+export const CustomControl = ({ section }: { section: CustomSection }) => {
+ const { control, commit } = useFilterRailContext("Sections");
+ return (
+ <>
+ {section.render({
+ control,
+ getFilter: (field) => filterFor(control.filters, field),
+ setFilter: (field, next) => commit(field, next ? { ...next, field } : null),
+ })}
+ >
+ );
+};
diff --git a/packages/core/src/components/filter-rail/settings.tsx b/packages/core/src/components/filter-rail/settings.tsx
new file mode 100644
index 00000000..9d388581
--- /dev/null
+++ b/packages/core/src/components/filter-rail/settings.tsx
@@ -0,0 +1,275 @@
+import { useMemo, useState } from "react";
+import { Popover } from "@base-ui/react/popover";
+import { GripVertical, RotateCcw, SlidersHorizontal } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { Button } from "../button";
+import { Checkbox } from "../checkbox";
+import { Select } from "../select";
+import { orderSections, sectionFields } from "./apply-layout";
+import { sectionName, useFilterRailContext } from "./filter-rail-context";
+import { useFilterRailT } from "./i18n";
+import type { UserOptionSort } from "./types";
+
+const POPUP_CLASS = cn(
+ "astw:bg-popover astw:text-popover-foreground astw:z-(--z-popup) astw:origin-(--transform-origin) astw:w-[24rem] astw:max-w-[calc(100vw-2rem)] astw:overflow-hidden astw:rounded-md astw:border astw:border-border astw:shadow-md",
+ "astw:animate-in astw:fade-in-0 astw:zoom-in-95 astw:data-ending-style:animate-out astw:data-ending-style:fade-out-0 astw:data-ending-style:zoom-out-95",
+);
+
+const USER_SORTS: readonly UserOptionSort[] = ["given", "label", "baselineCount"];
+
+type SettingsProps = { className?: string };
+
+/**
+ * Show / hide, reorder and sort the sections. Renders nothing unless
+ * `FilterRail.Root` has a `layout` binding.
+ *
+ * Hiding a section that carries a filter also clears that filter — hiding must
+ * never strand an active filter somewhere the user cannot see it.
+ */
+function Settings({ className }: SettingsProps) {
+ const t = useFilterRailT();
+ const { sections, layout, filteredSections, clearFields } = useFilterRailContext("Settings");
+ const [dragIndex, setDragIndex] = useState(null);
+ /** A gap index in `0..rows.length`, not a row index. */
+ const [dropIndex, setDropIndex] = useState(null);
+
+ const sortLabel: Record = {
+ given: t("sortGiven"),
+ label: t("sortLabel"),
+ baselineCount: t("sortBaselineCount"),
+ };
+ const sortItems = USER_SORTS.map((value) => ({ value, label: sortLabel[value] }));
+
+ const value = layout?.value;
+ const hidden = useMemo(() => new Set(value?.hidden ?? []), [value?.hidden]);
+ // Hidden sections included — this is the only place to switch them back on.
+ const rows = useMemo(() => orderSections(sections, value?.order ?? []), [sections, value?.order]);
+
+ if (!layout || !value) return null;
+
+ const move = (from: number, to: number) => {
+ if (from === to || to < 0 || to >= rows.length) return;
+ const ids = rows.map((section) => section.id);
+ const [moved] = ids.splice(from, 1);
+ if (moved === undefined) return;
+ ids.splice(to, 0, moved);
+ layout.onChange({ ...value, order: ids });
+ };
+
+ // `move` splices the row out first, so a downward gap index shifts by one.
+ const commitDrop = () => {
+ if (dragIndex !== null && dropIndex !== null) {
+ move(dragIndex, dropIndex > dragIndex ? dropIndex - 1 : dropIndex);
+ }
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ const toggleHidden = (id: string) => {
+ const next = new Set(hidden);
+ if (next.has(id)) {
+ next.delete(id);
+ } else {
+ next.add(id);
+ const section = sections.find((candidate) => candidate.id === id);
+ if (section && filteredSections.has(id)) clearFields(sectionFields(section));
+ }
+ layout.onChange({ ...value, hidden: [...next] });
+ };
+
+ const customised =
+ value.order.length > 0 || value.hidden.length > 0 || Object.keys(value.sort).length > 0;
+
+ return (
+
+
+
+
+ }
+ />
+
+
+
+
+
+ {t("settings")}
+
+
+ {t("settingsDescription")}
+
+
+
+ event.preventDefault()}
+ onDrop={(event) => {
+ event.preventDefault();
+ commitDrop();
+ }}
+ onDragLeave={(event) => {
+ if (!event.currentTarget.contains(event.relatedTarget as Node)) setDropIndex(null);
+ }}
+ >
+ {rows.map((section, index) => {
+ const name = sectionName(section);
+ const isHidden = hidden.has(section.id);
+ const clearsFilter = filteredSections.has(section.id) && !isHidden;
+ // Fall back to what the author declared, not to "Default".
+ const authored =
+ section.control === "checkbox" &&
+ typeof section.sort === "string" &&
+ (USER_SORTS as readonly string[]).includes(section.sort)
+ ? (section.sort as UserOptionSort)
+ : "given";
+ const current = value.sort[section.id] ?? authored;
+ let visibilityLabel = t("hideSection", { section: name });
+ if (isHidden) visibilityLabel = t("showSection", { section: name });
+ if (clearsFilter) visibilityLabel = t("hideSectionClears", { section: name });
+ return (
+ {
+ setDragIndex(index);
+ event.dataTransfer.effectAllowed = "move";
+ }}
+ onDragOver={(event) => {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "move";
+ const rect = event.currentTarget.getBoundingClientRect();
+ setDropIndex(event.clientY > rect.top + rect.height / 2 ? index + 1 : index);
+ }}
+ onDrop={(event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ commitDrop();
+ }}
+ onDragEnd={() => {
+ setDragIndex(null);
+ setDropIndex(null);
+ }}
+ className={cn(
+ "astw:relative astw:flex astw:h-9 astw:items-center astw:gap-1.5 astw:rounded-md astw:px-1.5 astw:hover:bg-accent",
+ dragIndex === index && "astw:opacity-40",
+ )}
+ >
+ {dragIndex !== null && dropIndex === index && (
+
+ )}
+ {dragIndex !== null &&
+ dropIndex === rows.length &&
+ index === rows.length - 1 && (
+
+ )}
+
+ toggleHidden(section.id)}
+ />
+
+ {/* Keyboard reorder — drag alone is mouse-only. */}
+ {
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ move(index, index - 1);
+ }
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ move(index, index + 1);
+ }
+ }}
+ className="astw:shrink-0 astw:cursor-grab astw:rounded astw:text-muted-foreground astw:hover:text-foreground astw:active:cursor-grabbing astw:focus-visible:outline-2 astw:focus-visible:outline-ring"
+ >
+
+
+
+
+ {section.label}
+ {clearsFilter && (
+
+ )}
+
+
+ {section.control === "checkbox" ? (
+ ({ value: item.value, label: item.label })}
+ value={sortItems.find((item) => item.value === current) ?? null}
+ onValueChange={(next) => {
+ if (next && !Array.isArray(next)) {
+ layout.onChange({
+ ...value,
+ sort: { ...value.sort, [section.id]: next.value },
+ });
+ }
+ }}
+ aria-label={t("sortOptions", { section: name })}
+ className="astw:w-[8.5rem] astw:shrink-0 astw:[&_[data-slot=select-trigger]]:h-7 astw:[&_[data-slot=select-trigger]]:text-xs"
+ />
+ ) : (
+ // Nothing to sort: a text box has no options, and a
+ // radio's states are a curated vocabulary.
+
+ —
+
+ )}
+
+ );
+ })}
+
+
+ {layout.onReset && (
+
+ layout.onReset?.()}
+ >
+
+ {t("resetLayout")}
+
+
+ )}
+
+
+
+
+ );
+}
+Settings.displayName = "FilterRail.Settings";
+
+export { Settings };
+export type { SettingsProps };
diff --git a/packages/core/src/components/filter-rail/storage.ts b/packages/core/src/components/filter-rail/storage.ts
new file mode 100644
index 00000000..63102e1b
--- /dev/null
+++ b/packages/core/src/components/filter-rail/storage.ts
@@ -0,0 +1,34 @@
+// SSR-safe localStorage helpers. Every failure (private window, disabled or
+// full storage, malformed JSON) degrades to in-memory state rather than throwing.
+
+export function readStorage(key: string, parse: (raw: unknown) => T, fallback: T): T {
+ if (typeof window === "undefined") return fallback;
+ try {
+ const raw = window.localStorage.getItem(key);
+ if (raw === null) return fallback;
+ return parse(JSON.parse(raw));
+ } catch {
+ return fallback;
+ }
+}
+
+export function writeStorage(key: string, value: unknown): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.setItem(key, JSON.stringify(value));
+ } catch {
+ // The session keeps working in memory.
+ }
+}
+
+export function removeStorage(key: string): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.removeItem(key);
+ } catch {
+ // As above.
+ }
+}
+
+export const stringArray = (value: unknown): string[] =>
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
diff --git a/packages/core/src/components/filter-rail/types.ts b/packages/core/src/components/filter-rail/types.ts
new file mode 100644
index 00000000..9fb54cf5
--- /dev/null
+++ b/packages/core/src/components/filter-rail/types.ts
@@ -0,0 +1,282 @@
+import type { ReactNode } from "react";
+import type { CollectionControl, Filter, SelectOption, SortState } from "@/types/collection";
+
+// ─── Options ─────────────────────────────────────────────────────────────────
+
+/**
+ * One choice in a checkbox or radio section.
+ *
+ * Widens `SelectOption` (`{ value, label }`) and stays assignable **from** it,
+ * so an existing `column.filter.options` array passes straight in.
+ *
+ * `value` is a string on purpose: `useURLCollectionVariables` stringifies array
+ * members when it writes the URL, so a numeric `in` filter would come back as
+ * `["1","2"]` and match nothing after a reload.
+ */
+export type FacetOption = SelectOption & {
+ /** Leading adornment — a colour swatch, a status dot. */
+ icon?: ReactNode;
+ /** Rendered but not selectable, with this as the reason (shown as a tooltip). */
+ disabledReason?: string;
+ /** A data-quality note shown as a tooltip, e.g. "Legacy spelling". */
+ note?: string;
+};
+
+/**
+ * How a checkbox section's options are ordered.
+ *
+ * `"given"` (default) keeps the authored order — a curated vocabulary
+ * (XS · S · M · L) must never be reordered. `"baselineCount"` is "most common
+ * first", stable across clicks. `"liveCount"` reorders rows whenever another
+ * section changes; offered, but rarely what you want.
+ */
+export type OptionSort =
+ | "given"
+ | "label"
+ | "baselineCount"
+ | "liveCount"
+ | ((a: FacetOption, b: FacetOption, counts: { a: number; b: number }) => number);
+
+// ─── Sections ────────────────────────────────────────────────────────────────
+
+type SectionBase = {
+ /** Stable and unique within the rail. Keys counts, layout and a11y ids. */
+ id: string;
+ /** The section heading — the word the business uses, not the field name. */
+ label: ReactNode;
+ /** One muted line under the heading. */
+ hint?: ReactNode;
+};
+
+type FieldSectionBase = SectionBase & {
+ field: TField;
+ /** Show the per-section Clear. Default `true`. */
+ clearable?: boolean;
+};
+
+/** Free text with one fixed operator. */
+export type TextSection = FieldSectionBase & {
+ control: "text";
+ operator: "contains" | "hasPrefix" | "eq";
+ placeholder?: string;
+ /** Default 250 ms. Every commit resets pagination. */
+ debounceMs?: number;
+ /** Below this length the filter is removed instead of applied. Default 1. */
+ minLength?: number;
+};
+
+/** Mutually exclusive choices — one filter, or none. */
+export type RadioSection = FieldSectionBase & {
+ control: "radio";
+ operator?: "eq" | "ne";
+ options: readonly FacetOption[];
+ /** The "no constraint" row. Default "Any"; `null` removes it. */
+ anyLabel?: ReactNode | null;
+};
+
+/** Multi-select — the `in` (any of) or `nin` (none of) operator. */
+export type CheckboxSection = FieldSectionBase & {
+ control: "checkbox";
+ operator?: "in" | "nin";
+ options: readonly FacetOption[];
+ sort?: OptionSort;
+ /**
+ * Options shown before "Show N more". Default 8, `false` shows everything.
+ * A selected option is never truncated away.
+ */
+ truncate?: number | false;
+ /** Option values held at the top, in this order. */
+ pinned?: readonly string[];
+ /** Above this many options, show a search box. Default 12, `false` never. */
+ searchThreshold?: number | false;
+};
+
+/** A single `YYYY-MM-DD` date with one fixed operator. */
+export type DateSection = FieldSectionBase & {
+ control: "date";
+ operator: "eq" | "gte" | "lte";
+ /** `YYYY-MM-DD` */
+ min?: string;
+ /** `YYYY-MM-DD` */
+ max?: string;
+};
+
+/** A from/to date pair, committed as `between` `{ min, max }`. */
+export type DateRangeSection = FieldSectionBase & {
+ control: "dateRange";
+ min?: string;
+ max?: string;
+ /** Named spans offered above the picker ("Last 30 days"). Click again to clear. */
+ presets?: readonly { label: string; from: string; to: string }[];
+};
+
+/** A numeric from/to pair: `between` `{ min, max }`, or `gte` / `lte` when half-open. */
+export type NumberRangeSection = FieldSectionBase & {
+ control: "numberRange";
+ /** Input bounds and placeholders. One end typed alone commits `gte` / `lte`. */
+ min?: number;
+ max?: number;
+ step?: number;
+ /** Rendered after the inputs — "units", "$". */
+ unit?: ReactNode;
+};
+
+/** True / false with a third "no constraint" state. */
+export type BooleanSection = FieldSectionBase & {
+ control: "boolean";
+ operator?: "eq" | "ne";
+ /** Default "Yes". */
+ trueLabel?: ReactNode;
+ /** Default "No". */
+ falseLabel?: ReactNode;
+ /** Default "Any"; `null` removes it. */
+ anyLabel?: ReactNode | null;
+};
+
+/** What a custom section's `render` receives. */
+export type CustomSectionContext = {
+ control: CollectionControl;
+ /** The current filter on `field`, if any. */
+ getFilter: (field: TField) => Filter | undefined;
+ /** Replace (or with `null`, remove) the filter on `field`. */
+ setFilter: (field: TField, next: Omit, "field"> | null) => void;
+};
+
+/**
+ * Anything the built-in controls do not cover. Declares the `fields` it writes
+ * so Clear all, Clear and the settings panel still treat it as rail-owned.
+ */
+export type CustomSection = SectionBase & {
+ control: "custom";
+ fields: readonly TField[];
+ clearable?: boolean;
+ render: (context: CustomSectionContext) => ReactNode;
+};
+
+export type FilterRailSection =
+ | TextSection
+ | RadioSection
+ | CheckboxSection
+ | DateSection
+ | DateRangeSection
+ | NumberRangeSection
+ | BooleanSection
+ | CustomSection;
+
+// ─── Counts ──────────────────────────────────────────────────────────────────
+
+/**
+ * Supplied by the consumer — the rail never sees rows. Compute in memory with
+ * `useFilterRailCounts`, or return the same shape from a backend aggregation.
+ */
+export type FacetCounts = {
+ /**
+ * `live[sectionId][optionValue]`: rows matching every **other** section's
+ * filter plus this option. `undefined` for a section means unknown — no
+ * numbers, nothing disabled.
+ */
+ live: Record | undefined>;
+ /** The same with no rail filters applied. Drives `sort: "baselineCount"`. */
+ baseline?: Record | undefined>;
+ /** Rows matching the full current filter set. */
+ total?: number;
+ /** Rows if section `id` alone were cleared. Drives the empty-result hint. */
+ withoutSection?: Record;
+};
+
+// ─── Layout (user preferences) ───────────────────────────────────────────────
+
+/** The option sorts a user may pick in `FilterRail.Settings`. */
+export type UserOptionSort = "given" | "label" | "baselineCount";
+
+/**
+ * The user's override of the authored section order, visibility and option
+ * sort. Kept separate from `sections` so a new authored section needs no
+ * migration of anyone's saved layout.
+ */
+export type RailLayout = {
+ /** Section ids, top to bottom. Unlisted ids follow in authored order. */
+ order: readonly string[];
+ /** Section ids the user has switched off. */
+ hidden: readonly string[];
+ /** Per-section override of the authored `sort`. Checkbox sections only. */
+ sort: Readonly>;
+};
+
+export type RailLayoutBinding = {
+ value: RailLayout;
+ onChange: (next: RailLayout) => void;
+ /** Back to the authored defaults. Omit and no Reset is offered. */
+ onReset?: () => void;
+};
+
+// ─── Saved views ─────────────────────────────────────────────────────────────
+
+/** Filters plus the table's presentation — everything that makes a view. */
+export type ViewState = {
+ filters: Filter[];
+ sort: SortState[];
+ pageSize: number;
+ /** Column ids, in display order. */
+ columnOrder: string[];
+ /** Column ids the user has hidden. */
+ hidden: string[];
+ /** Column id → the edge it is frozen to. */
+ pinned: Record;
+};
+
+export type SavedView = {
+ id: string;
+ name: string;
+ createdAt: string;
+ state: ViewState;
+};
+
+/** What `FilterRail.SavedViews` renders. `useSavedViews` returns one. */
+export type SavedViewBinding = {
+ items: readonly SavedView[];
+ /** The view the current state matches, or `null`. */
+ activeId: string | null;
+ /** Whether saving now would achieve something — drives the Save button. */
+ dirty: boolean;
+ onSave: (name: string) => void;
+ onApply: (view: SavedView) => void;
+ onDelete: (view: SavedView) => void;
+ /** One-line summary under each name — "3 filters · 2 hidden · 25/page". */
+ describe?: (view: SavedView) => string;
+};
+
+/** Where saved views live. Default: localStorage. Swap for a backend store. */
+export type SavedViewStorage = {
+ load: () => SavedView[];
+ save: (views: SavedView[]) => void;
+};
+
+// ─── Parts ───────────────────────────────────────────────────────────────────
+
+export type FilterRailRootProps = {
+ /**
+ * The same `control` passed to `useDataTable`. A prop rather than context:
+ * the rail sits in a different `Layout.Column`, outside `DataTable.Root`.
+ */
+ control: CollectionControl;
+ /** Top to bottom. Any mix of section types. */
+ sections: readonly FilterRailSection[];
+ /** Absent ⇒ no numbers, nothing disabled. */
+ counts?: FacetCounts;
+ /** User layout (order / hidden / sort). Required for `FilterRail.Settings`. */
+ layout?: RailLayoutBinding;
+ /**
+ * What to do with an option whose live count is zero. Default `"disable"`.
+ * A selected option is never disabled.
+ */
+ zeroBehavior?: "disable" | "show";
+ /** 32px rows (default) or 40px. */
+ density?: "compact" | "comfortable";
+ /** Accessible name of the rail landmark. Default "Filters". */
+ "aria-label"?: string;
+ /** Fires after the rail applies a change. */
+ onFilterChange?: (next: Filter[], changed: readonly TField[]) => void;
+ className?: string;
+ children: ReactNode;
+};
diff --git a/packages/core/src/components/filter-rail/use-filter-rail-counts.test.ts b/packages/core/src/components/filter-rail/use-filter-rail-counts.test.ts
new file mode 100644
index 00000000..f6dad041
--- /dev/null
+++ b/packages/core/src/components/filter-rail/use-filter-rail-counts.test.ts
@@ -0,0 +1,167 @@
+import { describe, expect, it } from "vitest";
+import { renderHook } from "@testing-library/react";
+import type { Filter } from "@/types/collection";
+import { matchesOperator } from "./operators";
+import { computeFacetCounts, useFilterRailCounts } from "./use-filter-rail-counts";
+import type { FilterRailSection } from "./types";
+
+type Row = {
+ id: number;
+ category: string;
+ color: string;
+ price: number;
+ inStock: boolean;
+ tags?: string[];
+ warehouse?: string;
+};
+
+// 🧪 Dummy Data: six products
+const rows: Row[] = [
+ { id: 1, category: "apparel", color: "red", price: 10, inStock: true, warehouse: "tokyo" },
+ { id: 2, category: "apparel", color: "blue", price: 20, inStock: false, warehouse: "tokyo" },
+ { id: 3, category: "shoes", color: "red", price: 30, inStock: true, warehouse: "osaka" },
+ { id: 4, category: "shoes", color: "blue", price: 40, inStock: true, warehouse: "tokyo" },
+ { id: 5, category: "bags", color: "red", price: 50, inStock: false, warehouse: "osaka" },
+ { id: 6, category: "bags", color: "green", price: 60, inStock: true, warehouse: "tokyo" },
+];
+
+const sections: FilterRailSection[] = [
+ { id: "category", label: "Category", control: "checkbox", field: "category", options: [] },
+ { id: "color", label: "Color", control: "checkbox", field: "color", options: [] },
+ { id: "stock", label: "In stock", control: "boolean", field: "inStock" },
+];
+
+const inFilter = (field: string, value: string[]): Filter => ({ field, operator: "in", value });
+
+describe("computeFacetCounts", () => {
+ it("counts every row with no filters", () => {
+ const counts = computeFacetCounts(rows, sections, []);
+ expect(counts.total).toBe(6);
+ expect(counts.live.category).toEqual({ apparel: 2, shoes: 2, bags: 2 });
+ expect(counts.live.color).toEqual({ red: 3, blue: 2, green: 1 });
+ // A boolean section has no options, so nothing is tallied for it.
+ expect(counts.live.stock).toEqual({});
+ expect(counts.withoutSection).toEqual({ category: 6, color: 6, stock: 6 });
+ expect(counts.baseline).toBeUndefined();
+ });
+
+ it("excludes a section's own filter from its live counts", () => {
+ const counts = computeFacetCounts(rows, sections, [inFilter("category", ["apparel"])]);
+ // Category counts ignore the category filter — ticking one box does not zero its neighbours.
+ expect(counts.live.category).toEqual({ apparel: 2, shoes: 2, bags: 2 });
+ // Color counts are within apparel only.
+ expect(counts.live.color).toEqual({ red: 1, blue: 1 });
+ expect(counts.total).toBe(2);
+ });
+
+ it("does not count a row failing two sections anywhere", () => {
+ const counts = computeFacetCounts(rows, sections, [
+ inFilter("category", ["apparel"]),
+ inFilter("color", ["red"]),
+ ]);
+ // Row 1 passes both; rows 2 (blue apparel) and 3/5 (red non-apparel) fail one;
+ // rows 4 (blue shoes) and 6 (green bags) fail both and count nowhere.
+ expect(counts.total).toBe(1);
+ expect(counts.live.category).toEqual({ apparel: 1, shoes: 1, bags: 1 });
+ expect(counts.live.color).toEqual({ red: 1, blue: 1 });
+ expect(counts.withoutSection).toEqual({ category: 3, color: 2, stock: 1 });
+ });
+
+ it("reports withoutSection for an empty result", () => {
+ const counts = computeFacetCounts(rows, sections, [
+ inFilter("category", ["bags"]),
+ inFilter("color", ["blue"]),
+ ]);
+ expect(counts.total).toBe(0);
+ // Clearing category → blue rows (2, 4); clearing color → bag rows (5, 6).
+ expect(counts.withoutSection?.category).toBe(2);
+ expect(counts.withoutSection?.color).toBe(2);
+ });
+
+ it("computes baseline counts ignoring rail filters", () => {
+ const counts = computeFacetCounts(rows, sections, [inFilter("category", ["apparel"])], {
+ baseline: true,
+ });
+ expect(counts.baseline?.color).toEqual({ red: 3, blue: 2, green: 1 });
+ expect(counts.baseline?.category).toEqual({ apparel: 2, shoes: 2, bags: 2 });
+ });
+
+ it("counts each member of an array-valued cell", () => {
+ const tagged = [{ tags: ["new", "sale"] }, { tags: ["sale"] }, { tags: ["clearance"] }];
+ const tagSections: FilterRailSection[] = [
+ { id: "tags", label: "Tags", control: "checkbox", field: "tags", options: [] },
+ ];
+ const counts = computeFacetCounts(tagged, tagSections, [inFilter("tags", ["new"])]);
+ expect(counts.live.tags).toEqual({ new: 1, sale: 2, clearance: 1 });
+ expect(counts.total).toBe(1);
+ });
+
+ it("ANDs base filters into every count", () => {
+ const base: Filter[] = [{ field: "warehouse", operator: "eq", value: "tokyo" }];
+ const counts = computeFacetCounts(rows, sections, [], { base, baseline: true });
+ expect(counts.total).toBe(4);
+ expect(counts.live.category).toEqual({ apparel: 2, shoes: 1, bags: 1 });
+ // Base filters also apply to the baseline.
+ expect(counts.baseline?.category).toEqual({ apparel: 2, shoes: 1, bags: 1 });
+ });
+
+ it('coerces a string "true" boolean filter (URL round-trip)', () => {
+ const counts = computeFacetCounts(rows, sections, [
+ { field: "inStock", operator: "eq", value: "true" },
+ ]);
+ expect(counts.total).toBe(4);
+ expect(counts.live.category).toEqual({ apparel: 1, shoes: 2, bags: 1 });
+ });
+
+ it("honours a custom getValue", () => {
+ const nested = [{ attrs: { category: "a" } }, { attrs: { category: "b" } }];
+ const counts = computeFacetCounts(nested, [sections[0]!], [inFilter("category", ["a"])], {
+ getValue: (row, field) => (row.attrs as Record)[field],
+ });
+ expect(counts.live.category).toEqual({ a: 1, b: 1 });
+ expect(counts.total).toBe(1);
+ });
+});
+
+describe("useFilterRailCounts", () => {
+ it("memoises on its inputs", () => {
+ const filters: Filter[] = [];
+ const { result, rerender } = renderHook(() => useFilterRailCounts(rows, sections, filters));
+ const first = result.current;
+ rerender();
+ expect(result.current).toBe(first);
+ expect(first.total).toBe(6);
+ });
+});
+
+describe("matchesOperator", () => {
+ it.each([
+ [true, "eq", "true", true],
+ [false, "eq", "true", false],
+ [true, "ne", "false", true],
+ [5, "eq", "5", true],
+ [5, "gt", 4, true],
+ [5, "gte", 5, true],
+ [5, "lt", 5, false],
+ [5, "lte", 5, true],
+ [10, "gt", 9, true],
+ ["2026-01-10", "gte", "2026-01-09", true],
+ [5, "between", { min: 1, max: 5 }, true],
+ [6, "between", { min: 1, max: 5 }, false],
+ [6, "between", { min: 1 }, true],
+ ["a", "in", ["a", "b"], true],
+ [1, "in", ["1"], true],
+ ["c", "nin", ["a", "b"], true],
+ [["x", "y"], "in", ["y"], true],
+ [["x", "y"], "nin", ["y"], false],
+ ["Hello", "contains", "ell", true],
+ ["Hello", "notContains", "ELL", false],
+ ["Hello", "hasPrefix", "he", true],
+ ["Hello", "notHasPrefix", "he", false],
+ ["Hello", "hasSuffix", "LO", true],
+ ["Hello", "notHasSuffix", "lo", false],
+ ["anything", "regex", ".*", true],
+ ] as const)("%j %s %j → %s", (value, operator, operand, expected) => {
+ expect(matchesOperator(value, operator, operand)).toBe(expected);
+ });
+});
diff --git a/packages/core/src/components/filter-rail/use-filter-rail-counts.ts b/packages/core/src/components/filter-rail/use-filter-rail-counts.ts
new file mode 100644
index 00000000..9f89be07
--- /dev/null
+++ b/packages/core/src/components/filter-rail/use-filter-rail-counts.ts
@@ -0,0 +1,133 @@
+import { useMemo } from "react";
+import type { Filter } from "@/types/collection";
+import { hasOptions, sectionFields } from "./apply-layout";
+import { matchesOperator } from "./operators";
+import type { FacetCounts, FilterRailSection } from "./types";
+
+export type UseFilterRailCountsOptions = {
+ /** Filters the rail does not own (a scope, a search box), ANDed into every count. */
+ base?: readonly Filter[];
+ /** Also compute unfiltered counts. Needed for `sort: "baselineCount"`. */
+ baseline?: boolean;
+ /**
+ * Read a field off a row. Default `row[field]`. (Not `valueOf` — every object
+ * inherits `Object.prototype.valueOf`, so a `?? fallback` would never fire.)
+ */
+ getValue?: (row: TRow, field: string) => unknown;
+};
+
+/**
+ * Standard faceting over rows held in memory.
+ *
+ * An option's count in section X is computed with every **other** section's
+ * filter applied but not X's own, so it answers "what would I get if I added
+ * this" — checking one box never zeroes out its neighbours.
+ *
+ * One pass: each row records which sections it fails. Failing two or more, it
+ * counts for nothing; failing exactly one, it counts toward that section only;
+ * failing none, it counts everywhere and toward the total.
+ *
+ * For a server-side collection, return the same `FacetCounts` shape from an
+ * aggregation query instead.
+ */
+export function computeFacetCounts(
+ rows: readonly TRow[],
+ sections: readonly FilterRailSection[],
+ filters: readonly Filter[],
+ options?: UseFilterRailCountsOptions,
+): FacetCounts {
+ const base = options?.base;
+ const wantBaseline = options?.baseline ?? false;
+ const getValue =
+ options?.getValue ?? ((row: TRow, field: string) => (row as Record)[field]);
+
+ const sectionFilters = sections.map((section) => {
+ const fields = new Set(sectionFields(section));
+ return filters.filter((filter) => fields.has(filter.field));
+ });
+
+ const live: Record> = {};
+ const baseline: Record> = {};
+ const withoutSection: Record = {};
+ for (const section of sections) {
+ live[section.id] = {};
+ if (wantBaseline) baseline[section.id] = {};
+ withoutSection[section.id] = 0;
+ }
+
+ const passes = (row: TRow, list: readonly Filter[]) =>
+ list.every((filter) =>
+ matchesOperator(getValue(row, filter.field), filter.operator, filter.value),
+ );
+
+ let total = 0;
+ for (const row of rows) {
+ if (base?.length && !passes(row, base)) continue;
+
+ // Only option sections are tallied — a date or range has nothing to count.
+ const contributions = sections.map((section) => {
+ if (!hasOptions(section)) return [];
+ const raw = getValue(row, section.field);
+ return Array.isArray(raw) ? raw.map(String) : [String(raw)];
+ });
+
+ const add = (index: number, target: Record>) => {
+ const bucket = target[sections[index]!.id]!;
+ for (const value of contributions[index]!) bucket[value] = (bucket[value] ?? 0) + 1;
+ };
+
+ if (wantBaseline) sections.forEach((_, index) => add(index, baseline));
+
+ const failed: number[] = [];
+ for (let index = 0; index < sections.length; index += 1) {
+ const list = sectionFilters[index]!;
+ if (list.length > 0 && !passes(row, list)) {
+ failed.push(index);
+ if (failed.length > 1) break;
+ }
+ }
+
+ if (failed.length > 1) continue;
+ if (failed.length === 1) {
+ const index = failed[0]!;
+ add(index, live);
+ withoutSection[sections[index]!.id]! += 1;
+ continue;
+ }
+
+ total += 1;
+ sections.forEach((section, index) => {
+ add(index, live);
+ withoutSection[section.id]! += 1;
+ });
+ }
+
+ return { live, ...(wantBaseline ? { baseline } : {}), total, withoutSection };
+}
+
+/**
+ * Memoised `computeFacetCounts` for `FilterRail.Root`'s `counts` prop.
+ *
+ * @example
+ * ```tsx
+ * const counts = useFilterRailCounts(products, sections, control.filters, { baseline: true });
+ * …
+ * ```
+ */
+export function useFilterRailCounts(
+ rows: readonly TRow[],
+ sections: readonly FilterRailSection[],
+ filters: readonly Filter[],
+ options?: UseFilterRailCountsOptions,
+): FacetCounts {
+ const { base, baseline, getValue } = options ?? {};
+ return useMemo(
+ () =>
+ computeFacetCounts(rows, sections as readonly FilterRailSection[], filters, {
+ base,
+ baseline,
+ getValue,
+ }),
+ [rows, sections, filters, base, baseline, getValue],
+ );
+}
diff --git a/packages/core/src/components/filter-rail/use-filter-rail-layout.test.ts b/packages/core/src/components/filter-rail/use-filter-rail-layout.test.ts
new file mode 100644
index 00000000..d73765a6
--- /dev/null
+++ b/packages/core/src/components/filter-rail/use-filter-rail-layout.test.ts
@@ -0,0 +1,79 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { act, cleanup, renderHook } from "@testing-library/react";
+import { useFilterRailLayout } from "./use-filter-rail-layout";
+import type { RailLayout } from "./types";
+
+const KEY = "as:filter-rail-layout:v1:products";
+const EMPTY: RailLayout = { order: [], hidden: [], sort: {} };
+
+afterEach(() => {
+ cleanup();
+ window.localStorage.clear();
+});
+
+describe("useFilterRailLayout", () => {
+ it("starts empty and does not write on mount", () => {
+ const { result } = renderHook(() => useFilterRailLayout("products"));
+ expect(result.current.value).toEqual(EMPTY);
+ expect(window.localStorage.getItem(KEY)).toBeNull();
+ });
+
+ it("reads a stored layout", () => {
+ const stored: RailLayout = {
+ order: ["status", "category"],
+ hidden: ["price"],
+ sort: { category: "label" },
+ };
+ window.localStorage.setItem(KEY, JSON.stringify(stored));
+ const { result } = renderHook(() => useFilterRailLayout("products"));
+ expect(result.current.value).toEqual(stored);
+ });
+
+ it("writes on change", () => {
+ const { result } = renderHook(() => useFilterRailLayout("products"));
+ const next: RailLayout = { order: ["b", "a"], hidden: ["c"], sort: { a: "baselineCount" } };
+ act(() => result.current.onChange(next));
+ expect(result.current.value).toEqual(next);
+ expect(JSON.parse(window.localStorage.getItem(KEY)!)).toEqual(next);
+ });
+
+ it("ignores malformed JSON", () => {
+ window.localStorage.setItem(KEY, "{oops");
+ const { result } = renderHook(() => useFilterRailLayout("products"));
+ expect(result.current.value).toEqual(EMPTY);
+ });
+
+ it("drops invalid members of a stored layout", () => {
+ window.localStorage.setItem(
+ KEY,
+ JSON.stringify({
+ order: ["a", 1, null],
+ hidden: "nope",
+ sort: { a: "label", b: "liveCount" },
+ }),
+ );
+ const { result } = renderHook(() => useFilterRailLayout("products"));
+ expect(result.current.value).toEqual({ order: ["a"], hidden: [], sort: { a: "label" } });
+ });
+
+ it("onReset clears state and storage", () => {
+ window.localStorage.setItem(KEY, JSON.stringify({ order: ["a"], hidden: [], sort: {} }));
+ const { result } = renderHook(() => useFilterRailLayout("products"));
+ act(() => result.current.onReset?.());
+ expect(result.current.value).toEqual(EMPTY);
+ expect(window.localStorage.getItem(KEY)).toBeNull();
+ });
+
+ it("re-hydrates when the key changes", () => {
+ window.localStorage.setItem(
+ "as:filter-rail-layout:v1:orders",
+ JSON.stringify({ order: ["x"], hidden: [], sort: {} }),
+ );
+ const { result, rerender } = renderHook(({ key }) => useFilterRailLayout(key), {
+ initialProps: { key: "products" },
+ });
+ expect(result.current.value).toEqual(EMPTY);
+ rerender({ key: "orders" });
+ expect(result.current.value.order).toEqual(["x"]);
+ });
+});
diff --git a/packages/core/src/components/filter-rail/use-filter-rail-layout.ts b/packages/core/src/components/filter-rail/use-filter-rail-layout.ts
new file mode 100644
index 00000000..ceeed197
--- /dev/null
+++ b/packages/core/src/components/filter-rail/use-filter-rail-layout.ts
@@ -0,0 +1,56 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { EMPTY_RAIL_LAYOUT } from "./apply-layout";
+import { readStorage, removeStorage, stringArray, writeStorage } from "./storage";
+import type { RailLayout, RailLayoutBinding, UserOptionSort } from "./types";
+
+const STORAGE_PREFIX = "as:filter-rail-layout:v1:";
+const SORTS = new Set(["given", "label", "baselineCount"]);
+
+/** Read defensively — a rail that throws on load is worse than one that forgets. */
+const parseLayout = (raw: unknown): RailLayout => {
+ if (!raw || typeof raw !== "object") return EMPTY_RAIL_LAYOUT;
+ const value = raw as Record;
+ const sort: Record = {};
+ if (value.sort && typeof value.sort === "object") {
+ for (const [id, mode] of Object.entries(value.sort)) {
+ if (SORTS.has(mode as UserOptionSort)) sort[id] = mode as UserOptionSort;
+ }
+ }
+ return { order: stringArray(value.order), hidden: stringArray(value.hidden), sort };
+};
+
+/**
+ * The user's section order, visibility and option sort, persisted to
+ * localStorage under `storageKey`. Pass the result to `FilterRail.Root`'s
+ * `layout` prop to enable `FilterRail.Settings`.
+ *
+ * For a team-shared layout, build a `{ value, onChange, onReset }` binding over
+ * your own store instead — the rail does not care where it lives.
+ */
+export function useFilterRailLayout(storageKey: string): RailLayoutBinding {
+ const key = STORAGE_PREFIX + storageKey;
+ const [value, setValue] = useState(() =>
+ readStorage(key, parseLayout, EMPTY_RAIL_LAYOUT),
+ );
+
+ // Re-hydrate when the key changes (a different screen reusing the component).
+ useEffect(() => {
+ setValue(readStorage(key, parseLayout, EMPTY_RAIL_LAYOUT));
+ }, [key]);
+
+ // Written in the setter, not an effect, so mount never clobbers storage.
+ const onChange = useCallback(
+ (next: RailLayout) => {
+ setValue(next);
+ writeStorage(key, next);
+ },
+ [key],
+ );
+
+ const onReset = useCallback(() => {
+ setValue(EMPTY_RAIL_LAYOUT);
+ removeStorage(key);
+ }, [key]);
+
+ return useMemo(() => ({ value, onChange, onReset }), [value, onChange, onReset]);
+}
diff --git a/packages/core/src/components/filter-rail/use-saved-views.test.tsx b/packages/core/src/components/filter-rail/use-saved-views.test.tsx
new file mode 100644
index 00000000..3d1bd33a
--- /dev/null
+++ b/packages/core/src/components/filter-rail/use-saved-views.test.tsx
@@ -0,0 +1,354 @@
+// Loaded first to break the pre-existing hooks/i18n ⇄ appshell-context import
+// cycle, which otherwise leaves `defineI18nLabels` undefined when a test's
+// first import is a module that pulls in `@/hooks/i18n` directly.
+import "@/contexts/appshell-context";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, cleanup, renderHook } from "@testing-library/react";
+import type { CollectionControl, Filter, SortState } from "@/types/collection";
+import type { UseDataTableReturn } from "../data-table/types";
+import { applyView, captureView, isSameView, useSavedViews } from "./use-saved-views";
+import type { SavedView, SavedViewStorage, ViewState } from "./types";
+
+type AnyTable = UseDataTableReturn>;
+
+afterEach(() => {
+ cleanup();
+ window.localStorage.clear();
+});
+
+// ─── Fake table ──────────────────────────────────────────────────────────────
+
+type TableState = {
+ filters: Filter[];
+ sort: SortState[];
+ pageSize: number;
+ columnOrder: string[];
+ hidden: string[];
+ pinned: Record;
+};
+
+// 🧪 Dummy Data: a three-column table in its default state
+const defaultState = (): TableState => ({
+ filters: [],
+ sort: [],
+ pageSize: 20,
+ columnOrder: ["sku", "name", "price"],
+ hidden: [],
+ pinned: {},
+});
+
+/** A `UseDataTableReturn` stand-in: only what capture / apply touch, as spies. */
+function fakeTable(state: TableState, log: string[] = []) {
+ const control = {
+ filters: state.filters,
+ sortStates: state.sort,
+ pageSize: state.pageSize,
+ setFilters: vi.fn((next: Filter[]) => log.push(`setFilters:${next.length}`)),
+ clearSort: vi.fn(() => log.push("clearSort")),
+ setSort: vi.fn((field: string, direction?: string) =>
+ log.push(`setSort:${field}:${direction}`),
+ ),
+ setPageSize: vi.fn((size: number) => log.push(`setPageSize:${size}`)),
+ resetPage: vi.fn(() => log.push("resetPage")),
+ };
+ const table = {
+ control: control as unknown as CollectionControl,
+ sortStates: state.sort,
+ pageSize: state.pageSize,
+ columnOrder: state.columnOrder,
+ pinnedColumns: state.pinned,
+ isColumnVisible: vi.fn((key: string) => !state.hidden.includes(key)),
+ toggleColumn: vi.fn((key: string) => log.push(`toggleColumn:${key}`)),
+ setColumnOrder: vi.fn((keys: string[]) => log.push(`setColumnOrder:${keys.join(",")}`)),
+ setPin: vi.fn((key: string, side: string | null) => log.push(`setPin:${key}:${side}`)),
+ };
+ return { table: table as unknown as AnyTable, control, raw: table };
+}
+
+const viewState = (overrides: Partial = {}): ViewState => ({
+ filters: [],
+ sort: [],
+ pageSize: 20,
+ columnOrder: ["sku", "name", "price"],
+ hidden: [],
+ pinned: {},
+ ...overrides,
+});
+
+// ─── captureView ─────────────────────────────────────────────────────────────
+
+describe("captureView", () => {
+ it("reads filters, sort, page size and column layout", () => {
+ const { table } = fakeTable({
+ filters: [{ field: "category", operator: "in", value: ["apparel"] }],
+ sort: [{ field: "price", direction: "Desc" }],
+ pageSize: 50,
+ columnOrder: ["name", "sku", "price"],
+ hidden: ["price"],
+ pinned: { sku: "left" },
+ });
+ expect(captureView(table)).toEqual({
+ filters: [{ field: "category", operator: "in", value: ["apparel"] }],
+ sort: [{ field: "price", direction: "Desc" }],
+ pageSize: 50,
+ columnOrder: ["name", "sku", "price"],
+ hidden: ["price"],
+ pinned: { sku: "left" },
+ });
+ });
+
+ it("falls back to the table's sortStates without a control", () => {
+ const { table, raw } = fakeTable({
+ ...defaultState(),
+ sort: [{ field: "sku", direction: "Asc" }],
+ });
+ (raw as { control: unknown }).control = undefined;
+ const view = captureView(table);
+ expect(view.filters).toEqual([]);
+ expect(view.sort).toEqual([{ field: "sku", direction: "Asc" }]);
+ });
+});
+
+// ─── applyView ───────────────────────────────────────────────────────────────
+
+describe("applyView", () => {
+ it("clears sort before re-applying it in sequence", () => {
+ const log: string[] = [];
+ const { table } = fakeTable(defaultState(), log);
+ applyView(
+ table,
+ viewState({
+ filters: [{ field: "category", operator: "in", value: ["apparel"] }],
+ sort: [
+ { field: "price", direction: "Desc" },
+ { field: "name", direction: "Asc" },
+ ],
+ pageSize: 50,
+ }),
+ );
+ const controlCalls = log.filter((entry) =>
+ /^(setFilters|clearSort|setSort|setPageSize|resetPage)/.test(entry),
+ );
+ expect(controlCalls).toEqual([
+ "setFilters:1",
+ "clearSort",
+ "setSort:price:Desc",
+ "setSort:name:Asc",
+ "setPageSize:50",
+ "resetPage",
+ ]);
+ });
+
+ it("skips setPageSize for a zero page size", () => {
+ const { table, control } = fakeTable(defaultState());
+ applyView(table, viewState({ pageSize: 0 }));
+ expect(control.setPageSize).not.toHaveBeenCalled();
+ });
+
+ it("keeps only existing columns in order and appends new ones", () => {
+ const { table, raw } = fakeTable(defaultState());
+ applyView(table, viewState({ columnOrder: ["price", "gone", "sku"] }));
+ expect(raw.setColumnOrder).toHaveBeenCalledWith(["price", "sku", "name"]);
+ });
+
+ it("toggles only columns whose visibility differs", () => {
+ const { table, raw } = fakeTable({ ...defaultState(), hidden: ["name"] });
+ applyView(table, viewState({ hidden: ["price", "name"] }));
+ expect(raw.toggleColumn).toHaveBeenCalledTimes(1);
+ expect(raw.toggleColumn).toHaveBeenCalledWith("price");
+
+ const second = fakeTable({ ...defaultState(), hidden: ["name"] });
+ applyView(second.table, viewState({ hidden: [] }));
+ expect(second.raw.toggleColumn).toHaveBeenCalledTimes(1);
+ expect(second.raw.toggleColumn).toHaveBeenCalledWith("name");
+ });
+
+ it("resets pins not in the view with null", () => {
+ const { table, raw } = fakeTable({ ...defaultState(), pinned: { name: "right" } });
+ applyView(table, viewState({ pinned: { sku: "left" } }));
+ expect(raw.setPin).toHaveBeenCalledWith("sku", "left");
+ expect(raw.setPin).toHaveBeenCalledWith("name", null);
+ expect(raw.setPin).toHaveBeenCalledWith("price", null);
+ });
+});
+
+// ─── isSameView ──────────────────────────────────────────────────────────────
+
+describe("isSameView", () => {
+ it("ignores filter order, in-array order, hidden order and none pins", () => {
+ const a = viewState({
+ filters: [
+ { field: "color", operator: "in", value: ["red", "blue"] },
+ { field: "category", operator: "eq", value: "shoes" },
+ ],
+ hidden: ["price", "name"],
+ pinned: { sku: "left", name: "none" },
+ });
+ const b = viewState({
+ filters: [
+ { field: "category", operator: "eq", value: "shoes" },
+ { field: "color", operator: "in", value: ["blue", "red"] },
+ ],
+ hidden: ["name", "price"],
+ pinned: { sku: "left" },
+ });
+ expect(isSameView(a, b)).toBe(true);
+ });
+
+ it("detects a real difference", () => {
+ expect(isSameView(viewState(), viewState({ pageSize: 50 }))).toBe(false);
+ expect(isSameView(viewState(), viewState({ sort: [{ field: "sku", direction: "Asc" }] }))).toBe(
+ false,
+ );
+ expect(isSameView(viewState(), viewState({ columnOrder: ["name", "sku", "price"] }))).toBe(
+ false,
+ );
+ });
+});
+
+// ─── useSavedViews ───────────────────────────────────────────────────────────
+
+const memoryStorage = (initial: SavedView[] = []) => {
+ let views = initial;
+ const storage: SavedViewStorage = {
+ load: vi.fn(() => views),
+ save: vi.fn((next: SavedView[]) => {
+ views = next;
+ }),
+ };
+ return { storage, read: () => views };
+};
+
+describe("useSavedViews", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ const renderViews = (state: TableState, storage?: SavedViewStorage) =>
+ renderHook(
+ ({ table }: { table: AnyTable }) =>
+ useSavedViews(table, { storageKey: "products", ...(storage ? { storage } : {}) }),
+ { initialProps: { table: fakeTable(state).table } },
+ );
+
+ it("is not dirty when the table is untouched", () => {
+ const { storage } = memoryStorage();
+ const { result } = renderViews(defaultState(), storage);
+ expect(result.current.items).toEqual([]);
+ expect(result.current.dirty).toBe(false);
+ expect(result.current.activeId).toBeNull();
+ });
+
+ it("is dirty once a filter is applied, and onSave adds a view that becomes active", () => {
+ const { storage, read } = memoryStorage();
+ const state = {
+ ...defaultState(),
+ filters: [{ field: "category", operator: "in", value: ["apparel"] }] as Filter[],
+ };
+ const { result } = renderViews(state, storage);
+ expect(result.current.dirty).toBe(true);
+
+ act(() => result.current.onSave("Apparel"));
+ expect(result.current.items).toHaveLength(1);
+ expect(result.current.items[0]).toMatchObject({
+ name: "Apparel",
+ state: { filters: state.filters },
+ });
+ expect(read()).toHaveLength(1);
+ expect(result.current.activeId).toBe(result.current.items[0]!.id);
+ expect(result.current.dirty).toBe(false);
+ expect(result.current.describe?.(result.current.items[0]!)).toBe("1 filter · 20/page");
+ });
+
+ it("saving under an existing name (any case) replaces it", () => {
+ const { storage } = memoryStorage();
+ const { result, rerender } = renderViews({ ...defaultState(), pageSize: 50 }, storage);
+ act(() => result.current.onSave("Wide"));
+ const firstId = result.current.items[0]!.id;
+
+ rerender({ table: fakeTable({ ...defaultState(), pageSize: 100 }).table });
+ act(() => result.current.onSave("wide"));
+ expect(result.current.items).toHaveLength(1);
+ expect(result.current.items[0]!.id).toBe(firstId);
+ expect(result.current.items[0]!.name).toBe("wide");
+ expect(result.current.items[0]!.state.pageSize).toBe(100);
+ });
+
+ it("activeId follows the table state", () => {
+ const saved: SavedView = {
+ id: "v1",
+ name: "Hidden price",
+ createdAt: "2026-09-30T00:00:00.000Z",
+ state: viewState({ hidden: ["price"] }),
+ };
+ const { storage } = memoryStorage([saved]);
+ const { result, rerender } = renderViews(defaultState(), storage);
+ expect(result.current.activeId).toBeNull();
+
+ rerender({ table: fakeTable({ ...defaultState(), hidden: ["price"] }).table });
+ expect(result.current.activeId).toBe("v1");
+ expect(result.current.dirty).toBe(false);
+ });
+
+ it("onDelete removes a view", () => {
+ const saved: SavedView = {
+ id: "v1",
+ name: "One",
+ createdAt: "2026-09-30T00:00:00.000Z",
+ state: viewState({ pageSize: 50 }),
+ };
+ const { storage, read } = memoryStorage([saved]);
+ const { result } = renderViews(defaultState(), storage);
+ act(() => result.current.onDelete(saved));
+ expect(result.current.items).toEqual([]);
+ expect(read()).toEqual([]);
+ });
+
+ it("onApply applies the view to the table", () => {
+ const { storage } = memoryStorage();
+ const fake = fakeTable(defaultState());
+ const { result } = renderHook(() =>
+ useSavedViews(fake.table, { storageKey: "products", storage }),
+ );
+ act(() =>
+ result.current.onApply({
+ id: "v1",
+ name: "Sorted",
+ createdAt: "2026-09-30T00:00:00.000Z",
+ state: viewState({ sort: [{ field: "sku", direction: "Asc" }] }),
+ }),
+ );
+ expect(fake.control.clearSort).toHaveBeenCalled();
+ expect(fake.control.setSort).toHaveBeenCalledWith("sku", "Asc");
+ });
+
+ it("persists to localStorage under as:saved-views:v1: by default", () => {
+ const { result } = renderViews({ ...defaultState(), pageSize: 50 });
+ act(() => result.current.onSave("Fifty"));
+ const raw = window.localStorage.getItem("as:saved-views:v1:products");
+ expect(raw).not.toBeNull();
+ const stored = JSON.parse(raw!) as SavedView[];
+ expect(stored).toHaveLength(1);
+ expect(stored[0]!.name).toBe("Fifty");
+
+ // A fresh hook reads it back.
+ const second = renderViews({ ...defaultState(), pageSize: 50 });
+ expect(second.result.current.items).toHaveLength(1);
+ expect(second.result.current.activeId).toBe(stored[0]!.id);
+ });
+
+ it("drops malformed entries from localStorage", () => {
+ window.localStorage.setItem(
+ "as:saved-views:v1:products",
+ JSON.stringify([{ id: 1 }, { id: "ok", name: "Ok", createdAt: "", state: viewState() }]),
+ );
+ const { result } = renderViews(defaultState());
+ expect(result.current.items.map((item) => item.id)).toEqual(["ok"]);
+ });
+
+ it("survives invalid JSON in localStorage", () => {
+ window.localStorage.setItem("as:saved-views:v1:products", "{not json");
+ const { result } = renderViews(defaultState());
+ expect(result.current.items).toEqual([]);
+ });
+});
diff --git a/packages/core/src/components/filter-rail/use-saved-views.ts b/packages/core/src/components/filter-rail/use-saved-views.ts
new file mode 100644
index 00000000..0d1afb1b
--- /dev/null
+++ b/packages/core/src/components/filter-rail/use-saved-views.ts
@@ -0,0 +1,204 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { UseDataTableReturn } from "../data-table/types";
+import { useFilterRailT } from "./i18n";
+import { readStorage, writeStorage } from "./storage";
+import type { SavedView, SavedViewBinding, SavedViewStorage, ViewState } from "./types";
+
+type AnyTable = UseDataTableReturn>;
+
+const STORAGE_PREFIX = "as:saved-views:v1:";
+
+// ─── Capture / apply ─────────────────────────────────────────────────────────
+
+/** Read the table's current state into a saveable bundle. */
+export function captureView(table: AnyTable): ViewState {
+ return {
+ filters: table.control?.filters ?? [],
+ sort: table.control?.sortStates ?? table.sortStates,
+ pageSize: table.pageSize,
+ columnOrder: table.columnOrder,
+ hidden: table.columnOrder.filter((key) => !table.isColumnVisible(key)),
+ pinned: { ...table.pinnedColumns },
+ };
+}
+
+/**
+ * Put the table into `state`. Order matters:
+ *
+ * - Sort is cleared, then re-applied in sequence so a multi-sort keeps its primary key.
+ * - Column order keeps only columns that still exist; new ones are appended.
+ * - Visibility is toggled only where it differs — `toggleColumn` is a flip.
+ * - Pins not in the view are reset to the column default.
+ */
+export function applyView(table: AnyTable, state: ViewState): void {
+ const control = table.control;
+ const keys = table.columnOrder;
+ if (control) {
+ control.setFilters(state.filters);
+ control.clearSort();
+ for (const sort of state.sort) control.setSort(sort.field, sort.direction);
+ if (state.pageSize > 0) control.setPageSize(state.pageSize);
+ control.resetPage();
+ }
+
+ const order = state.columnOrder.filter((key) => keys.includes(key));
+ table.setColumnOrder([...order, ...keys.filter((key) => !order.includes(key))]);
+
+ const hidden = new Set(state.hidden);
+ for (const key of keys) {
+ if (table.isColumnVisible(key) === hidden.has(key)) table.toggleColumn(key);
+ }
+
+ for (const key of keys) table.setPin(key, state.pinned[key] ?? null);
+}
+
+// ─── Compare ─────────────────────────────────────────────────────────────────
+
+/** Key order, `in`-array order and "none" pins must not produce a false "modified". */
+const normalise = (state: ViewState): string =>
+ JSON.stringify({
+ filters: [...state.filters]
+ .map((filter) => ({
+ field: filter.field,
+ operator: filter.operator,
+ value: Array.isArray(filter.value)
+ ? [...filter.value].map(String).toSorted()
+ : filter.value,
+ }))
+ .toSorted((a, b) => `${a.field}:${a.operator}`.localeCompare(`${b.field}:${b.operator}`)),
+ sort: state.sort.map((sort) => ({ field: sort.field, direction: sort.direction })),
+ pageSize: state.pageSize,
+ columnOrder: state.columnOrder,
+ hidden: [...state.hidden].toSorted(),
+ pinned: Object.fromEntries(
+ Object.entries(state.pinned)
+ .filter(([, side]) => side !== "none")
+ .toSorted(([a], [b]) => a.localeCompare(b)),
+ ),
+ });
+
+export const isSameView = (a: ViewState, b: ViewState): boolean => normalise(a) === normalise(b);
+
+/** The current layout with nothing chosen — "untouched". */
+const untouched = (current: ViewState): ViewState => ({
+ filters: [],
+ sort: [],
+ pageSize: current.pageSize,
+ columnOrder: current.columnOrder,
+ hidden: [],
+ pinned: {},
+});
+
+// ─── Storage ─────────────────────────────────────────────────────────────────
+
+const isSavedView = (value: unknown): value is SavedView => {
+ if (!value || typeof value !== "object") return false;
+ const view = value as Partial;
+ return (
+ typeof view.id === "string" &&
+ typeof view.name === "string" &&
+ !!view.state &&
+ Array.isArray(view.state.filters)
+ );
+};
+
+const localStorageFor = (key: string): SavedViewStorage => ({
+ load: () => readStorage(key, (raw) => (Array.isArray(raw) ? raw.filter(isSavedView) : []), []),
+ save: (views) => writeStorage(key, views),
+});
+
+// ─── Hook ────────────────────────────────────────────────────────────────────
+
+export type UseSavedViewsOptions = {
+ /** localStorage key suffix. Ignored when `storage` is given. */
+ storageKey: string;
+ /** Swap in a backend store to share views across a team or devices. */
+ storage?: SavedViewStorage;
+};
+
+/**
+ * Named, restorable views of a `DataTable` — filters **plus** sort, page size
+ * and column order / visibility / pinning. Returns the binding
+ * `FilterRail.SavedViews` renders.
+ *
+ * @example
+ * ```tsx
+ * const table = useDataTable({ columns, data, control });
+ * const views = useSavedViews(table, { storageKey: "products" });
+ *
+ * ```
+ */
+export function useSavedViews>(
+ table: UseDataTableReturn,
+ options: UseSavedViewsOptions,
+): SavedViewBinding {
+ const t = useFilterRailT();
+ const anyTable = table as unknown as AnyTable;
+ // Held in a ref so an inline `storage` object does not reload on every render.
+ const storageRef = useRef(null as unknown as SavedViewStorage);
+ storageRef.current = options.storage ?? localStorageFor(STORAGE_PREFIX + options.storageKey);
+ const [items, setItems] = useState(() => storageRef.current.load());
+
+ // Re-hydrate when the key changes (a different screen reusing the hook).
+ useEffect(() => {
+ setItems(storageRef.current.load());
+ }, [options.storageKey]);
+
+ const persist = useCallback((next: SavedView[]) => {
+ setItems(next);
+ storageRef.current.save(next);
+ }, []);
+
+ const current = captureView(anyTable);
+ const currentKey = normalise(current);
+ const active = items.find((view) => normalise(view.state) === currentKey) ?? null;
+ const dirty = active === null && currentKey !== normalise(untouched(current));
+
+ const onSave = useCallback(
+ (name: string) => {
+ // Saving over an existing name replaces it.
+ const existing = items.find((view) => view.name.toLowerCase() === name.toLowerCase());
+ const entry: SavedView = {
+ id:
+ existing?.id ??
+ `view-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
+ name,
+ createdAt: new Date().toISOString(),
+ state: captureView(anyTable),
+ };
+ persist(
+ existing
+ ? items.map((view) => (view.id === existing.id ? entry : view))
+ : [...items, entry],
+ );
+ },
+ [items, persist, anyTable],
+ );
+
+ const onApply = useCallback((view: SavedView) => applyView(anyTable, view.state), [anyTable]);
+
+ const onDelete = useCallback(
+ (view: SavedView) => persist(items.filter((item) => item.id !== view.id)),
+ [items, persist],
+ );
+
+ const describe = useCallback(
+ (view: SavedView) => {
+ const { state } = view;
+ const parts: string[] = [];
+ if (state.filters.length) parts.push(t("viewFilters", { count: state.filters.length }));
+ if (state.sort.length) parts.push(t("viewSorted"));
+ if (state.hidden.length) parts.push(t("viewHidden", { count: state.hidden.length }));
+ const pinned = Object.values(state.pinned).filter((side) => side !== "none").length;
+ if (pinned) parts.push(t("viewPinned", { count: pinned }));
+ if (state.pageSize > 0) parts.push(t("viewPageSize", { count: state.pageSize }));
+ return parts.join(" · ");
+ },
+ [t],
+ );
+
+ return useMemo(
+ () => ({ items, activeId: active?.id ?? null, dirty, onSave, onApply, onDelete, describe }),
+ [items, active, dirty, onSave, onApply, onDelete, describe],
+ );
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index dd96cbb1..f54cfae4 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -207,6 +207,15 @@ export type { PositionProps } from "./lib/position";
export { Sheet } from "./components/sheet";
export { Tabs } from "./components/tabs";
export { Toolbar, type ToolbarProps } from "./components/toolbar";
+export {
+ FilterRail,
+ useFilterRailCounts,
+ useFilterRailLayout,
+ useFilterRailCompact,
+ useSavedViews,
+ type FilterRailRootProps,
+ type FilterRailSection,
+} from "./components/filter-rail";
export { Tooltip } from "./components/tooltip";
export { Select, type SelectAsyncFetcher } from "./components/select";
export { Combobox, type ComboboxAsyncFetcher } from "./components/combobox";