diff --git a/.changeset/data-table-columns-and-row-nav.md b/.changeset/data-table-columns-and-row-nav.md index 4394da0f..88b2d45b 100644 --- a/.changeset/data-table-columns-and-row-nav.md +++ b/.changeset/data-table-columns-and-row-nav.md @@ -5,6 +5,6 @@ DataTable: sticky/pinned columns and user column settings - **Pinned columns** — add `pin: "left" | "right"` to a `Column` to freeze it during horizontal scroll (sticky offsets are measured from the rendered layout; `width` is optional but recommended for stable sizing). The selection column auto-pins left and the row-actions column auto-pins right, with a subtle freeze shadow at the frozen edge once content scrolls under it. -- **Column settings** — pass `columnSettings` to `DataTable.Toolbar` to render a built-in "Columns" control (top-right) that lets users show/hide columns, reorder them (drag), and change pinning by dragging a column between the Fixed left / Scrollable / Fixed right zones. +- **`DataTable.ColumnSettings`** — a "Columns" toolbar popover to show/hide columns, reorder them (drag), and change pinning by dragging a column between the Fixed left / Scrollable / Fixed right zones. Right-aligns itself inside `DataTable.Toolbar`. - **Persisted column layout** — pass a stable `tableId` to `useDataTable` to persist each user's column visibility, order, and pinning to `localStorage` (per-user preference; not stored in the URL). Omit for in-memory-only layout. - `Table.Root` now accepts an optional `containerRef` for its scroll container. diff --git a/catalogue/src/fundamental/components.md b/catalogue/src/fundamental/components.md index 9e8ee544..c02626ff 100644 --- a/catalogue/src/fundamental/components.md +++ b/catalogue/src/fundamental/components.md @@ -387,7 +387,7 @@ Plus `badgeVariants` CVA for custom-styled siblings. **Import:** compound namespace + helpers from `'@tailor-platform/app-shell'`, e.g. `DataTable`, `useDataTable`, `useCollectionVariables`, `createColumnHelper`, and types such as `Column`, `UseDataTableReturn`. -**Purpose:** Production list screens over GraphQL **connections**. Owns toolbar filter chips (**`DataTable.Filters`** from column `filter` configs), header sort, **`DataTable.Pagination`** (cursor-first; First/Last when `total` is provided), loading skeleton/error row, **`onClickRow`**, **`rowActions`** (kebab column), **`onSelectionChange`** (checkbox column), **column pinning** (`pin: "left" | "right"`) and built-in **column settings** (`` — user show/hide + reorder + pin, persisted per-user via **`tableId`**). +**Purpose:** Production list screens over GraphQL **connections**. Owns toolbar filter chips (**`DataTable.Filters`** from column `filter` configs), header sort, **`DataTable.Pagination`** (cursor-first; First/Last when `total` is provided), loading skeleton/error row, **`onClickRow`**, **`rowActions`** (kebab column), **`onSelectionChange`** (checkbox column), **column pinning** (`pin: "left" | "right"`) and **`DataTable.ColumnSettings`** (user show/hide + reorder + pin, persisted per-user via **`tableId`**). **Primitives:** Builds on low-level **`Table`**; do not reinvent pagination/filters manually unless the dataset is trivial. @@ -410,8 +410,9 @@ const table = useDataTable({ }); - + + @@ -422,7 +423,7 @@ const table = useDataTable({ **Row navigation:** whole row is clickable via **`onClickRow`** → `navigate(detailHref(row))`; wrap the primary identifier cell in `` for keyboard/SR access. Never add a per-row "View" / "Open" / "→" button. (A first-class row-interaction API is under design — see the row-interaction tracking issue.) -**Column pinning & settings:** set `pin: "left" | "right"` on a `Column` to freeze it during horizontal scroll — selection auto-pins left, row-actions auto-pins right (a `width` is optional but recommended for stable sizing). Pass **`columnSettings`** to **`DataTable.Toolbar`** to render the built-in "Columns" control (show/hide, reorder, re-pin) at the top-right; pass a stable, **unique** **`tableId`** to persist each user's layout to `localStorage` (a per-user preference — intentionally not in the URL like filters/sort). +**Column pinning & settings:** set `pin: "left" | "right"` on a `Column` to freeze it during horizontal scroll — selection auto-pins left, row-actions auto-pins right (a `width` is optional but recommended for stable sizing). Drop **`DataTable.ColumnSettings`** in the toolbar to let users show/hide, reorder, and re-pin columns (it right-aligns itself); pass a stable, **unique** **`tableId`** to persist each user's layout to `localStorage` (a per-user preference — intentionally not in the URL like filters/sort). **Column alignment:** each `Column` accepts **`align`** (`"left" | "right"`) applied to both header and body cell. Numeric `type` columns (`"number"`, `"money"`) default to `"right"` automatically so digits align on the decimal place — pass `align="left"` to opt out; everything else defaults to `"left"`. diff --git a/catalogue/src/pattern/list/dense-scan/PATTERN.md b/catalogue/src/pattern/list/dense-scan/PATTERN.md index 66d1c763..f2c53704 100644 --- a/catalogue/src/pattern/list/dense-scan/PATTERN.md +++ b/catalogue/src/pattern/list/dense-scan/PATTERN.md @@ -74,7 +74,7 @@ Omit `fill` on pages that should flow and scroll naturally (forms, dashboards, a - Bulk actions toolbar appears only when ≥1 row is selected - Whole row is clickable via `onClickRow`; wrap the primary identifier cell in `` for keyboard/SR access. No per-row "View" / "Open" buttons - Per-row `Menu` (overflow `…`) is reserved for non-navigation actions (Archive, Duplicate, Delete) -- Wide lists (many columns / horizontal scroll): pin the column users scan by to the **left** — the record's identifier (invoice / order **reference**) or its **name** (customer, product) — so it stays anchored while the rest scrolls; optionally pin a single high-signal **status** or **total** to the **right**. Keep the pinned set small (≈1 left, at most 1 right) — over-pinning eats the scroll area. Let users override via `` (show/hide + reorder + re-pin) with a stable `tableId` so each user's layout persists +- Wide lists (many columns / horizontal scroll): pin the column users scan by to the **left** — the record's identifier (invoice / order **reference**) or its **name** (customer, product) — so it stays anchored while the rest scrolls; optionally pin a single high-signal **status** or **total** to the **right**. Keep the pinned set small (≈1 left, at most 1 right) — over-pinning eats the scroll area. Let users override via `DataTable.ColumnSettings` (show/hide + reorder + re-pin) with a stable `tableId` so each user's layout persists ## Anti-patterns diff --git a/docs/components/data-table.md b/docs/components/data-table.md index bce581c5..1314b938 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -142,14 +142,15 @@ function JournalsPage() { `DataTable` is a namespace object. All sub-components read state from `DataTable.Root` via context. -| Sub-component | Description | -| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `DataTable.Root` | Context provider. Wraps all other sub-components. Required. | -| `DataTable.Table` | Renders the `` with headers and body. Required. | -| `DataTable.Toolbar` | Container for toolbar content (e.g. filters). Optional. Pass `columnSettings` to render the built-in "Columns" control (show/hide + reorder + pin) at the top-right. See props below. | -| `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. | -| `DataTable.Footer` | Footer container for pagination and other footer content. Optional. | -| `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. | +| Sub-component | Description | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DataTable.Root` | Context provider. Wraps all other sub-components. Required. | +| `DataTable.Table` | Renders the `
` with headers and body. Required. | +| `DataTable.Toolbar` | Container for toolbar content (filters, column settings). Optional. Lays children out in a row. | +| `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. | +| `DataTable.ColumnSettings` | "Columns" popover to show/hide columns, reorder them (drag), and pin them by dragging between the **Fixed left**, **Scrollable**, and **Fixed right** zones. Persists per-user when `useDataTable` has a `tableId`. Place inside `DataTable.Toolbar`. | +| `DataTable.Footer` | Footer container for pagination and other footer content. Optional. | +| `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. | ### `DataTable.Root` Props @@ -161,11 +162,10 @@ function JournalsPage() { ### `DataTable.Toolbar` Props -| Prop | Type | Default | Description | -| ---------------- | ----------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| `children` | `ReactNode` | — | Toolbar content (e.g. `DataTable.Filters`), laid out on the left. | -| `columnSettings` | `boolean` | `false` | Render the built-in "Columns" control (show/hide + reorder + pin) anchored to the top-right. Persists per-user when `useDataTable` has a `tableId`. | -| `className` | `string` | — | Additional CSS class for the toolbar container. | +| Prop | Type | Default | Description | +| ----------- | ----------- | ------- | ----------------------------------------------------------------------- | +| `children` | `ReactNode` | — | Toolbar content (e.g. `DataTable.Filters`, `DataTable.ColumnSettings`). | +| `className` | `string` | — | Additional CSS class for the toolbar container. | ### `DataTable.Filters` Props @@ -212,7 +212,7 @@ Row selection is enabled by providing `onSelectionChange` to `useDataTable`. The ## Column pinning, visibility & ordering - **Pin** a column with `pin: "left" | "right"`. Pinned columns stay visible during horizontal scroll; the selection column auto-pins left and the row-actions column auto-pins right. A subtle shadow appears at the frozen edge once the table is scrolled under it. Sticky offsets are measured from the rendered layout, so a `width` isn't required — but setting `width` on pinned columns is recommended so their size stays stable as content changes. -- **Column settings.** Pass `columnSettings` to `DataTable.Toolbar` to render a built-in "Columns" control — a popover to show/hide columns, reorder them (drag), and change pinning by dragging a column between the **Fixed left**, **Scrollable**, and **Fixed right** zones. It's a toolbar prop (not a composed sub-component) because the control always sits in the same top-right position. +- **`DataTable.ColumnSettings`** gives users a "Columns" popover to show/hide columns, reorder them (drag), and change pinning by dragging a column between the **Fixed left**, **Scrollable**, and **Fixed right** zones. Place it inside `DataTable.Toolbar`; it right-aligns itself, so filters sit left and this control sits right on the same row. - **Persistence.** Pass a stable, **unique** `tableId` to persist each user's column layout (visibility, order, pinning) to `localStorage` (key `as:data-table:v1:`). This is a per-user preference — it is deliberately **not** stored in the URL like filters/sort/pagination, so it survives reloads and isn't reset by shared/filtered links. Omit `tableId` for in-memory-only layout (state simply isn't persisted). Two tables mounted with the same `tableId` share one storage key and overwrite each other — use a unique id per table (e.g. `:`); a dev-mode warning fires on duplicates. ```tsx @@ -223,7 +223,9 @@ const table = useDataTable({ }); - + + + ; ``` @@ -276,7 +278,7 @@ A column definition passed to `useDataTable`. `Column` is a discriminated | `render` | `(row: TRow) => ReactNode` | Renders the cell content. Optional — overrides the built-in `type` renderer when set. | | `id` | `string` | Stable identifier for column visibility and React key. Falls back to `label` when omitted. | | `width` | `number` | Fixed column width in pixels. Optional. | -| `pin` | `"left" \| "right"` | Freezes the column to that edge so it stays visible during horizontal scroll (the default; the user can override it via the toolbar's `columnSettings` control). Sticky offsets are measured from the rendered layout, so `width` isn't required — but setting `width` on pinned columns is recommended for stable sizing. The selection column auto-pins left and the row-actions column auto-pins right. | +| `pin` | `"left" \| "right"` | Freezes the column to that edge so it stays visible during horizontal scroll (the default; the user can override it via `DataTable.ColumnSettings`). Sticky offsets are measured from the rendered layout, so `width` isn't required — but setting `width` on pinned columns is recommended for stable sizing. The selection column auto-pins left and the row-actions column auto-pins right. | | `align` | `"left" \| "right"` | Horizontal alignment. Defaults to `"right"` for `type: "number"` and `type: "money"`; `"left"` otherwise. Pass `"left"` to opt a numeric column out. | | `truncate` | `boolean` | Truncate overflowing text with an ellipsis. Wires up an app-shell `` automatically when the resolved cell value is a string or number — resolved via `accessor` first, then `row[col.id]` as a fallback — so hovering the cell reveals the full value. With `inferColumns`, no explicit `accessor` is needed because `id` is pinned to the field name. Requires another column to anchor the row width (`width` on a neighbor, or a fixed-size column like selection / row actions). | | `accessor` | _(narrowed per `type`)_ | Extracts the raw value. The return type is narrowed per `type` branch — returning an array is a compile error on all typed columns except `badge`, and returning a plain object is a compile error on all typed columns. Untyped columns (`type` omitted) retain `unknown`. `null` and `undefined` are always allowed. | diff --git a/examples/vite-app/src/pages/data-table-lab/page.tsx b/examples/vite-app/src/pages/data-table-lab/page.tsx index 3c42a414..86e60b8c 100644 --- a/examples/vite-app/src/pages/data-table-lab/page.tsx +++ b/examples/vite-app/src/pages/data-table-lab/page.tsx @@ -285,8 +285,9 @@ const DataTableLabPage = () => { } > - + + diff --git a/packages/core/src/components/data-table/column-settings.test.tsx b/packages/core/src/components/data-table/column-settings.test.tsx index f2057049..8abbaf2b 100644 --- a/packages/core/src/components/data-table/column-settings.test.tsx +++ b/packages/core/src/components/data-table/column-settings.test.tsx @@ -29,7 +29,9 @@ function SettingsHarness() { const table = useDataTable({ columns, data }); return ( - + + + ); @@ -40,25 +42,7 @@ const headerLabels = (container: HTMLElement) => .map((th) => th.textContent?.trim()) .filter(Boolean); -describe("DataTable.Toolbar columnSettings", () => { - it("renders the Columns button only when the columnSettings prop is set", () => { - function Bare() { - const table = useDataTable({ columns, data }); - return ( - - - - - ); - } - render(, { wrapper }); - expect(screen.queryByRole("button", { name: /columns/i })).toBeNull(); - - cleanup(); - render(, { wrapper }); - expect(screen.getByRole("button", { name: /columns/i })).toBeTruthy(); - }); - +describe("DataTable.ColumnSettings", () => { it("renders a checkbox per column and toggling hides it from the header", () => { const { container } = render(, { wrapper }); fireEvent.click(screen.getByRole("button", { name: /columns/i })); @@ -117,7 +101,9 @@ describe("DataTable.Toolbar columnSettings", () => { const table = useDataTable({ columns: pinnedColumns, data }); return ( - + + + ); diff --git a/packages/core/src/components/data-table/column-settings.tsx b/packages/core/src/components/data-table/column-settings.tsx index b7b31691..59303383 100644 --- a/packages/core/src/components/data-table/column-settings.tsx +++ b/packages/core/src/components/data-table/column-settings.tsx @@ -53,9 +53,9 @@ function effectiveSection( * that edge (or unpin it), and drag within a zone to reorder. Each row has a * visibility checkbox. State persists when `useDataTable` has a `tableId`. * - * @internal Rendered by `DataTable.Toolbar` when its `columnSettings` prop is - * set — not exported on the `DataTable` namespace, since the control always - * lives in the same place and needs no per-consumer composition. + * Place inside `DataTable.Toolbar`. It right-aligns itself (`ml-auto`), so + * composing it after `DataTable.Filters` puts filters left and this control + * right, on the same row. */ function DataTableColumnSettings({ className }: { className?: string }) { const t = useDataTableT(); @@ -249,7 +249,7 @@ function DataTableColumnSettings({ className }: { className?: string }) { + diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index 4c547f71..768f4b65 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -23,6 +23,7 @@ import { DataTableContext, type DataTableContextValue } from "./data-table-conte import { useDataTableT } from "./i18n"; import { getCellValue, renderTypedCell } from "./cell-renderers"; import { DataTableToolbar, DataTableFilters } from "./toolbar"; +import { DataTableColumnSettings } from "./column-settings"; import { DataTablePagination } from "./pagination"; export type { DataTablePaginationProps } from "./pagination"; @@ -1051,9 +1052,9 @@ export const DataTable = { */ Root: DataTableRoot, /** - * Container for toolbar content (filters, search, etc.). Place inside - * `DataTable.Root`, before `DataTable.Table`. Pass `columnSettings` to render - * the built-in "Columns" control (show/hide, reorder, pin) at the top-right. + * Container for toolbar content (filters, column settings, search, etc.). + * Place inside `DataTable.Root`, before `DataTable.Table`. Lays children out + * in a row; `DataTable.ColumnSettings` right-aligns itself within it. */ Toolbar: DataTableToolbar, /** @@ -1063,6 +1064,13 @@ export const DataTable = { * `useCollectionVariables()`, otherwise this component throws at render time. */ Filters: DataTableFilters, + /** + * Column controls popover — show/hide columns, drag to reorder, and drag + * between the Fixed left / Scrollable / Fixed right zones to pin them. + * Persists per-user when `useDataTable` has a `tableId`. Place inside + * `DataTable.Toolbar` (it right-aligns itself). + */ + ColumnSettings: DataTableColumnSettings, /** * Renders `
` with built-in `Headers` and `Body`. * Place inside `DataTable.Root`. diff --git a/packages/core/src/components/data-table/i18n.ts b/packages/core/src/components/data-table/i18n.ts index 3a508f7c..ff0eadf7 100644 --- a/packages/core/src/components/data-table/i18n.ts +++ b/packages/core/src/components/data-table/i18n.ts @@ -13,7 +13,7 @@ export const dataTableLabels = defineI18nLabels({ // RowActionsMenu aria-label rowActions: "Row actions", - // Column settings (DataTable.Toolbar columnSettings) + // Column settings (DataTable.ColumnSettings) columns: "Columns", columnSettings: "Column settings", showAllColumns: "Show all", diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index bbaca82e..9e725bd3 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -12,7 +12,6 @@ import { Calendar } from "@/components/calendar"; import { Tooltip } from "@/components/tooltip"; import { parseDate, DateFormatter } from "@internationalized/date"; import { useResolvedLocale } from "@/contexts/appshell-context"; -import { DataTableColumnSettings } from "./column-settings"; import { useDataTableContext } from "./data-table-context"; import { useDataTableT } from "./i18n"; import type { @@ -29,33 +28,19 @@ import type { Column } from "./types"; // ============================================================================= /** Use `DataTable.Toolbar` instead of calling this directly. */ -function DataTableToolbar({ - children, - columnSettings = false, - className, -}: { - children?: ReactNode; - /** - * Render the built-in column-settings control (a "Columns" button that opens - * the show/hide + reorder + pin popover) anchored to the top-right of the - * toolbar. The button always lives in the same place, so it's a prop rather - * than a separately-composed sub-component. - */ - columnSettings?: boolean; - className?: string; -}) { +function DataTableToolbar({ children, className }: { children?: ReactNode; className?: string }) { return (
- {/* Left group keeps children in their original vertical stack (so existing - toolbars are visually unchanged); the settings button sits top-right. */} -
{children}
- {columnSettings && } + {children}
); } diff --git a/packages/core/src/components/data-table/types.ts b/packages/core/src/components/data-table/types.ts index dfab8c52..8dbd942d 100644 --- a/packages/core/src/components/data-table/types.ts +++ b/packages/core/src/components/data-table/types.ts @@ -112,7 +112,7 @@ export interface ColumnBase> { /** * Freeze this column to the left or right edge so it stays visible while the * table scrolls horizontally. This is the **default** pin; the user can - * override it at runtime via the toolbar's `columnSettings` control (persisted when + * override it at runtime via `DataTable.ColumnSettings` (persisted when * `tableId` is set). * * Sticky offsets for stacked pinned columns are measured from the rendered