From e181e78de002ba6e744cf7545b985dcf910cc4fa Mon Sep 17 00:00:00 2001 From: itsprade Date: Thu, 8 Oct 2026 11:22:38 +0530 Subject: [PATCH 1/5] feat(core): add FilterRail, an always-visible faceted filter rail Promotes the `demo/filter-rail` prototype into packages/core as a public component. It writes the same CollectionControl a DataTable reads, so there is no second filter engine. - Config sections (checkbox, radio, boolean, text, numberRange, date, dateRange, custom) + optional compound parts: Root, Header, ClearAll, Settings, SavedViews, Sections, Trigger, Sheet. - Hooks: useFilterRailCounts, useFilterRailLayout, useSavedViews, useFilterRailCompact. - Showcase page /showcase/filter-rail with three variants, docs outline, decision record and changeset. Co-Authored-By: Claude Opus 5.5 --- .changeset/filter-rail.md | 20 + decisions/filter-rail.md | 46 + docs-manifest.json | 28 +- .../components/filter-rail.docs.outline.md | 156 +++ docs/components/filter-rail.md | 154 +++ examples/vite-app/src/App.tsx | 1 + .../src/pages/showcase/filter-rail/page.tsx | 572 +++++++++++ examples/vite-app/src/routes.generated.ts | 1 + ...ts__filter-rail__filter-rail.test.tsx.snap | 19 + .../filter-rail/apply-layout.test.ts | 140 +++ .../components/filter-rail/apply-layout.ts | 83 ++ .../src/components/filter-rail/controls.tsx | 199 ++++ .../filter-rail/filter-rail-context.ts | 44 + .../filter-rail/filter-rail.test.tsx | 953 ++++++++++++++++++ .../components/filter-rail/filter-rail.tsx | 311 ++++++ .../core/src/components/filter-rail/i18n.ts | 129 +++ .../core/src/components/filter-rail/index.ts | 41 + .../src/components/filter-rail/internals.tsx | 256 +++++ .../src/components/filter-rail/operators.ts | 78 ++ .../src/components/filter-rail/responsive.tsx | 118 +++ .../components/filter-rail/saved-views.tsx | 165 +++ .../src/components/filter-rail/sections.tsx | 301 ++++++ .../src/components/filter-rail/settings.tsx | 275 +++++ .../src/components/filter-rail/storage.ts | 34 + .../core/src/components/filter-rail/types.ts | 282 ++++++ .../use-filter-rail-counts.test.ts | 167 +++ .../filter-rail/use-filter-rail-counts.ts | 133 +++ .../use-filter-rail-layout.test.ts | 79 ++ .../filter-rail/use-filter-rail-layout.ts | 56 + .../filter-rail/use-saved-views.test.tsx | 354 +++++++ .../components/filter-rail/use-saved-views.ts | 204 ++++ packages/core/src/index.ts | 9 + 32 files changed, 5407 insertions(+), 1 deletion(-) create mode 100644 .changeset/filter-rail.md create mode 100644 decisions/filter-rail.md create mode 100644 docs-src/components/filter-rail.docs.outline.md create mode 100644 docs/components/filter-rail.md create mode 100644 examples/vite-app/src/pages/showcase/filter-rail/page.tsx create mode 100644 packages/core/__snapshots__/src__components__filter-rail__filter-rail.test.tsx.snap create mode 100644 packages/core/src/components/filter-rail/apply-layout.test.ts create mode 100644 packages/core/src/components/filter-rail/apply-layout.ts create mode 100644 packages/core/src/components/filter-rail/controls.tsx create mode 100644 packages/core/src/components/filter-rail/filter-rail-context.ts create mode 100644 packages/core/src/components/filter-rail/filter-rail.test.tsx create mode 100644 packages/core/src/components/filter-rail/filter-rail.tsx create mode 100644 packages/core/src/components/filter-rail/i18n.ts create mode 100644 packages/core/src/components/filter-rail/index.ts create mode 100644 packages/core/src/components/filter-rail/internals.tsx create mode 100644 packages/core/src/components/filter-rail/operators.ts create mode 100644 packages/core/src/components/filter-rail/responsive.tsx create mode 100644 packages/core/src/components/filter-rail/saved-views.tsx create mode 100644 packages/core/src/components/filter-rail/sections.tsx create mode 100644 packages/core/src/components/filter-rail/settings.tsx create mode 100644 packages/core/src/components/filter-rail/storage.ts create mode 100644 packages/core/src/components/filter-rail/types.ts create mode 100644 packages/core/src/components/filter-rail/use-filter-rail-counts.test.ts create mode 100644 packages/core/src/components/filter-rail/use-filter-rail-counts.ts create mode 100644 packages/core/src/components/filter-rail/use-filter-rail-layout.test.ts create mode 100644 packages/core/src/components/filter-rail/use-filter-rail-layout.ts create mode 100644 packages/core/src/components/filter-rail/use-saved-views.test.tsx create mode 100644 packages/core/src/components/filter-rail/use-saved-views.ts 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 0a1569b8..76313246 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -644,6 +644,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", @@ -1612,6 +1637,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", @@ -1668,6 +1694,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": "c3fb2588a0699c26", "packages/core/skills/app-shell-patterns/references/migrations.md": "15845e186f68fe7e", - "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 }) => ( + + ), +}; +``` + +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 }) => ( + + ), +}; +``` + +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 cc372536..d5ac7e05 100644 --- a/examples/vite-app/src/App.tsx +++ b/examples/vite-app/src/App.tsx @@ -77,6 +77,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 ( + + ); + })} +
+ ); + }, + }, +]; + +// ─── 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 3f50e86a..1d84965b 100644 --- a/examples/vite-app/src/routes.generated.ts +++ b/examples/vite-app/src/routes.generated.ts @@ -34,6 +34,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`] = `""`; + +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 ( + + ); + })} +
+ )} + 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