diff --git a/.changeset/filter-rail.md b/.changeset/filter-rail.md new file mode 100644 index 000000000..583d104e5 --- /dev/null +++ b/.changeset/filter-rail.md @@ -0,0 +1,19 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add `FilterRail`, an always-visible faceted filter rail that sits beside a `DataTable` and writes the same `CollectionControl`. + +Sections are config — any mix of `checkbox`, `radio`, `boolean`, `text`, `numberRange`, `date`, `dateRange` and `custom` — and every other feature is an optional part: `Header`, `ClearAll` and `Settings` (show / hide, reorder, sort). Below `lg` the rail turns itself into a left sheet behind a "Filters" button, like the Sidebar; wrap the page in `FilterRail.Provider` to place that button (`FilterRail.Trigger`) anywhere. + +```tsx + + + + + + + +``` + +Companion hook: `useFilterRailLayout` (persisted section layout). Checkbox and radio sections are for small, fixed sets of values; the docs explain which control to use for fields whose values keep growing. diff --git a/.changeset/layout-fill-stacked-columns.md b/.changeset/layout-fill-stacked-columns.md new file mode 100644 index 000000000..d4062fc39 --- /dev/null +++ b/.changeset/layout-fill-stacked-columns.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/app-shell": patch +--- + +Fix `` with several columns on narrow screens. Below the breakpoint where the columns stack (`lg` for two columns, `xl` for three), the layout no longer bounds its rows, so no column is squashed to zero height and the content area scrolls as usual — as the `fill` docs already describe. Side-by-side behaviour is unchanged. diff --git a/decisions/filter-rail.md b/decisions/filter-rail.md new file mode 100644 index 000000000..585311f3a --- /dev/null +++ b/decisions/filter-rail.md @@ -0,0 +1,67 @@ +# Decision: `FilterRail` — an exposed faceted filter rail + +> Status: **Proposed — ships `FilterRail` as a new public component.** +> Scope: a second filter surface beside `DataTable`. `DataTable.Filters` is unchanged. + +## Context + +`DataTable.Filters` hides filterable fields behind an "Add filter" panel. That suits the long tail of ad-hoc filters, but ERP list screens usually have a handful of axes people use constantly (category, status, availability), and for those a dropdown costs a click just to learn what is filterable. + +A demo on `demo/filter-rail` proved out an always-visible rail. This decision covers promoting it into the package. + +## Decision + +### Config sections + optional parts + +Sections are a config array (`sections`, like `DataTable` `columns` or `DescriptionCard` `fields`); assembly is a compound namespace (`FilterRail.Provider / Root / Header / ClearAll / Settings / Sections / Trigger`). + +- The rail must know every section to offer Clear all, reorder/hide settings and the "hiding clears its filter" rule. Pure JSX sections would need a registration protocol for that. +- A single component with a prop per feature cannot place pieces elsewhere (the narrow-screen button in a table toolbar) and grows a flag per feature. Parts are included or left out. +- A `custom` section type covers anything the built-in controls do not, and declares the `fields` it owns so it still takes part in Clear all. + +### Not `DataTable.FilterRail` + +The rail lives in a different `Layout.Column`, so it can never be a descendant of `DataTable.Root`. It therefore takes `control` as a prop rather than reading context. + +### Same filter model, no new operators + +Checkbox = `in`/`nin`, radio = `eq`/`ne`, boolean = `eq`, ranges = `between` (a field holds one filter, so `gte` + `lte` is not expressible). The operator is fixed per section; users pick values, not operators. + +### No result counts (for now) + +The prototype showed how many rows each option would return. That needs one aggregate query per filtered section (each excluding its own filter), plus distinct values for every listed field. There is no proven way to get either cheaply from TailorDB yet, so counts are **removed from this change** (agreed with the team, 2026-10-13). A spike will establish whether TailorDB can serve distinct values and grouped counts, or whether CDC-maintained cache tables are needed; the prototype code is kept on `feat/filter-rail-counts` for it. Counts can return later as an additive, optional prop. + +### Listed options are for small, fixed sets + +Checkbox and radio sections take a static `options` array and list every value. They are for small, fixed sets (status, category). A field whose values keep growing (product names, customers, suppliers) must use a search instead — a `text` section, or a `custom` section rendering a searchable picker. The docs carry a "Choosing a control" table, and in development the rail warns when a listed section has more than 50 options. Loading options asynchronously into a checkbox section is a possible follow-up, not part of this change. + +### Storage belongs to the consumer + +The rail never sees rows. Layout storage is a binding with a localStorage default (`useFilterRailLayout`) and a seam for a backend store — a team-shared layout is a product decision, not a component one. + +### Narrow screens: the Sidebar pattern + +Below `lg` the rail turns itself into a left sheet behind a "Filters" button, as the Sidebar does on mobile. The breakpoint check stays internal — AppShell exports no breakpoint hooks (`useIsMobile` / `useIsTablet` are private too), so apps write no breakpoint code. + +The app still owns where the button goes: `FilterRail.Trigger`, inside `FilterRail.Provider`, can sit anywhere (a table toolbar, a page header). If none is mounted, `Root` renders a fallback button in its own spot, so the filters are never unreachable on a screen whose author never tested narrow widths. + +`Root` takes the config directly in the common case; `Provider` is only needed when the Trigger lives outside the rail. Inside a Provider, `Root` takes no config (enforced by its prop types). + +### Saved views are a separate change + +Named views (filters plus sort, page size and column layout) were prototyped here and moved to a follow-up. A view is mostly `DataTable` state, its storage needs an async design for team-shared views, and keeping it out lets this review focus on the rail. + +### No hidden state + +Sections do not collapse; a selected option is never truncated, disabled or searched away; hiding a section clears its filter. + +### Radio stays private + +app-shell has no radio control. The rail uses a native radio in a `
` internally; a public `RadioGroup` can be promoted later without changing the rail's API. + +## Consequences + +- New public surface: `FilterRail`, `FilterRailRootProps`, `FilterRailSection`, `useFilterRailLayout`. +- Follow-ups: result counts (spike first), saved views, and possibly async option loading. +- A field should belong to one surface: do not render `DataTable.Filters` for fields the rail owns. +- Known limit: the URL serializer stringifies scalars, so a backend must coerce `"true"`/numeric strings as it would any input (the rail itself reads `"true"` back correctly). diff --git a/docs-manifest.json b/docs-manifest.json index 749f4e4b3..ea7012aba 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -664,6 +664,23 @@ "examples": null } }, + "filter-rail": { + "slug": "filter-rail", + "kind": "code-backed", + "outline": "docs-src/components/filter-rail.docs.outline.md", + "output": "docs/components/filter-rail.md", + "examples": null, + "sources": ["packages/core/src/components/filter-rail/**"], + "claims": [], + "symbols": ["FilterRail", "FilterRailRootProps", "FilterRailSection", "useFilterRailLayout"], + "hashes": { + "typeSurface": "f80feb4bebee066f", + "outline": "d2114b5403a2cdf2", + "snapshot": "21267134f12858cf", + "outputMd": "5b6feade4643dde3", + "examples": null + } + }, "form": { "slug": "form", "kind": "code-backed", @@ -1700,6 +1717,7 @@ "packages/core/skills/app-shell-patterns/references/components/description-card.md": "a347fd1f4ccd433e", "packages/core/skills/app-shell-patterns/references/components/dialog.md": "ec6a0ac098d1d87a", "packages/core/skills/app-shell-patterns/references/components/document-progress-card.md": "d0fb940f685f8869", + "packages/core/skills/app-shell-patterns/references/components/filter-rail.md": "5b6feade4643dde3", "packages/core/skills/app-shell-patterns/references/components/form.md": "91d901b17aed7781", "packages/core/skills/app-shell-patterns/references/components/global-header-layout.md": "dd2b0da3702cf8d9", "packages/core/skills/app-shell-patterns/references/components/grid.md": "9051eb53dde4179f", @@ -1757,6 +1775,6 @@ "packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "916baa3cd2b9cbf1", "packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "717ef4e7ae835aff", "packages/core/skills/app-shell-patterns/references/migrations.md": "013c3fef2303b096", - "packages/core/skills/app-shell-patterns/SKILL.md": "526decbfd2db6555" + "packages/core/skills/app-shell-patterns/SKILL.md": "4650734e91fbc858" } } 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 000000000..afb0ed871 --- /dev/null +++ b/docs-src/components/filter-rail.docs.outline.md @@ -0,0 +1,146 @@ +--- +kind: code-backed +group: filter-rail +title: FilterRail +description: An always-visible faceted filter rail with user layout settings, driven by the same CollectionControl as DataTable +sources: + - packages/core/src/components/filter-rail/** +--- + +# FilterRail + +`FilterRail` shows every filterable axis beside the results instead of hiding it behind a filter dropdown. Each section shows its options up front, so users see what they can filter by without opening anything. + +It writes the same `CollectionControl` a `DataTable` reads, so there is no second filter engine: a checkbox section **is** the `in` operator, a radio section is `eq`, a range is `between`. + +```tsx +import { FilterRail, type FilterRailSection } from "@tailor-platform/app-shell"; + +const sections: FilterRailSection[] = [ + { + id: "category", + label: "Category", + control: "checkbox", + field: "category", + options: categories, + }, + { id: "status", label: "Status", control: "radio", field: "status", options: statuses }, +]; + + + +; +``` + +## Sections and parts + +What the rail shows is **config**: `sections`, one entry per axis, in screen order. How the rail is assembled is **parts**: leave a part out and that feature is not rendered. + +| Part | Renders | +| --------------------- | ----------------------------------------------------------------------------- | +| `FilterRail.Root` | The rail. Takes `control`, `sections`, `layout`. | +| `FilterRail.Header` | A title row with a slot for actions. | +| `FilterRail.ClearAll` | Clears the rail's own filters only. Hidden while none are active. | +| `FilterRail.Settings` | Show / hide, reorder and sort sections. Needs `layout`. | +| `FilterRail.Sections` | The sections, in the user's layout. Scrolls while the header stays put. | +| `FilterRail.Provider` | Holds the config when a `Trigger` sits outside the rail (see Narrow screens). | +| `FilterRail.Trigger` | The narrow-screen "Filters" button, placed anywhere inside `Provider`. | + +```tsx + + + + + + + +``` + +### Section types + +Every section has an `id`, a `label` and an optional `hint`. The operator is fixed per section — the user picks values, never operators. + +| `control` | Commits | Notes | +| ------------- | ------------------------------------------ | ----------------------------------------------------------- | +| `checkbox` | `in` (or `nin`) with the ticked values | `sort`, `truncate` (default 8), `pinned`, `searchThreshold` | +| `radio` | `eq` (or `ne`) with one value | An "Any" row removes the filter; `anyLabel: null` hides it | +| `boolean` | `eq` with `true` / `false` | Three states: Any, `trueLabel`, `falseLabel` | +| `text` | `contains`, `hasPrefix` or `eq` | Debounced (`debounceMs`, default 250) | +| `numberRange` | `between` `{ min, max }`, or `gte` / `lte` | Commits on blur / Enter; one end alone is half-open | +| `date` | `eq`, `gte` or `lte` a `YYYY-MM-DD` | Uses `DatePicker` | +| `dateRange` | `between` `{ min, max }` | Uses `DateRangePicker`; optional `presets` | +| `custom` | Whatever `render` writes | Declares the `fields` it owns so Clear all includes them | + +A checkbox list long enough to be truncated also gets a search box, so a value can be found without scrolling (`searchThreshold` overrides when it appears). + +A field holds one filter, so two sections must not share a field; the rail warns in the console when they do. + +```tsx +const stockLevel: FilterRailSection = { + id: "stockLevel", + label: "Stock level", + control: "custom", + fields: ["stock"], + render: ({ getFilter, setFilter }) => ( + + ), +}; +``` + +The rail never hides state: a selected option is never truncated away, never disabled and never hidden by a section's search box, and sections do not collapse. + +## Choosing a control + +Checkbox and radio sections list every option, so they are only for **small, fixed sets** whose values you know up front — status, category, size, a handful of warehouses. Their `options` are a static array; the rail does not fetch them. + +Do **not** use a checkbox or radio list for a field whose values **keep growing** — product names, SKUs, customers, suppliers, order numbers. The list gets longer with every new record, takes over the rail, and becomes a scroll hunt. Give those fields a search instead: + +| The field's values are… | Use | +| ------------------------------------------------ | --------------------------------------------------------------------------- | +| A small fixed set (≈ up to 20), pick any | `checkbox` | +| A small fixed set, pick one | `radio` (or `boolean` for yes / no) | +| Open-ended and growing (names, codes, customers) | `text` — a search that commits `contains`, `hasPrefix` or `eq` | +| Open-ended, but users must pick exact records | a `custom` section rendering a searchable picker (e.g. an async `Combobox`) | +| Numbers or dates | `numberRange`, `date`, `dateRange` | + +In development the rail warns in the console when a checkbox or radio section lists more than 50 options, as a nudge towards a search. + +## User layout + +`useFilterRailLayout(storageKey)` persists the user's section order, hidden sections and option sort to localStorage. Pass it as `layout` and add `FilterRail.Settings`. Hiding a section that carries a filter also clears that filter. To share a layout across a team, pass your own `{ value, onChange, onReset }` binding instead. + +## Beside a DataTable + +The rail sits in its own `Layout.Column`, outside `DataTable.Root`, which is why `control` is a prop. Do not also render `DataTable.Filters` for the same fields — one fact in two places can drift. + +## Narrow screens + +Below the `lg` breakpoint there is no room for a side column, so the rail turns itself into a left sheet behind a **Filters** button carrying the active count — the same way the Sidebar handles small screens. Apps write no breakpoint code. + +By default the button appears where the rail would have been. To put it somewhere else — a table toolbar, a page header — wrap the page in `FilterRail.Provider`, move the config there, and place `FilterRail.Trigger` wherever you want it. The Trigger renders nothing on wide screens, and while one is mounted the rail skips its own button. + +```tsx + + + + + + + + + + + + + + + + + +``` diff --git a/docs/components/filter-rail.md b/docs/components/filter-rail.md new file mode 100644 index 000000000..86113189a --- /dev/null +++ b/docs/components/filter-rail.md @@ -0,0 +1,144 @@ +--- +title: FilterRail +description: An always-visible faceted filter rail with user layout settings, 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 shows its options up front, so users see what they can filter by without opening anything. + +It writes the same `CollectionControl` a `DataTable` reads, so there is no second filter engine: a checkbox section **is** the `in` operator, a radio section is `eq`, a range is `between`. + +```tsx +import { FilterRail, type FilterRailSection } from "@tailor-platform/app-shell"; + +const sections: FilterRailSection[] = [ + { + id: "category", + label: "Category", + control: "checkbox", + field: "category", + options: categories, + }, + { id: "status", label: "Status", control: "radio", field: "status", options: statuses }, +]; + + + +; +``` + +## Sections and parts + +What the rail shows is **config**: `sections`, one entry per axis, in screen order. How the rail is assembled is **parts**: leave a part out and that feature is not rendered. + +| Part | Renders | +| --------------------- | ----------------------------------------------------------------------------- | +| `FilterRail.Root` | The rail. Takes `control`, `sections`, `layout`. | +| `FilterRail.Header` | A title row with a slot for actions. | +| `FilterRail.ClearAll` | Clears the rail's own filters only. Hidden while none are active. | +| `FilterRail.Settings` | Show / hide, reorder and sort sections. Needs `layout`. | +| `FilterRail.Sections` | The sections, in the user's layout. Scrolls while the header stays put. | +| `FilterRail.Provider` | Holds the config when a `Trigger` sits outside the rail (see Narrow screens). | +| `FilterRail.Trigger` | The narrow-screen "Filters" button, placed anywhere inside `Provider`. | + +```tsx + + + + + + + +``` + +### Section types + +Every section has an `id`, a `label` and an optional `hint`. The operator is fixed per section — the user picks values, never operators. + +| `control` | Commits | Notes | +| ------------- | ------------------------------------------ | ----------------------------------------------------------- | +| `checkbox` | `in` (or `nin`) with the ticked values | `sort`, `truncate` (default 8), `pinned`, `searchThreshold` | +| `radio` | `eq` (or `ne`) with one value | An "Any" row removes the filter; `anyLabel: null` hides it | +| `boolean` | `eq` with `true` / `false` | Three states: Any, `trueLabel`, `falseLabel` | +| `text` | `contains`, `hasPrefix` or `eq` | Debounced (`debounceMs`, default 250) | +| `numberRange` | `between` `{ min, max }`, or `gte` / `lte` | Commits on blur / Enter; one end alone is half-open | +| `date` | `eq`, `gte` or `lte` a `YYYY-MM-DD` | Uses `DatePicker` | +| `dateRange` | `between` `{ min, max }` | Uses `DateRangePicker`; optional `presets` | +| `custom` | Whatever `render` writes | Declares the `fields` it owns so Clear all includes them | + +A checkbox list long enough to be truncated also gets a search box, so a value can be found without scrolling (`searchThreshold` overrides when it appears). + +A field holds one filter, so two sections must not share a field; the rail warns in the console when they do. + +```tsx +const stockLevel: FilterRailSection = { + id: "stockLevel", + label: "Stock level", + control: "custom", + fields: ["stock"], + render: ({ getFilter, setFilter }) => ( + + ), +}; +``` + +The rail never hides state: a selected option is never truncated away, never disabled and never hidden by a section's search box, and sections do not collapse. + +## Choosing a control + +Checkbox and radio sections list every option, so they are only for **small, fixed sets** whose values you know up front — status, category, size, a handful of warehouses. Their `options` are a static array; the rail does not fetch them. + +Do **not** use a checkbox or radio list for a field whose values **keep growing** — product names, SKUs, customers, suppliers, order numbers. The list gets longer with every new record, takes over the rail, and becomes a scroll hunt. Give those fields a search instead: + +| The field's values are… | Use | +| ------------------------------------------------ | --------------------------------------------------------------------------- | +| A small fixed set (≈ up to 20), pick any | `checkbox` | +| A small fixed set, pick one | `radio` (or `boolean` for yes / no) | +| Open-ended and growing (names, codes, customers) | `text` — a search that commits `contains`, `hasPrefix` or `eq` | +| Open-ended, but users must pick exact records | a `custom` section rendering a searchable picker (e.g. an async `Combobox`) | +| Numbers or dates | `numberRange`, `date`, `dateRange` | + +In development the rail warns in the console when a checkbox or radio section lists more than 50 options, as a nudge towards a search. + +## User layout + +`useFilterRailLayout(storageKey)` persists the user's section order, hidden sections and option sort to localStorage. Pass it as `layout` and add `FilterRail.Settings`. Hiding a section that carries a filter also clears that filter. To share a layout across a team, pass your own `{ value, onChange, onReset }` binding instead. + +## Beside a DataTable + +The rail sits in its own `Layout.Column`, outside `DataTable.Root`, which is why `control` is a prop. Do not also render `DataTable.Filters` for the same fields — one fact in two places can drift. + +## Narrow screens + +Below the `lg` breakpoint there is no room for a side column, so the rail turns itself into a left sheet behind a **Filters** button carrying the active count — the same way the Sidebar handles small screens. Apps write no breakpoint code. + +By default the button appears where the rail would have been. To put it somewhere else — a table toolbar, a page header — wrap the page in `FilterRail.Provider`, move the config there, and place `FilterRail.Trigger` wherever you want it. The Trigger renders nothing on wide screens, and while one is mounted the rail skips its own button. + +```tsx + + + + + + + + + + + + + + + + + +``` diff --git a/examples/vite-app/src/App.tsx b/examples/vite-app/src/App.tsx index 60ebe0929..7ce850443 100644 --- a/examples/vite-app/src/App.tsx +++ b/examples/vite-app/src/App.tsx @@ -83,6 +83,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 000000000..0befb8077 --- /dev/null +++ b/examples/vite-app/src/pages/showcase/filter-rail/page.tsx @@ -0,0 +1,555 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { + Badge, + DataTable, + FilterRail, + Layout, + Tabs, + createColumnHelper, + useCollectionVariables, + useDataTable, + useFilterRailLayout, + 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 and settings; below lg the Filters +// button sits in the table toolbar (Provider + Trigger) +// Checkboxes — Root + Sections only, plus a long Tags list with value search; +// below lg the rail falls back to its own Filters button +// Radios — radio + boolean + a custom section + +// ─── 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": + // A list-valued cell (tags) matches when any of its members does. + if (Array.isArray(value)) return value.some((member) => matches(member, "in", operand)); + 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), + 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, + }, + { + // A long, list-valued field: more options than fit, so the section shows a + // search box to find a value without scrolling. + id: "tags", + label: "Tags", + control: "checkbox", + field: "tags", + options: [...new Set(ROWS.flatMap((row) => row.tags))] + .sort() + .map((value) => ({ value, label: value })), + }, + { + 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. */ +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 layout = useFilterRailLayout("showcase:products"); + const data = useMemo(() => queryRows(variables), [variables]); + const table = useDataTable({ + columns: COLUMNS, + data, + control, + tableId: "filter-rail:full", + }); + + // Provider, because the Trigger lives in the table toolbar rather than in the + // rail. Below lg the rail turns into a sheet and the Trigger opens it; on wide + // screens the Trigger renders nothing. + return ( + + + {header} + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +// ─── Variant: Checkboxes only ─────────────────────────────────────────────────── + +const CheckboxVariant = ({ header }: { header: ReactNode }) => { + const { variables, control } = useCollectionVariables({ params: { pageSize: 25 } }); + 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 e29ab4a89..91ac3be8e 100644 --- a/examples/vite-app/src/routes.generated.ts +++ b/examples/vite-app/src/routes.generated.ts @@ -36,6 +36,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 000000000..37d4e7136 --- /dev/null +++ b/packages/core/__snapshots__/src__components__filter-rail__filter-rail.test.tsx.snap @@ -0,0 +1,15 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`FilterRail > snapshots > checkbox section 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 000000000..797b826fb --- /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: "label" }, + }); + 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 000000000..c71cc73e4 --- /dev/null +++ b/packages/core/src/components/filter-rail/apply-layout.ts @@ -0,0 +1,100 @@ +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))); + +/** + * Above this many options, a checkbox or radio section is the wrong control: + * the field's values are open-ended, not a small fixed set. + */ +export const MAX_LISTED_OPTIONS = 50; + +/** Sections that list their options — 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); + } + // Checkbox and radio lists are for small, fixed sets (status, category). + // A field whose values keep growing (product names, customers) belongs in + // a search, not an ever-longer list. + if (hasOptions(section) && section.options.length > MAX_LISTED_OPTIONS) { + problems.push( + `FilterRail: section "${section.id}" lists ${section.options.length} options. ` + + "Checkbox and radio sections are for small, fixed sets of values. For a field whose " + + 'values keep growing (names, customers, suppliers), use a `control: "text"` section ' + + "or a searchable custom section instead.", + ); + } + } + 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 000000000..5f3d20b8d --- /dev/null +++ b/packages/core/src/components/filter-rail/controls.tsx @@ -0,0 +1,209 @@ +// 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 { 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. Re-synced during + // render when the applied filter changes (Clear all, a shared URL). + const [from, setFrom] = useState(show(bounds.min)); + const [to, setTo] = useState(show(bounds.max)); + const boundsKey = `${bounds.min}|${bounds.max}`; + const [synced, setSynced] = useState(boundsKey); + if (synced !== boundsKey) { + setSynced(boundsKey); + setFrom(show(bounds.min)); + setTo(show(bounds.max)); + } + + const apply = () => { + let lo = num(from.trim()); + let hi = num(to.trim()); + // Ends typed the wrong way round mean the same range, not an empty one. + if (lo !== undefined && hi !== undefined && lo > hi) [lo, hi] = [hi, lo]; + if (lo === bounds.min && hi === bounds.max) { + // Nothing to commit, but show the normalised ends. + setFrom(show(lo)); + setTo(show(hi)); + 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 000000000..d901e42a1 --- /dev/null +++ b/packages/core/src/components/filter-rail/filter-rail-context.ts @@ -0,0 +1,57 @@ +import { createContext, useContext } from "react"; +import type { CollectionControl, Filter } from "@/types/collection"; +import type { 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[]; + layout: RailLayoutBinding | undefined; + 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; + + // Narrow-screen form + /** Below `lg`: the rail lives in a sheet behind a button. */ + compact: boolean; + sheetOpen: boolean; + setSheetOpen: (open: boolean) => void; + /** Whether a `FilterRail.Trigger` is mounted, so Root skips its fallback button. */ + hasTrigger: boolean; + /** Called by `FilterRail.Trigger` on mount; returns the unregister function. */ + registerTrigger: () => () => 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 or .`, + ); + } + return context; +} + +/** For Root, which provides the context itself when no Provider is above it. */ +export const useOptionalFilterRailContext = () => useContext(FilterRailContext); + +/** 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-d.tsx b/packages/core/src/components/filter-rail/filter-rail.test-d.tsx new file mode 100644 index 000000000..6217ddcbe --- /dev/null +++ b/packages/core/src/components/filter-rail/filter-rail.test-d.tsx @@ -0,0 +1,59 @@ +/** + * Vitest type tests for `FilterRail`'s field typing and Root/Provider config. + * + * To run: `pnpm test` + */ + +import { describe, expectTypeOf, it } from "vitest"; +import type { CollectionControl } from "@/types/collection"; +import { FilterRail, type FilterRailRootProps, type FilterRailSection } from "./index"; + +type Field = "category" | "status"; + +declare const control: CollectionControl; + +describe("FilterRail types", () => { + it("accepts sections whose field belongs to the control", () => { + const sections: FilterRailSection[] = [ + { id: "category", label: "Category", control: "checkbox", field: "category", options: [] }, + ]; + + + ; + }); + + it("rejects a misspelled field", () => { + const sections: FilterRailSection[] = [ + // @ts-expect-error — "categroy" is not a field of the control + { id: "category", label: "Category", control: "checkbox", field: "categroy", options: [] }, + ]; + expectTypeOf(sections).toBeArray(); + }); + + it("types a custom section's fields and setFilter", () => { + const section: FilterRailSection = { + id: "custom", + label: "Custom", + control: "custom", + fields: ["status"], + render: ({ setFilter }) => { + setFilter("status", { operator: "eq", value: "active" }); + // @ts-expect-error — unknown field + setFilter("warehouse", null); + return null; + }, + }; + expectTypeOf(section).not.toBeAny(); + }); + + it("requires both control and sections when Root is standalone", () => { + // @ts-expect-error — `sections` is missing + const missing: FilterRailRootProps = { control, children: null }; + expectTypeOf(missing).not.toBeAny(); + }); + + it("allows a Root with no config (inside a Provider)", () => { + const inside: FilterRailRootProps = { children: null }; + expectTypeOf(inside).not.toBeAny(); + }); +}); 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 000000000..cedb46441 --- /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, Provider, Root, Sections } from "./filter-rail"; +import { Trigger } from "./responsive"; +import { Settings } from "./settings"; +import type { FilterRailConfig, FilterRailSection, RailLayout } 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 & { + "aria-label"?: string; + className?: string; + 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