From 48299ad68dbf167bff391af664e08fb55bcb8886 Mon Sep 17 00:00:00 2001 From: itsprade Date: Mon, 13 Jul 2026 14:57:51 +0530 Subject: [PATCH 1/5] feat(data-table): sticky columns, column settings, and accessible row navigation Adds three DataTable capabilities driven by the "too many columns / hard to reach the View button" feedback (platform-planning#1489): - Pinned/sticky columns via `pin: "left" | "right"` (selection auto-pins left, row-actions auto-pins right) with a scroll-aware freeze shadow. - `DataTable.ColumnSettings` popover: show/hide, drag-reorder, and pin columns by dragging between Fixed left / Scrollable / Fixed right zones. - Per-user column layout (visibility, order, pinning) persisted to localStorage via a new `tableId` option. - `rowHref` for accessible whole-row navigation (primary cell as a real ), replacing per-row "View" buttons; `onClickRow` kept for non-navigation. Also: `Table.Root` gains an optional `containerRef`; docs, catalogue skill, and a demo lab page updated; changeset added. Co-Authored-By: Claude Opus 4.8 --- .changeset/data-table-columns-and-row-nav.md | 11 + catalogue/src/fundamental/components.md | 10 +- .../src/pattern/list/dense-scan/PATTERN.md | 3 +- docs/components/data-table.md | 78 ++- examples/vite-app/src/App.tsx | 1 + .../src/pages/data-table-lab/page.tsx | 290 ++++++++ examples/vite-app/src/routes.generated.ts | 1 + .../references/fundamental/components.md | 10 +- .../references/patterns/list-dense-scan.md | 3 +- .../components/data-table/cell-renderers.tsx | 8 +- .../data-table/column-settings.test.tsx | 143 ++++ .../components/data-table/column-settings.tsx | 259 +++++++ .../data-table/data-table-context.tsx | 7 + .../components/data-table/data-table.test.tsx | 170 ++++- .../src/components/data-table/data-table.tsx | 637 +++++++++++++++--- .../core/src/components/data-table/i18n.ts | 23 + .../core/src/components/data-table/types.ts | 57 +- .../data-table/use-data-table.test.ts | 106 ++- .../components/data-table/use-data-table.ts | 146 +++- .../data-table/use-persistent-column-state.ts | 103 +++ packages/core/src/components/table.tsx | 5 +- 21 files changed, 1908 insertions(+), 163 deletions(-) create mode 100644 .changeset/data-table-columns-and-row-nav.md create mode 100644 examples/vite-app/src/pages/data-table-lab/page.tsx create mode 100644 packages/core/src/components/data-table/column-settings.test.tsx create mode 100644 packages/core/src/components/data-table/column-settings.tsx create mode 100644 packages/core/src/components/data-table/use-persistent-column-state.ts diff --git a/.changeset/data-table-columns-and-row-nav.md b/.changeset/data-table-columns-and-row-nav.md new file mode 100644 index 00000000..131a66a6 --- /dev/null +++ b/.changeset/data-table-columns-and-row-nav.md @@ -0,0 +1,11 @@ +--- +"@tailor-platform/app-shell": minor +--- + +DataTable: sticky/pinned columns, user column settings, and accessible row-click navigation + +- **Pinned columns** — add `pin: "left" | "right"` to a `Column` (requires `width`) to freeze it during horizontal scroll. The selection column auto-pins left and the row-actions column auto-pins right, and a subtle freeze shadow appears at the frozen edge once content is scrolled under it. +- **`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. +- **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. +- **`rowHref`** — accessible whole-row navigation. The primary cell renders as a real `` (keyboard/screen-reader reachable, cmd/middle-click opens a new tab) while the whole row stays clickable. Prefer this over a per-row "View" button; keep `onClickRow` for non-navigation side effects. Optional `primaryColumnId` chooses which cell carries the link. +- `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 e752f4b9..4a49efbd 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). +**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, **`rowHref`** (accessible whole-row navigation — preferred) / **`onClickRow`** (non-navigation side effects), **`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. @@ -404,13 +404,15 @@ const table = useDataTable({ data: fetching ? undefined : mappedFromQuery, loading: fetching, control, - onClickRow: (row) => navigate(detailHref(row)), + tableId: "purchase-orders", // persist user column layout to localStorage + rowHref: (row) => detailHref(row), // whole-row nav; primary cell becomes a // onSelectionChange, rowActions, sort: … }); + @@ -419,6 +421,10 @@ const table = useDataTable({ ; ``` +**Row navigation:** prefer **`rowHref`** over `onClickRow` for going to a detail page — it renders the primary cell as a real `` (keyboard/SR reachable, cmd/middle-click opens a new tab) while keeping the whole row clickable. Use `onClickRow` only for non-navigation side effects. Never add a per-row "View" / "Open" / "→" button. + +**Column pinning & settings:** set `pin: "left" | "right"` on a `Column` (it must have a `width`) to freeze it during horizontal scroll — selection auto-pins left, row-actions auto-pins right. Drop **`DataTable.ColumnSettings`** in the toolbar to let users show/hide, reorder, and re-pin columns; pass a stable **`tableId`** to persist their 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"`. **Metadata path:** Prefer `createColumnHelper` + `inferColumns(tableMetadata.order)` (`@tailor-platform/app-shell-sdk-plugin` codegen) when available so enum/datetime/string filters bind to the right editors. diff --git a/catalogue/src/pattern/list/dense-scan/PATTERN.md b/catalogue/src/pattern/list/dense-scan/PATTERN.md index baef4dbe..e5efede5 100644 --- a/catalogue/src/pattern/list/dense-scan/PATTERN.md +++ b/catalogue/src/pattern/list/dense-scan/PATTERN.md @@ -72,8 +72,9 @@ Omit `fill` on pages that should flow and scroll naturally (forms, dashboards, a - Handle every state: `DataTable` renders the loading skeleton and error row; always provide a **labelled empty state** (what the list is + how to add the first record) rather than a bare empty table - Status Badge colors must use design system tokens (variant prop): the **primary** status column uses **filled** semantic variants; **secondary** status columns (delivery, billing) use **`outline-*`** (see `design-system.md` → Composition & emphasis rules) - Bulk actions toolbar appears only when ≥1 row is selected -- Whole row is clickable via `onClickRow`; no per-row "View" / "Open" buttons +- Whole row navigates via `rowHref` (renders the primary cell as an accessible ``); use `onClickRow` only for non-navigation side effects. No per-row "View" / "Open" buttons - Per-row `Menu` (overflow `…`) is reserved for non-navigation actions (Archive, Duplicate, Delete) +- Wide lists: pin key columns with `pin: "left" | "right"`, and offer `DataTable.ColumnSettings` (show/hide + reorder + pin) with a `tableId` so each user's layout persists ## Anti-patterns diff --git a/docs/components/data-table.md b/docs/components/data-table.md index 5b9c8140..126275b5 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, column visibility). Optional. | -| `DataTable.Filters` | Auto-generated filter chips 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 (e.g. filters, column visibility). Optional. | +| `DataTable.Filters` | Auto-generated filter chips 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 @@ -176,6 +177,41 @@ function JournalsPage() { Row selection is enabled by providing `onSelectionChange` to `useDataTable`. The `total` value comes from `DataTableData.total`. +## Row navigation + +**Use whole-row navigation, not a per-row "View" button.** Pass `rowHref` to make each row a link to its detail page: + +```tsx +const table = useDataTable({ + columns, + data, + rowHref: (row) => paths.for("/orders/:id", { id: row.id }), +}); +``` + +The primary cell (the first visible column, or `primaryColumnId`) renders as a real ``, so the row is reachable by keyboard and screen readers and can be opened in a new tab (cmd/ctrl/middle-click); clicking anywhere else in the row navigates too. `link`-type cells inside the row keep their own targets (no double navigation). Reserve `onClickRow` for non-navigation side effects (e.g. opening a drawer) and the row-actions kebab for non-navigation actions — don't add a "View" / "Open" / "→" button column. + +## Column pinning, visibility & ordering + +- **Pin** a column with `pin: "left" | "right"` (it must have a `width`). 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. +- **`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`. +- **Persistence.** Pass a stable `tableId` to persist each user's column layout (visibility, order, pinning) to `localStorage` (key `astw: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. + +```tsx +const table = useDataTable({ + columns, // e.g. [{ id: "ref", label: "Ref", width: 140, pin: "left" }, ...] + data, + tableId: "orders-list", +}); + + + + + + +; +``` + ## `useDataTable` Creates the table state object to pass to `DataTable.Root`. @@ -191,17 +227,20 @@ const table = useDataTable({ ### Options -| Option | Type | Description | -| ------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `columns` | `Column[]` | Column definitions. Required. | -| `data` | `DataTableData \| undefined` | Fetched data. Pass `undefined` while loading. | -| `loading` | `boolean` | When `true`, renders a loading skeleton. | -| `error` | `Error \| null` | When set, renders an error message in the table body. | -| `control` | `CollectionControl` | Collection control from `useCollectionVariables()`. Required for `DataTable.Pagination` and `DataTable.Filters`. | -| `onClickRow` | `(row: TRow) => void` | Called when the user clicks a row. Adds a pointer cursor to rows. | -| `rowActions` | `RowAction[]` | Per-row action items rendered in a kebab-menu column. The column is omitted when empty or not provided. | -| `onSelectionChange` | `(ids: string[]) => void` | Called with selected row IDs on change. Providing this enables the checkbox column. Rows must have a string `id`. | -| `sort` | `false \| { multiple?: boolean }` | Sort behaviour. `false` disables sorting entirely. `{ multiple: true }` enables multi-column sorting. Omit or pass `{}` for single-column sort (default). | +| Option | Type | Description | +| ------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `columns` | `Column[]` | Column definitions. Required. | +| `data` | `DataTableData \| undefined` | Fetched data. Pass `undefined` while loading. | +| `loading` | `boolean` | When `true`, renders a loading skeleton. | +| `error` | `Error \| null` | When set, renders an error message in the table body. | +| `control` | `CollectionControl` | Collection control from `useCollectionVariables()`. Required for `DataTable.Pagination` and `DataTable.Filters`. | +| `onClickRow` | `(row: TRow) => void` | Called when the user clicks a row (non-navigation side effects, e.g. opening a drawer). Adds a pointer cursor. Mutually exclusive with `rowHref` — `rowHref` wins if both are set. | +| `rowHref` | `(row: TRow) => string` | Makes the whole row navigate to the returned URL. The primary cell renders as a real `` (keyboard/screen-reader reachable, cmd/middle-click opens a new tab); clicking anywhere else in the row navigates too. Prefer this over an `onClickRow` "View" button. Requires a react-router context. | +| `primaryColumnId` | `string` | Id (or label) of the column whose cell carries the `rowHref` ``. Defaults to the first visible column. Ignored without `rowHref`. | +| `tableId` | `string` | Stable id used to persist per-user column layout (visibility, order, pinning) to `localStorage`. When omitted, column layout is in-memory only and resets on reload. | +| `rowActions` | `RowAction[]` | Per-row action items rendered in a kebab-menu column. The column is omitted when empty or not provided. | +| `onSelectionChange` | `(ids: string[]) => void` | Called with selected row IDs on change. Providing this enables the checkbox column. Rows must have a string `id`. | +| `sort` | `false \| { multiple?: boolean }` | Sort behaviour. `false` disables sorting entirely. `{ multiple: true }` enables multi-column sorting. Omit or pass `{}` for single-column sort (default). | ### `DataTableData` @@ -223,6 +262,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 `DataTable.ColumnSettings`). **Pinned columns must set `width`** — sticky offsets are computed from the widths of adjacent pinned columns; a `pin` without a `width` is ignored with a dev warning. 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/App.tsx b/examples/vite-app/src/App.tsx index 54b842b3..bdba02fa 100644 --- a/examples/vite-app/src/App.tsx +++ b/examples/vite-app/src/App.tsx @@ -55,6 +55,7 @@ const App = () => { + } diff --git a/examples/vite-app/src/pages/data-table-lab/page.tsx b/examples/vite-app/src/pages/data-table-lab/page.tsx new file mode 100644 index 00000000..81805391 --- /dev/null +++ b/examples/vite-app/src/pages/data-table-lab/page.tsx @@ -0,0 +1,290 @@ +import { + Layout, + Badge, + DataTable, + useDataTable, + createColumnHelper, + type AppShellPageProps, +} from "@tailor-platform/app-shell"; +import { FlaskConical } from "lucide-react"; +import { paths } from "../../routes.generated"; + +// ─── Dummy data ────────────────────────────────────────────────────────────── +// 🧪 Dummy Data: Replace with a real GraphQL-backed source later. + +type InvoiceStatus = "draft" | "sent" | "paid" | "overdue"; + +type Invoice = { + id: string; + customer: string; + email: string; + region: string; + owner: string; + status: InvoiceStatus; + amount: number; + tax: number; + total: number; + issued: string; + dueDate: string; + notes: string; +}; + +const CUSTOMERS = ["Acme Corp", "Globex", "Initech", "Umbrella", "Soylent", "Hooli", "Stark Ind."]; +const REGIONS = ["North America", "EMEA", "APAC", "LATAM"]; +const OWNERS = ["A. Kimura", "B. Osei", "C. Lindqvist", "D. Alvarez", "E. Nakamura"]; +const STATUSES: InvoiceStatus[] = ["draft", "sent", "paid", "overdue"]; + +// Deterministic pseudo-random so the dataset is stable across renders/reloads. +function makeInvoices(count: number): Invoice[] { + const rows: Invoice[] = []; + let seed = 4242; + const rand = () => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed / 0x7fffffff; + }; + const base = new Date("2026-01-01T00:00:00Z").getTime(); + const pick = (arr: T[]) => arr[Math.floor(rand() * arr.length)]; + for (let i = 0; i < count; i++) { + const amount = Math.round((rand() * 9000 + 100) * 100) / 100; + const tax = Math.round(amount * 0.1 * 100) / 100; + const customer = pick(CUSTOMERS); + const issued = new Date(base + Math.floor(rand() * 120) * 86_400_000); + const due = new Date(issued.getTime() + 30 * 86_400_000); + rows.push({ + id: `INV-${String(1000 + i)}`, + customer, + email: `billing@${customer.toLowerCase().replace(/[^a-z]/g, "")}.example`, + region: pick(REGIONS), + owner: pick(OWNERS), + status: pick(STATUSES), + amount, + tax, + total: Math.round((amount + tax) * 100) / 100, + issued: issued.toISOString().slice(0, 10), + dueDate: due.toISOString().slice(0, 10), + notes: `Follow-up scheduled with ${pick(OWNERS)} regarding ${pick(REGIONS)} terms and renewal.`, + }); + } + return rows; +} + +const INVOICES = makeInvoices(40); + +const statusVariant = (status: InvoiceStatus) => + status === "paid" + ? ("success" as const) + : status === "overdue" + ? ("outline-warning" as const) + : status === "sent" + ? ("outline-info" as const) + : ("neutral" as const); + +// ─── Columns ─────────────────────────────────────────────────────────────── +// Widths are set on every column so pinned columns can compute sticky offsets. + +const { column } = createColumnHelper(); + +const baseColumns = [ + column({ id: "id", label: "Invoice", type: "text", accessor: (r) => r.id, width: 130 }), + column({ + id: "customer", + label: "Customer", + type: "text", + accessor: (r) => r.customer, + width: 180, + sort: { field: "customer", type: "string" }, + }), + column({ + id: "email", + label: "Billing email", + type: "text", + accessor: (r) => r.email, + width: 240, + }), + column({ id: "region", label: "Region", type: "text", accessor: (r) => r.region, width: 150 }), + column({ + id: "owner", + label: "Account owner", + type: "text", + accessor: (r) => r.owner, + width: 160, + }), + column({ + id: "status", + label: "Status", + width: 120, + render: (r) => {r.status}, + }), + column({ id: "amount", label: "Amount", type: "money", accessor: (r) => r.amount, width: 130 }), + column({ id: "tax", label: "Tax", type: "money", accessor: (r) => r.tax, width: 110 }), + column({ id: "total", label: "Total", type: "money", accessor: (r) => r.total, width: 130 }), + column({ id: "issued", label: "Issued", type: "date", accessor: (r) => r.issued, width: 140 }), + column({ + id: "dueDate", + label: "Due date", + type: "date", + accessor: (r) => r.dueDate, + width: 140, + }), + column({ + id: "notes", + label: "Notes", + type: "text", + accessor: (r) => r.notes, + width: 260, + truncate: true, + }), +]; + +const rowActions = [ + { id: "duplicate", label: "Duplicate", onClick: (r: Invoice) => alert(`Duplicate ${r.id}`) }, + { + id: "delete", + label: "Delete", + variant: "destructive" as const, + onClick: (r: Invoice) => alert(`Delete ${r.id}`), + }, +]; + +// ─── Section shell ─────────────────────────────────────────────────────────── + +function Section({ + title, + description, + children, +}: { + title: string; + description: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+

{title}

+

{description}

+ {children} +
+ ); +} + +const data = { rows: INVOICES, total: INVOICES.length }; + +// ─── Page ────────────────────────────────────────────────────────────────── + +const DataTableLabPage = () => { + // Section 1 — all-in-one column settings + default pins + row actions. + // `tableId` persists the user's layout to localStorage across reloads. + const settingsTable = useDataTable({ + columns: baseColumns.map((c) => (c.id === "id" ? { ...c, pin: "left" as const } : c)), + data, + tableId: "lab-invoices-settings", + rowActions, + }); + + // Section 2 — whole-row click navigation. + const rowClickTable = useDataTable({ + columns: baseColumns.map((c) => (c.id === "id" ? { ...c, pin: "left" as const } : c)), + data, + tableId: "lab-invoices-rowclick", + rowHref: (row) => paths.for("/dashboard/orders/:id", { id: row.id }), + }); + + // Section 3 — whole-row click + a clickable cell. The Customer cell is a + // `link` column, so clicking it navigates to its own target instead of the + // row's; clicking anywhere else opens the row's detail page. + const cellLinkColumns = baseColumns.map((c) => { + if (c.id === "id") return { ...c, pin: "left" as const }; + if (c.id === "customer") { + return column({ + id: "customer", + label: "Customer", + type: "link", + accessor: (r: Invoice) => r.customer, + width: 180, + typeOptions: { href: () => paths.for("/dashboard/products") }, + }); + } + return c; + }); + const cellLinkTable = useDataTable({ + columns: cellLinkColumns, + data, + tableId: "lab-invoices-celllink", + rowHref: (row) => paths.for("/dashboard/orders/:id", { id: row.id }), + }); + + return ( + + + +
+ Prototype playground. Compares column-settings surfaces and row-click + navigation for the DataTable. Scroll each table horizontally to see pinned columns stay + put. Column layout (visibility, order, pins) persists per table via tableId. +
+ +
+ Open Columns to show/hide, drag or use the arrows to reorder, and pin + columns left/right. The Invoice column is pinned left and the actions column + is pinned right by default. Changes persist across reloads. + + } + > + + + + + + +
+ +
+ The entire row is a link to the detail page. The Invoice cell is a real{" "} + <Link>: Tab to it and press Enter, or cmd/middle-click any row to + open it in a new tab. + + } + > + + + + + + +
+ +
+ Same whole-row navigation, but the Customer cell is its own link. + Clicking the customer goes to Products; clicking anywhere else opens the invoice + detail — no double navigation. + + } + > + + + + + + +
+
+
+ ); +}; + +DataTableLabPage.appShellPageProps = { + meta: { + title: "DataTable Lab", + icon: , + }, +} satisfies AppShellPageProps; + +export default DataTableLabPage; diff --git a/examples/vite-app/src/routes.generated.ts b/examples/vite-app/src/routes.generated.ts index b5032895..b1e3ade7 100644 --- a/examples/vite-app/src/routes.generated.ts +++ b/examples/vite-app/src/routes.generated.ts @@ -23,6 +23,7 @@ export type GeneratedRouteParams = { "/dashboard/orders/:id": { id: string }; "/dashboard/products": {}; "/data-table": {}; + "/data-table-lab": {}; "/date-picker": {}; "/settings": {}; }; diff --git a/packages/core/skills/app-shell-patterns/references/fundamental/components.md b/packages/core/skills/app-shell-patterns/references/fundamental/components.md index e752f4b9..4a49efbd 100644 --- a/packages/core/skills/app-shell-patterns/references/fundamental/components.md +++ b/packages/core/skills/app-shell-patterns/references/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). +**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, **`rowHref`** (accessible whole-row navigation — preferred) / **`onClickRow`** (non-navigation side effects), **`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. @@ -404,13 +404,15 @@ const table = useDataTable({ data: fetching ? undefined : mappedFromQuery, loading: fetching, control, - onClickRow: (row) => navigate(detailHref(row)), + tableId: "purchase-orders", // persist user column layout to localStorage + rowHref: (row) => detailHref(row), // whole-row nav; primary cell becomes a // onSelectionChange, rowActions, sort: … }); + @@ -419,6 +421,10 @@ const table = useDataTable({ ; ``` +**Row navigation:** prefer **`rowHref`** over `onClickRow` for going to a detail page — it renders the primary cell as a real `` (keyboard/SR reachable, cmd/middle-click opens a new tab) while keeping the whole row clickable. Use `onClickRow` only for non-navigation side effects. Never add a per-row "View" / "Open" / "→" button. + +**Column pinning & settings:** set `pin: "left" | "right"` on a `Column` (it must have a `width`) to freeze it during horizontal scroll — selection auto-pins left, row-actions auto-pins right. Drop **`DataTable.ColumnSettings`** in the toolbar to let users show/hide, reorder, and re-pin columns; pass a stable **`tableId`** to persist their 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"`. **Metadata path:** Prefer `createColumnHelper` + `inferColumns(tableMetadata.order)` (`@tailor-platform/app-shell-sdk-plugin` codegen) when available so enum/datetime/string filters bind to the right editors. diff --git a/packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md b/packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md index dfbe8b34..49eb3452 100644 --- a/packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md +++ b/packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md @@ -153,8 +153,9 @@ Omit `fill` on pages that should flow and scroll naturally (forms, dashboards, a - Handle every state: `DataTable` renders the loading skeleton and error row; always provide a **labelled empty state** (what the list is + how to add the first record) rather than a bare empty table - Status Badge colors must use design system tokens (variant prop): the **primary** status column uses **filled** semantic variants; **secondary** status columns (delivery, billing) use **`outline-*`** (see `design-system.md` → Composition & emphasis rules) - Bulk actions toolbar appears only when ≥1 row is selected -- Whole row is clickable via `onClickRow`; no per-row "View" / "Open" buttons +- Whole row navigates via `rowHref` (renders the primary cell as an accessible ``); use `onClickRow` only for non-navigation side effects. No per-row "View" / "Open" buttons - Per-row `Menu` (overflow `…`) is reserved for non-navigation actions (Archive, Duplicate, Delete) +- Wide lists: pin key columns with `pin: "left" | "right"`, and offer `DataTable.ColumnSettings` (show/hide + reorder + pin) with a `tableId` so each user's layout persists ## Anti-patterns diff --git a/packages/core/src/components/data-table/cell-renderers.tsx b/packages/core/src/components/data-table/cell-renderers.tsx index 83596e88..9fd4025c 100644 --- a/packages/core/src/components/data-table/cell-renderers.tsx +++ b/packages/core/src/components/data-table/cell-renderers.tsx @@ -148,7 +148,13 @@ function renderLink>( const href = options.href(row); if (!href) return label; return ( - + // Stop propagation so a link cell inside a clickable row (onClickRow / + // rowHref) follows the cell's own target instead of the row's. + e.stopPropagation()} + className="astw:text-primary astw:underline-offset-4 astw:hover:underline" + > {label} ); diff --git a/packages/core/src/components/data-table/column-settings.test.tsx b/packages/core/src/components/data-table/column-settings.test.tsx new file mode 100644 index 00000000..8abbaf2b --- /dev/null +++ b/packages/core/src/components/data-table/column-settings.test.tsx @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, it, expect } from "vitest"; +import { cleanup, render, screen, fireEvent, within } from "@testing-library/react"; +import { createAppShellWrapper } from "../../../tests/test-utils"; +import { DataTable } from "./data-table"; +import { useDataTable } from "./use-data-table"; +import type { Column, DataTableData } from "./types"; + +afterEach(() => { + cleanup(); +}); + +beforeEach(() => { + localStorage.clear(); +}); + +type Row = { id: string; a: string; b: string; c: string }; + +const columns: Column[] = [ + { id: "a", label: "Alpha", width: 100, render: (r) => r.a }, + { id: "b", label: "Bravo", width: 100, render: (r) => r.b }, + { id: "c", label: "Charlie", width: 100, render: (r) => r.c }, +]; + +const data: DataTableData = { rows: [{ id: "1", a: "a1", b: "b1", c: "c1" }] }; + +const wrapper = createAppShellWrapper("en"); + +function SettingsHarness() { + const table = useDataTable({ columns, data }); + return ( + + + + + + + ); +} + +const headerLabels = (container: HTMLElement) => + Array.from(container.querySelectorAll('[data-slot="data-table-header"] th')) + .map((th) => th.textContent?.trim()) + .filter(Boolean); + +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 })); + + const panel = document.querySelector( + '[data-slot="data-table-column-settings-popup"]', + )!; + expect(panel).toBeTruthy(); + + // Uncheck "Bravo". + const bravoCheckbox = within(panel).getByRole("checkbox", { name: "Bravo" }); + fireEvent.click(bravoCheckbox); + + expect(headerLabels(container)).toEqual(["Alpha", "Charlie"]); + }); + + it("renders the three drop-zone sections", () => { + render(, { wrapper }); + fireEvent.click(screen.getByRole("button", { name: /columns/i })); + + const panel = document.querySelector( + '[data-slot="data-table-column-settings-popup"]', + )!; + expect(panel.querySelector('[data-section="left"]')).toBeTruthy(); + expect(panel.querySelector('[data-section="scrollable"]')).toBeTruthy(); + expect(panel.querySelector('[data-section="right"]')).toBeTruthy(); + }); + + it("dragging a column into the Fixed right section pins it right", () => { + const { container } = render(, { wrapper }); + fireEvent.click(screen.getByRole("button", { name: /columns/i })); + + const panel = document.querySelector( + '[data-slot="data-table-column-settings-popup"]', + )!; + const charlieRow = within(panel).getByText("Charlie").closest('[draggable="true"]')!; + const rightZone = panel.querySelector('[data-section="right"]')!; + + fireEvent.dragStart(charlieRow); + fireEvent.dragOver(rightZone); + fireEvent.drop(rightZone); + + const charlieHead = Array.from( + container.querySelectorAll('[data-slot="data-table-header"] th'), + ).find((th) => th.textContent?.trim() === "Charlie"); + expect(charlieHead?.style.position).toBe("sticky"); + expect(charlieHead?.style.right).toBe("0px"); + }); + + it("dragging a column into Scrollable unpins a column pinned by default", () => { + // "Alpha" is pinned left by default; dragging it to Scrollable must stick. + const pinnedColumns: Column[] = columns.map((c) => + c.id === "a" ? { ...c, pin: "left" as const } : c, + ); + function Harness() { + const table = useDataTable({ columns: pinnedColumns, data }); + return ( + + + + + + + ); + } + const { container } = render(, { wrapper }); + fireEvent.click(screen.getByRole("button", { name: /columns/i })); + + const panel = document.querySelector( + '[data-slot="data-table-column-settings-popup"]', + )!; + const alphaRow = within(panel).getByText("Alpha").closest('[draggable="true"]')!; + const scrollableZone = panel.querySelector('[data-section="scrollable"]')!; + + fireEvent.dragStart(alphaRow); + fireEvent.dragOver(scrollableZone); + fireEvent.drop(scrollableZone); + + const alphaHead = Array.from( + container.querySelectorAll('[data-slot="data-table-header"] th'), + ).find((th) => th.textContent?.trim() === "Alpha"); + expect(alphaHead?.style.position).toBe(""); + }); + + it("show all / hide all toggle every column", () => { + const { container } = render(, { wrapper }); + fireEvent.click(screen.getByRole("button", { name: /columns/i })); + + const panel = document.querySelector( + '[data-slot="data-table-column-settings-popup"]', + )!; + fireEvent.click(within(panel).getByRole("button", { name: /hide all/i })); + expect(headerLabels(container)).toEqual([]); + + fireEvent.click(within(panel).getByRole("button", { name: /show all/i })); + expect(headerLabels(container)).toEqual(["Alpha", "Bravo", "Charlie"]); + }); +}); diff --git a/packages/core/src/components/data-table/column-settings.tsx b/packages/core/src/components/data-table/column-settings.tsx new file mode 100644 index 00000000..0a010f71 --- /dev/null +++ b/packages/core/src/components/data-table/column-settings.tsx @@ -0,0 +1,259 @@ +import { useCallback, useMemo, useState } from "react"; +import { Popover } from "@base-ui/react/popover"; +import { Checkbox } from "@base-ui/react/checkbox"; +import { Check, GripVertical, SlidersHorizontal } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/button"; +import { useDataTableContext } from "./data-table-context"; +import { useDataTableT } from "./i18n"; + +// Shared popup styling, matching DataTable's filter popover. +const POPUP_CLASS = cn( + "astw:bg-popover astw:text-popover-foreground astw:z-(--z-popup) astw:origin-(--transform-origin) astw:overflow-hidden astw:rounded-md astw:border astw:border-border astw:shadow-md", + "astw:animate-in astw:fade-in-0 astw:zoom-in-95 astw:data-ending-style:animate-out astw:data-ending-style:fade-out-0 astw:data-ending-style:zoom-out-95", +); + +const CHECKBOX_CLASS = cn( + "astw:flex astw:size-4 astw:shrink-0 astw:items-center astw:justify-center astw:rounded-sm astw:border astw:border-input", + "astw:data-checked:bg-primary astw:data-checked:border-primary astw:data-checked:text-primary-foreground", +); + +// The DataTable column key formula (mirrors useDataTable's getColumnKey); +// ctx.columns is the full column list in definition order, so the index-based +// fallback lines up with the keys in ctx.columnOrder. +function columnKey(col: { id?: string; label?: string }, index: number): string { + return col.id ?? col.label ?? String(index); +} + +type Section = "left" | "scrollable" | "right"; + +// Which pin value a section maps to. "none" explicitly unpins (overriding a +// column's default `pin`) so dragging a pinned-by-default column into +// "Scrollable" sticks. +const SECTION_PIN: Record = { + left: "left", + scrollable: "none", + right: "right", +}; + +/** Insertion indicator shown at the active drop position. */ +function InsertLine() { + return
; +} + +/** Resolve a column's effective pin (override wins; "none" is explicit unpin). */ +function effectiveSection( + stored: "left" | "right" | "none" | undefined, + defaultPin: "left" | "right" | undefined, +): Section { + if (stored === "none") return "scrollable"; + return stored ?? defaultPin ?? "scrollable"; +} + +// ============================================================================= +// DataTable.ColumnSettings — 3 droppable sections (fixed left / scrollable / +// fixed right). Dragging a column between sections sets its pin; dragging +// within a section reorders it. +// ============================================================================= + +/** + * A "Columns" toolbar popover with three drop zones — **Fixed left**, + * **Scrollable**, and **Fixed right**. Drag a column into a zone to pin it to + * 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`. + * + * Place inside `DataTable.Toolbar`. + */ +function DataTableColumnSettings({ className }: { className?: string }) { + const t = useDataTableT(); + const { + columns, + columnOrder, + isColumnVisible, + toggleColumn, + showAllColumns, + hideAllColumns, + pinnedColumns, + setPin, + setColumnOrder, + } = useDataTableContext>(); + + const meta = useMemo(() => { + const label = new Map(); + const defaultPin = new Map(); + columns.forEach((col, i) => { + const key = columnKey(col, i); + label.set(key, col.label ?? key); + defaultPin.set(key, col.pin); + }); + return { label, defaultPin }; + }, [columns]); + + const sectionOf = useCallback( + (key: string): Section => effectiveSection(pinnedColumns[key], meta.defaultPin.get(key)), + [pinnedColumns, meta], + ); + + // Group the ordered columns into their sections (order preserved per section). + const buckets = useMemo(() => { + const grouped: Record = { left: [], scrollable: [], right: [] }; + for (const key of columnOrder) grouped[sectionOf(key)].push(key); + return grouped; + }, [columnOrder, sectionOf]); + + const [dragKey, setDragKey] = useState(null); + const [dropTarget, setDropTarget] = useState<{ section: Section; index: number } | null>(null); + + const resetDrag = () => { + setDragKey(null); + setDropTarget(null); + }; + + const handleDrop = () => { + if (dragKey && dropTarget) { + const { section, index } = dropTarget; + // Rebuild the buckets without the dragged key, then insert it at the drop + // position, and flatten back to a single order. Setting the order to the + // grouped layout keeps it consistent with how the table renders. + const next: Record = { + left: buckets.left.filter((k) => k !== dragKey), + scrollable: buckets.scrollable.filter((k) => k !== dragKey), + right: buckets.right.filter((k) => k !== dragKey), + }; + const target = next[section]; + target.splice(Math.max(0, Math.min(index, target.length)), 0, dragKey); + setColumnOrder([...next.left, ...next.scrollable, ...next.right]); + setPin(dragKey, SECTION_PIN[section]); + } + resetDrag(); + }; + + const dropLineAt = (section: Section, index: number) => + dragKey != null && dropTarget?.section === section && dropTarget.index === index; + + const renderRow = (key: string, section: Section, index: number) => ( +
setDragKey(key)} + onDragEnd={resetDrag} + onDragOver={(e) => { + e.preventDefault(); + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + const after = e.clientY > rect.top + rect.height / 2; + setDropTarget({ section, index: after ? index + 1 : index }); + }} + className={cn( + "astw:flex astw:items-center astw:gap-2 astw:rounded-sm astw:py-1 astw:pr-2 astw:pl-1.5 astw:hover:bg-accent", + dragKey === key && "astw:opacity-40", + )} + > + + + + toggleColumn(key)} + aria-label={meta.label.get(key)} + className={CHECKBOX_CLASS} + > + + + + + +
+ ); + + const renderSection = (section: Section, title: string) => { + const keys = buckets[section]; + const active = dragKey != null && dropTarget?.section === section; + return ( +
{ + e.preventDefault(); + // Bare section area (header / padding / empty) → drop at the end. + setDropTarget({ section, index: keys.length }); + }} + onDrop={(e) => { + e.preventDefault(); + handleDrop(); + }} + className={cn( + "astw:flex astw:flex-col astw:gap-0.5 astw:rounded-md astw:py-1.5", + active && "astw:bg-accent/40", + )} + > +
+ {title} +
+ {keys.length === 0 ? ( +
+ {t("dropColumnsHere")} +
+ ) : ( + keys.map((key, i) => ( +
+ {dropLineAt(section, i) && } + {renderRow(key, section, i)} +
+ )) + )} + {keys.length > 0 && dropLineAt(section, keys.length) && } +
+ ); + }; + + return ( + + + + {t("columns")} + + } + /> + {/* Stacking context on the portal container so the popup renders above the + DataTable's sticky header row (z-index: 10). */} + + + +
+ {renderSection("left", t("sectionPinnedLeft"))} +
+ {renderSection("scrollable", t("sectionScrollable"))} +
+ {renderSection("right", t("sectionPinnedRight"))} +
+
+ + +
+
+
+
+
+
+ ); +} +DataTableColumnSettings.displayName = "DataTable.ColumnSettings"; + +export { DataTableColumnSettings }; diff --git a/packages/core/src/components/data-table/data-table-context.tsx b/packages/core/src/components/data-table/data-table-context.tsx index 8e9fd4cf..84d6919b 100644 --- a/packages/core/src/components/data-table/data-table-context.tsx +++ b/packages/core/src/components/data-table/data-table-context.tsx @@ -51,9 +51,16 @@ export interface DataTableContextValue> { showAllColumns: () => void; hideAllColumns: () => void; isColumnVisible: (fieldOrId: string) => boolean; + columnOrder: string[]; + moveColumn: (key: string, toIndex: number) => void; + setColumnOrder: (keys: string[]) => void; + pinnedColumns: Record; + setPin: (key: string, side: "left" | "right" | "none" | null) => void; // Row interaction onClickRow?: (row: TRow) => void; + rowHref?: (row: TRow) => string; + primaryColumnId?: string; rowActions?: RowAction[]; // Row selection diff --git a/packages/core/src/components/data-table/data-table.test.tsx b/packages/core/src/components/data-table/data-table.test.tsx index 277c38e9..681e5eb1 100644 --- a/packages/core/src/components/data-table/data-table.test.tsx +++ b/packages/core/src/components/data-table/data-table.test.tsx @@ -1,6 +1,6 @@ import { afterEach, describe, it, expect, vi } from "vitest"; import { cleanup, render, screen, fireEvent } from "@testing-library/react"; -import { MemoryRouter } from "react-router"; +import { MemoryRouter, useLocation } from "react-router"; import type { ReactNode } from "react"; import { createAppShellWrapper } from "../../../tests/test-utils"; import { DataTable } from "./data-table"; @@ -55,6 +55,11 @@ function TestDataTable(props: { const wrapper = createAppShellWrapper("en"); +const headByText = (container: HTMLElement, text: string) => + Array.from(container.querySelectorAll('[data-slot="data-table-header"] th')).find( + (th) => th.textContent?.trim() === text, + ); + // When a cell is wrapped in `Tooltip.Trigger`, Base UI tags the trigger // element with a generated `base-ui-…` id (used for the popup's // `aria-describedby` pointer). We assert that id's presence as the @@ -420,7 +425,7 @@ describe("DataTable", () => { // Column truncate // ------------------------------------------------------------------------- describe("column truncate", () => { - it("adds truncate + max-w-0 to body cells when truncate=true", () => { + it("constrains the cell width and truncates in an inner span when truncate=true", () => { const cols: Column[] = [ { label: "Name", @@ -435,9 +440,13 @@ describe("DataTable", () => { }); const firstRow = container.querySelector('[data-slot="data-table-row"]'); const cells = firstRow?.querySelectorAll('[data-slot="data-table-cell"]') ?? []; - expect(cells[0]?.className).toContain("truncate"); + // The cell keeps the width constraint but NOT `overflow: hidden` (which would + // clip a pinned column's freeze shadow); truncation moves to an inner span. expect(cells[0]?.className).toContain("max-w-0"); - expect(cells[1]?.className).not.toContain("truncate"); + expect(cells[0]?.className).not.toContain("astw:truncate"); + expect(cells[0]?.querySelector('span[class*="truncate"]')).toBeTruthy(); + expect(cells[1]?.className).not.toContain("max-w-0"); + expect(cells[1]?.querySelector('span[class*="truncate"]')).toBeFalsy(); }); it("wires a Tooltip when accessor returns a string", () => { @@ -508,8 +517,8 @@ describe("DataTable", () => { } const { container } = render(, { wrapper }); const cell = container.querySelector('[data-slot="data-table-cell"]'); - // Truncate classes still apply, but no Tooltip wraps the cell. - expect(cell?.className).toContain("truncate"); + // Truncation still applies (inner span), but no Tooltip wraps the cell. + expect(cell?.querySelector('span[class*="truncate"]')).toBeTruthy(); expect(isTooltipWired(cell)).toBe(false); }); @@ -525,7 +534,7 @@ describe("DataTable", () => { wrapper, }); const cell = container.querySelector('[data-slot="data-table-cell"]'); - expect(cell?.className).toContain("truncate"); + expect(cell?.querySelector('span[class*="truncate"]')).toBeTruthy(); expect(isTooltipWired(cell)).toBe(false); }); @@ -1094,4 +1103,151 @@ describe("DataTable", () => { expect(onSelectionChange).toHaveBeenCalledWith(["1", "2"]); }); }); + + // ------------------------------------------------------------------------- + // Sticky / pinned columns + // ------------------------------------------------------------------------- + describe("pinned columns", () => { + type Row = { id: string; a: string; b: string; c: string }; + const rows: Row[] = [{ id: "1", a: "A1", b: "B1", c: "C1" }]; + const pinnedCols: Column[] = [ + { id: "a", label: "A", width: 100, pin: "left", render: (r) => r.a }, + { id: "b", label: "B", width: 100, render: (r) => r.b }, + { id: "c", label: "C", width: 100, pin: "right", render: (r) => r.c }, + ]; + + function PinHarness(props: { onSelectionChange?: (ids: string[]) => void }) { + const table = useDataTable({ + columns: pinnedCols, + data: { rows }, + onSelectionChange: props.onSelectionChange, + }); + return ( + + + + ); + } + + it("applies sticky positioning + edge offsets to pinned columns", () => { + const { container } = render(, { wrapper }); + const a = headByText(container, "A"); + const c = headByText(container, "C"); + expect(a?.style.position).toBe("sticky"); + expect(a?.style.left).toBe("0px"); + expect(c?.style.position).toBe("sticky"); + expect(c?.style.right).toBe("0px"); + // Non-pinned column is not sticky. + expect(headByText(container, "B")?.style.position).toBe(""); + }); + + it("offsets a left-pinned column past the auto-pinned selection column", () => { + const { container } = render( {}} />, { wrapper }); + // Selection column auto-pins to the left edge; column A stacks after it. + const a = headByText(container, "A"); + expect(a?.style.left).toBe("52px"); + }); + + it("marks the boundary pinned cell with a scroll-aware freeze shadow", () => { + const { container } = render(, { wrapper }); + // The shadow pseudo-element is revealed only when the container is scrolled + // under that edge (data-pin-shadow-left / -right toggled by DataTable.Table). + expect(headByText(container, "A")?.className).toContain("data-pin-shadow-left"); + expect(headByText(container, "C")?.className).toContain("data-pin-shadow-right"); + }); + + it("ignores a pin without a width and warns", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const cols: Column[] = [ + { id: "a", label: "A", pin: "left", render: (r) => r.a }, + { id: "b", label: "B", width: 100, render: (r) => r.b }, + ]; + function Harness() { + const table = useDataTable({ columns: cols, data: { rows } }); + return ( + + + + ); + } + const { container } = render(, { wrapper }); + expect(headByText(container, "A")?.style.position).toBe(""); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("no width")); + warn.mockRestore(); + }); + }); + + // ------------------------------------------------------------------------- + // Row-click navigation (rowHref) + // ------------------------------------------------------------------------- + describe("rowHref navigation", () => { + type Row = { id: string; name: string; status: string }; + const rows: Row[] = [ + { id: "1", name: "Alice", status: "Active" }, + { id: "2", name: "Bob", status: "Inactive" }, + ]; + const cols: Column[] = [ + { id: "name", label: "Name", render: (r) => r.name }, + { id: "status", label: "Status", render: (r) => r.status }, + ]; + + function LocationProbe() { + const loc = useLocation(); + return
{loc.pathname}
; + } + + function NavHarness(props: { onClickRow?: (row: Row) => void; primaryColumnId?: string }) { + const table = useDataTable({ + columns: cols, + data: { rows }, + rowHref: (row) => `/orders/${row.id}`, + onClickRow: props.onClickRow, + primaryColumnId: props.primaryColumnId, + }); + return ( + + + + + + + ); + } + + it("renders the primary cell as a single link per row", () => { + const { container } = render(, { wrapper }); + const bodyRows = container.querySelectorAll('[data-slot="data-table-row"]'); + const firstRowAnchors = bodyRows[0].querySelectorAll("a"); + expect(firstRowAnchors).toHaveLength(1); + expect(firstRowAnchors[0].getAttribute("href")).toBe("/orders/1"); + expect(firstRowAnchors[0].textContent).toBe("Alice"); + }); + + it("navigates when the row is clicked", () => { + const { container } = render(, { wrapper }); + const cell = container.querySelectorAll('[data-slot="data-table-cell"]')[1]; // Status cell + fireEvent.click(cell); + expect(screen.getByTestId("loc").textContent).toBe("/orders/1"); + }); + + it("honours primaryColumnId for the link cell", () => { + const { container } = render(, { wrapper }); + const firstRowAnchor = container + .querySelector('[data-slot="data-table-row"]') + ?.querySelector("a"); + expect(firstRowAnchor?.textContent).toBe("Active"); + }); + + it("warns and lets rowHref win when onClickRow is also provided", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const onClickRow = vi.fn(); + const { container } = render(, { wrapper }); + const cell = container.querySelectorAll('[data-slot="data-table-cell"]')[1]; + fireEvent.click(cell); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("rowHref` wins")); + expect(onClickRow).not.toHaveBeenCalled(); + expect(screen.getByTestId("loc").textContent).toBe("/orders/1"); + warn.mockRestore(); + }); + }); }); diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index b8c6530a..413dcc19 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -1,4 +1,13 @@ -import { useContext, type ReactNode } from "react"; +import { + useContext, + useEffect, + useMemo, + useRef, + type CSSProperties, + type MouseEvent, + type ReactNode, +} from "react"; +import { Link, useNavigate } from "react-router"; import { Ellipsis } from "lucide-react"; import { Checkbox } from "@base-ui/react/checkbox"; import { Check, Minus } from "lucide-react"; @@ -14,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"; @@ -42,13 +52,181 @@ function nextSortDirection(current: string | undefined): "Asc" | "Desc" | undefi return "Asc"; } +// ============================================================================= +// Column pinning (sticky columns) +// ============================================================================= + +// Fixed widths of the built-in selection / row-actions columns, used as the +// innermost anchors when computing cumulative sticky offsets. +const SELECTION_WIDTH = 52; +const ACTIONS_WIDTH = 50; + +type PinSide = "left" | "right"; + +interface PinPlacement { + side: PinSide; + /** Distance from the pinned edge, in px. */ + offset: number; + /** True for the cell at the freeze seam — it draws the divider border. */ + isBoundary: boolean; +} + +interface PinLayout> { + /** Visible columns reordered into `[left-pinned, unpinned, right-pinned]`. */ + ordered: Column[]; + /** Sticky placement per pinned column (keyed by column reference). */ + placements: Map, PinPlacement>; + /** Placement for the built-in selection column, when pinned. */ + selection?: PinPlacement; + /** Placement for the built-in row-actions column, when pinned. */ + actions?: PinPlacement; +} + +function columnKeyAt>( + col: Column, + index: number, +): string { + return col.id ?? col.label ?? String(index); +} + +/** + * Resolve a column's effective pin: the per-user override wins over the static + * default. `"none"` is an explicit unpin (overrides a pinned default). + */ +function resolvePin( + stored: "left" | "right" | "none" | undefined, + defaultPin: PinSide | undefined, +): PinSide | undefined { + if (stored === "none") return undefined; + return stored ?? defaultPin; +} + +function effectivePin>( + col: Column, + index: number, + pinnedColumns: Record, +): PinSide | undefined { + return resolvePin(pinnedColumns[columnKeyAt(col, index)], col.pin); +} + +/** + * Group the visible columns by pin side and compute cumulative sticky offsets. + * The selection column (if present) auto-pins to the left edge and row-actions + * (if present) auto-pins to the right edge, with user-pinned columns stacking + * outward from them. + */ +function computePinLayout>( + columns: Column[], + pinnedColumns: Record, + opts: { hasSelection: boolean; hasRowActions: boolean }, +): PinLayout { + const left: Column[] = []; + const middle: Column[] = []; + const right: Column[] = []; + + columns.forEach((col, index) => { + const pin = effectivePin(col, index, pinnedColumns); + // Offsets are derived from column widths; a pin without a width can't take + // part, so fail soft (unpinned) with a dev warning rather than break layout. + if (pin && !col.width) { + console.warn( + `[DataTable] Column "${columnKeyAt(col, index)}" has pin="${pin}" but no width; ignoring the pin. Pinned columns must set an explicit width.`, + ); + middle.push(col); + return; + } + if (pin === "left") left.push(col); + else if (pin === "right") right.push(col); + else middle.push(col); + }); + + const placements = new Map, PinPlacement>(); + + // Left group, visually [selection?, ...left]; offsets accumulate rightward. + let leftOffset = 0; + let selection: PinPlacement | undefined; + if (opts.hasSelection) { + selection = { side: "left", offset: 0, isBoundary: left.length === 0 }; + leftOffset = SELECTION_WIDTH; + } + left.forEach((col, i) => { + placements.set(col, { side: "left", offset: leftOffset, isBoundary: i === left.length - 1 }); + leftOffset += col.width ?? 0; + }); + + // Right group, visually [...right, actions?]; offsets accumulate leftward. + let rightOffset = 0; + let actions: PinPlacement | undefined; + if (opts.hasRowActions) { + actions = { side: "right", offset: 0, isBoundary: right.length === 0 }; + rightOffset = ACTIONS_WIDTH; + } + for (let i = right.length - 1; i >= 0; i--) { + const col = right[i]; + placements.set(col, { side: "right", offset: rightOffset, isBoundary: i === 0 }); + rightOffset += col.width ?? 0; + } + + return { ordered: [...left, ...middle, ...right], placements, selection, actions }; +} + +/** + * Merge sticky positioning + background + boundary-divider styles onto a cell's + * existing style/className. Pinned body cells stay opaque across hover/selected + * states so scrolled content never bleeds through them. + */ +function pinCellProps( + placement: PinPlacement | undefined, + base: { style?: CSSProperties; className?: string }, + variant: "header" | "body", +): { style?: CSSProperties; className?: string } { + if (!placement) return base; + const style: CSSProperties = { + ...base.style, + position: "sticky", + [placement.side]: placement.offset, + }; + const className = cn( + base.className, + // Below the sticky header (z-10) but above non-pinned scrolling cells. + "astw:z-[1]", + variant === "header" + ? // The header's bottom hairline is drawn by the thead inset-shadow, which + // the opaque pinned background covers. Redraw it with a `::before` (which + // renders under `border-collapse`, where a cell border would be dropped on + // the sticky header row). Body cells don't need this — the row's collapsed + // border already paints over the cell background. + "astw:bg-card astw:before:pointer-events-none astw:before:absolute astw:before:inset-x-0 astw:before:bottom-0 astw:before:h-px astw:before:bg-border astw:before:content-['']" + : cn( + "astw:bg-card", + // Exact opaque equivalent of the row's `bg-muted/50` hover so pinned + // cells match the rest of the row; theme-aware via the tokens. + "astw:group-hover:[background-color:color-mix(in_srgb,var(--muted)_50%,var(--card))]", + "astw:group-aria-selected:bg-muted", + ), + // Freeze-seam shadow via a pseudo-element gradient. A real `box-shadow` is + // dropped by browsers on cells under `border-collapse: collapse` (the table's + // model), so we paint a gradient just outside the boundary edge instead — it + // sits above the scrolling cells and reads as depth. Hidden at rest; revealed + // only while content is scrolled under that edge (data attributes set on the + // scroll container by DataTable.Table). + placement.isBoundary && + placement.side === "left" && + "astw:after:pointer-events-none astw:after:absolute astw:after:inset-y-0 astw:after:right-0 astw:after:w-1 astw:after:translate-x-full astw:after:bg-gradient-to-r astw:after:from-black/10 astw:dark:after:from-black/20 astw:after:to-transparent astw:after:content-[''] astw:after:opacity-0 astw:after:transition-opacity astw:[[data-pin-shadow-left]_&]:after:opacity-100", + placement.isBoundary && + placement.side === "right" && + "astw:after:pointer-events-none astw:after:absolute astw:after:inset-y-0 astw:after:left-0 astw:after:w-1 astw:after:-translate-x-full astw:after:bg-gradient-to-l astw:after:from-black/10 astw:dark:after:from-black/20 astw:after:to-transparent astw:after:content-[''] astw:after:opacity-0 astw:after:transition-opacity astw:[[data-pin-shadow-right]_&]:after:opacity-100", + ); + return { style, className }; +} + // ============================================================================= // DataTableLoaderRows (internal) // ============================================================================= interface DataTableLoaderRowsProps> { rowCount: number; - columns: Column[] | undefined; + pinLayout: PinLayout; hasSelection: boolean; hasRowActions: boolean; } @@ -59,10 +237,11 @@ const SKELETON_WIDTHS = [75, 55, 85, 65, 70]; /** @internal */ function DataTableLoaderRows>({ rowCount, - columns, + pinLayout, hasSelection, hasRowActions, }: DataTableLoaderRowsProps) { + const { ordered: columns, placements, selection, actions } = pinLayout; // No fixed row height: each cell's placeholder matches the height of the // real content it stands in for (text line, badge, icon button), so the // skeleton rows resolve to exactly the same row height as loaded rows and @@ -70,18 +249,31 @@ function DataTableLoaderRows>({ return ( <> {Array.from({ length: rowCount }).map((_, rowIndex) => ( - - {hasSelection && ( - -
- - )} + + {hasSelection && + (() => { + const { style, className } = pinCellProps( + selection, + { style: { width: SELECTION_WIDTH }, className: "astw:pl-3!" }, + "body", + ); + return ( + +
+ + ); + })()} {columns?.map((col, colIndex) => { - const key = col.id ?? col.label ?? String(colIndex); + const key = columnKeyAt(col, colIndex); const skeletonWidth = SKELETON_WIDTHS[(rowIndex + colIndex) % SKELETON_WIDTHS.length]; const isBadge = col.type === "badge"; + const { style, className } = pinCellProps( + placements.get(col), + { style: col.width ? { width: col.width } : undefined }, + "body", + ); return ( - +
>({ ); })} - {hasRowActions && ( - - {/* size-9 box = the real icon Button's footprint; the visible - pulse stays 24px to read as an ellipsis placeholder */} -
-
-
- - )} + {hasRowActions && + (() => { + const { style, className } = pinCellProps( + actions, + { style: { width: ACTIONS_WIDTH } }, + "body", + ); + return ( + + {/* size-9 box = the real icon Button's footprint; the visible + pulse stays 24px to read as an ellipsis placeholder */} +
+
+
+ + ); + })()} ))} @@ -169,6 +369,11 @@ function DataTableRoot>({ toggleColumn: value.toggleColumn, showAllColumns: value.showAllColumns, hideAllColumns: value.hideAllColumns, + columnOrder: value.columnOrder, + moveColumn: value.moveColumn, + setColumnOrder: value.setColumnOrder, + pinnedColumns: value.pinnedColumns, + setPin: value.setPin, pageInfo: value.pageInfo, total: value.total, totalPages: value.totalPages, @@ -182,6 +387,8 @@ function DataTableRoot>({ hasPrevPage: value.hasPrevPage, hasNextPage: value.hasNextPage, onClickRow: value.onClickRow, + rowHref: value.rowHref, + primaryColumnId: value.primaryColumnId, rowActions: value.rowActions, selectedIds: value.selectedIds, isRowSelected: value.isRowSelected, @@ -229,13 +436,14 @@ DataTableRoot.displayName = "DataTable.Root"; // ============================================================================= /** @internal */ -function DataTableHeaders({ className }: { className?: string }) { +function DataTableHeaders({ className: headerClassName }: { className?: string }) { const ctx = useContext(DataTableContext); if (!ctx) { throw new Error(" must be used within "); } const { visibleColumns: columns, + pinnedColumns, sortStates, onSort, rowActions, @@ -247,6 +455,11 @@ function DataTableHeaders({ className }: { className?: string }) { } = ctx; const t = useDataTableT(); const hasSelection = !!toggleRowSelection; + const hasRowActions = !!(rowActions && rowActions.length > 0); + const { ordered, placements, selection, actions } = useMemo( + () => computePinLayout(columns, pinnedColumns, { hasSelection, hasRowActions }), + [columns, pinnedColumns, hasSelection, hasRowActions], + ); return ( // Sticky within the DataTable.Table scroll container so column headers @@ -258,41 +471,49 @@ function DataTableHeaders({ className }: { className?: string }) { className={cn( "astw:sticky astw:top-0 astw:z-10 astw:bg-card", "astw:shadow-[inset_0_-1px_0_0_var(--border)] astw:[&_tr]:border-b-0", - className, + headerClassName, )} > - - {hasSelection && ( - - { - if (checked) { - selectAllRows?.(); - } else { - clearSelection?.(); - } - }} - aria-label={t("selectAll")} - className={cn( - "astw:flex astw:size-4 astw:items-center astw:justify-center astw:rounded-xs astw:border astw:border-input", - "astw:data-checked:bg-primary astw:data-checked:border-primary astw:data-checked:text-primary-foreground", - "astw:data-indeterminate:bg-primary astw:data-indeterminate:border-primary astw:data-indeterminate:text-primary-foreground", - )} - > - - {isIndeterminate ? ( - - ) : ( - - )} - - - - )} - {columns?.map((col, colIndex) => { - const key = col.id ?? col.label ?? String(colIndex); + + {hasSelection && + (() => { + const { style, className } = pinCellProps( + selection, + { style: { width: SELECTION_WIDTH }, className: "astw:pl-3!" }, + "header", + ); + return ( + + { + if (checked) { + selectAllRows?.(); + } else { + clearSelection?.(); + } + }} + aria-label={t("selectAll")} + className={cn( + "astw:flex astw:size-4 astw:items-center astw:justify-center astw:rounded-xs astw:border astw:border-input", + "astw:data-checked:bg-primary astw:data-checked:border-primary astw:data-checked:text-primary-foreground", + "astw:data-indeterminate:bg-primary astw:data-indeterminate:border-primary astw:data-indeterminate:text-primary-foreground", + )} + > + + {isIndeterminate ? ( + + ) : ( + + )} + + + + ); + })()} + {ordered?.map((col, colIndex) => { + const key = columnKeyAt(col, colIndex); const label = col.label; const isSortable = !!col.sort; @@ -306,14 +527,22 @@ function DataTableHeaders({ className }: { className?: string }) { }; const align = resolveAlign(col); + const { style, className } = pinCellProps( + placements.get(col), + { + style: col.width ? { width: col.width } : undefined, + className: cn( + isSortable && "astw:cursor-pointer astw:select-none", + align === "right" && "astw:text-right", + ), + }, + "header", + ); return ( ); })} - {rowActions && rowActions.length > 0 && ( - - {t("actionsHeader")} - - )} + {hasRowActions && + (() => { + const { style, className } = pinCellProps( + actions, + { style: { width: ACTIONS_WIDTH } }, + "header", + ); + return ( + + {t("actionsHeader")} + + ); + })()} ); @@ -364,10 +601,13 @@ function DataTableBody({ className }: { className?: string }) { } const { visibleColumns: columns, + pinnedColumns, rows, loading, error, onClickRow, + rowHref, + primaryColumnId, rowActions, isRowSelected, toggleRowSelection, @@ -378,6 +618,10 @@ function DataTableBody({ className }: { className?: string }) { const hasSelection = !!toggleRowSelection; const totalColSpan = (columns?.length ?? 1) + (hasRowActions ? 1 : 0) + (hasSelection ? 1 : 0); const rowCount = pageSize > 0 ? pageSize : DEFAULT_ROWS; + const pinLayout = useMemo( + () => computePinLayout(columns, pinnedColumns, { hasSelection, hasRowActions }), + [columns, pinnedColumns, hasSelection, hasRowActions], + ); const tableBodyProps = { "data-slot": "data-table-body", className, @@ -388,7 +632,7 @@ function DataTableBody({ className }: { className?: string }) { @@ -418,8 +662,92 @@ function DataTableBody({ className }: { className?: string }) { ); } + const listProps: DataTableRowListProps> = { + rows, + pinLayout, + hasSelection, + hasRowActions, + isRowSelected, + toggleRowSelection, + rowActions, + rowHref, + primaryColumnId, + onClickRow, + }; + return ( + {/* When rowHref is set the rows navigate via react-router — split into a + child that calls useNavigate so the hook never runs in a non-routed + (static) table. */} + {rowHref ? : } + + ); +} +DataTableBody.displayName = "DataTable.Body"; + +// ============================================================================= +// Row rendering (shared by static and navigable variants) +// ============================================================================= + +interface DataTableRowListProps> { + rows: TRow[]; + pinLayout: PinLayout; + hasSelection: boolean; + hasRowActions: boolean; + isRowSelected: (row: TRow) => boolean; + toggleRowSelection?: (row: TRow) => void; + rowActions?: RowAction[]; + rowHref?: (row: TRow) => string; + primaryColumnId?: string; + onClickRow?: (row: TRow) => void; +} + +/** Renders rows with `rowHref` navigation wired through `useNavigate`. */ +function NavigableRows>(props: DataTableRowListProps) { + const navigate = useNavigate(); + return ; +} + +/** @internal */ +function DataTableRowList>({ + rows, + pinLayout, + hasSelection, + hasRowActions, + isRowSelected, + toggleRowSelection, + rowActions, + rowHref, + primaryColumnId, + onClickRow, + navigate, +}: DataTableRowListProps & { navigate?: (to: string) => void }) { + const t = useDataTableT(); + const { ordered, placements, selection, actions } = pinLayout; + const clickable = !!rowHref || !!onClickRow; + + // The primary cell carries the rowHref . Default to the first visible + // column; `primaryColumnId` overrides. + const primaryKey = rowHref + ? (primaryColumnId ?? (ordered.length > 0 ? columnKeyAt(ordered[0], 0) : undefined)) + : undefined; + + const handleRowClick = (e: MouseEvent, row: TRow) => { + if (rowHref && navigate) { + // Let the browser handle modifier/middle clicks (open in new tab) and + // clicks that landed on a link (the primary or a link cell). + if (e.defaultPrevented) return; + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + if ((e.target as HTMLElement).closest("a")) return; + navigate(rowHref(row)); + return; + } + onClickRow?.(row); + }; + + return ( + <> {rows.map((row, rowIndex) => { const rowId = (row as Record)["id"]; const selected = isRowSelected?.(row) ?? false; @@ -428,38 +756,79 @@ function DataTableBody({ className }: { className?: string }) { key={rowId != null ? String(rowId) : rowIndex} data-slot="data-table-row" aria-selected={hasSelection ? selected : undefined} - className={cn(onClickRow && "astw:cursor-pointer")} - onClick={onClickRow ? () => onClickRow(row) : undefined} + className={cn("astw:group", clickable && "astw:cursor-pointer")} + onClick={clickable ? (e) => handleRowClick(e, row) : undefined} > - {hasSelection && ( - e.stopPropagation()} - > - toggleRowSelection(row)} - aria-label={t("selectRow")} - className={cn( - "astw:flex astw:size-4 astw:items-center astw:justify-center astw:rounded-xs astw:border astw:border-input", - "astw:data-checked:bg-primary astw:data-checked:border-primary astw:data-checked:text-primary-foreground", - )} - > - - - - - - )} - {columns?.map((col, colIndex) => { - const key = col.id ?? col.label ?? String(colIndex); - const content = col.render ? col.render(row) : renderTypedCell(row, col); - const cellClassName = cn( - resolveAlign(col) === "right" && "astw:text-right", - col.truncate && "astw:truncate astw:max-w-0", + {hasSelection && + toggleRowSelection && + (() => { + const { style, className } = pinCellProps( + selection, + { style: { width: SELECTION_WIDTH }, className: "astw:pl-3!" }, + "body", + ); + return ( + e.stopPropagation()} + > + toggleRowSelection(row)} + aria-label={t("selectRow")} + className={cn( + "astw:flex astw:size-4 astw:items-center astw:justify-center astw:rounded-xs astw:border astw:border-input", + "astw:data-checked:bg-primary astw:data-checked:border-primary astw:data-checked:text-primary-foreground", + )} + > + + + + + + ); + })()} + {ordered?.map((col, colIndex) => { + const key = columnKeyAt(col, colIndex); + const rendered = col.render ? col.render(row) : renderTypedCell(row, col); + const isPrimary = primaryKey !== undefined && key === primaryKey; + // Wrap the primary cell in a real for keyboard/SR access and + // new-tab support. The row's closest("a") guard prevents a second + // programmatic navigation when this link is clicked. + const content = + rowHref && isPrimary ? ( + + {rendered} + + ) : ( + rendered + ); + + const { style: cellStyle, className: cellClassName } = pinCellProps( + placements.get(col), + { + style: col.width ? { width: col.width } : undefined, + className: cn( + resolveAlign(col) === "right" && "astw:text-right", + // Keep the width constraint on the cell, but move the + // `overflow: hidden` truncation to an inner span (below) — a + // truncating cell would otherwise clip the freeze-shadow + // `::after`, which is drawn just outside the cell edge. + col.truncate && "astw:max-w-0", + ), + }, + "body", + ); + // Truncate via an inner element so the cell's overflow stays visible. + const cellBody = col.truncate ? ( + {content} + ) : ( + content ); - const cellStyle = col.width ? { width: col.width } : undefined; // Surface the full value on hover when the cell is truncated // and the resolved cell value is a stringifiable primitive. @@ -488,7 +857,7 @@ function DataTableBody({ className }: { className?: string }) { /> } > - {content} + {cellBody} {tooltipLabel} @@ -502,22 +871,34 @@ function DataTableBody({ className }: { className?: string }) { style={cellStyle} className={cellClassName} > - {content} + {cellBody} ); })} - {hasRowActions && ( - e.stopPropagation()}> - - - )} + {hasRowActions && + rowActions && + (() => { + const { style, className } = pinCellProps( + actions, + { style: { width: ACTIONS_WIDTH } }, + "body", + ); + return ( + e.stopPropagation()} + > + + + ); + })()} ); })} - + ); } -DataTableBody.displayName = "DataTable.Body"; // ============================================================================= // RowActionsMenu (internal — uses app-shell Menu) @@ -573,6 +954,44 @@ function RowActionsMenu>({ /** Use `DataTable.Table` instead of calling this directly. */ function DataTableTable({ className }: { className?: string }) { + const ctx = useContext(DataTableContext); + const containerRef = useRef(null); + // When a column is pinned, the sticky offsets are computed from each column's + // declared `width`. Auto table layout treats `width` as a hint and can render + // columns narrower/wider than declared, which leaves gaps or overlaps between + // stacked sticky columns. `table-fixed` makes the declared widths + // authoritative so the offsets line up exactly. Scoped to pinned tables so + // non-pinned tables keep their natural content-based sizing. + const hasColumnPin = + !!ctx && + ctx.visibleColumns.some((col, i) => { + const key = col.id ?? col.label ?? String(i); + return resolvePin(ctx.pinnedColumns[key], col.pin) != null; + }); + + // Reflect horizontal scroll position onto the container as data attributes so + // the pinned-column freeze shadows can show only while there is content + // scrolled under that edge (left shadow once scrolled from the start; right + // shadow while more remains to the right). Re-runs when the column set changes + // (which changes scrollWidth) and observes size changes. + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const update = () => { + const maxScroll = el.scrollWidth - el.clientWidth; + el.toggleAttribute("data-pin-shadow-left", el.scrollLeft > 0); + el.toggleAttribute("data-pin-shadow-right", el.scrollLeft < maxScroll - 1); + }; + update(); + el.addEventListener("scroll", update, { passive: true }); + const observer = new ResizeObserver(update); + observer.observe(el); + return () => { + el.removeEventListener("scroll", update); + observer.disconnect(); + }; + }, [ctx?.visibleColumns, ctx?.pinnedColumns]); + return ( // min-h-0 lets the scroll container shrink within DataTable.Root's flex // column; combined with the container's overflow-auto this is the region @@ -580,8 +999,9 @@ function DataTableTable({ className }: { className?: string }) { // (DataTableHeaders) stays pinned to the top of this scrollport. @@ -632,6 +1052,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`. + */ + 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 9732593d..713cadda 100644 --- a/packages/core/src/components/data-table/i18n.ts +++ b/packages/core/src/components/data-table/i18n.ts @@ -13,6 +13,17 @@ export const dataTableLabels = defineI18nLabels({ // RowActionsMenu aria-label rowActions: "Row actions", + // Column settings (DataTable.ColumnSettings / DataTable.ColumnToggle) + columns: "Columns", + columnSettings: "Column settings", + showAllColumns: "Show all", + hideAllColumns: "Hide all", + dragToReorder: "Drag to reorder", + sectionPinnedLeft: "Fixed left", + sectionScrollable: "Scrollable", + sectionPinnedRight: "Fixed right", + dropColumnsHere: "Drag columns here", + // Row selection selectAll: "Select all rows", selectRow: "Select row", @@ -73,6 +84,18 @@ export const dataTableLabels = defineI18nLabels({ errorPrefix: "エラー:", actionsHeader: "操作", rowActions: "行の操作", + + // Column settings + columns: "列", + columnSettings: "列の設定", + showAllColumns: "すべて表示", + hideAllColumns: "すべて非表示", + dragToReorder: "ドラッグして並び替え", + sectionPinnedLeft: "左に固定", + sectionScrollable: "スクロール", + sectionPinnedRight: "右に固定", + dropColumnsHere: "ここに列をドラッグ", + selectAll: "全行を選択", selectRow: "行を選択", paginationFirst: "最初のページ", diff --git a/packages/core/src/components/data-table/types.ts b/packages/core/src/components/data-table/types.ts index 91b1478d..c609fffc 100644 --- a/packages/core/src/components/data-table/types.ts +++ b/packages/core/src/components/data-table/types.ts @@ -109,6 +109,17 @@ export interface ColumnBase> { id?: string; /** Fixed column width in pixels. When omitted the column sizes naturally. */ width?: number; + /** + * 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 `DataTable.ColumnSettings` (persisted when + * `tableId` is set). + * + * **Pinned columns must set `width`** — the sticky offset of each pinned + * column is computed from the widths of the columns pinned beside it. A `pin` + * without a `width` is ignored (with a dev warning). + */ + pin?: "left" | "right"; /** * Horizontal alignment for the header and body cell. When omitted, numeric * `type` values (`"number"` and `"money"`) default to `"right"` so digits @@ -241,8 +252,36 @@ export type UseDataTableOptions< * using `DataTable.Pagination` or `DataTable.Filters`. */ control?: CollectionControl; - /** Called when the user clicks a row. Adds a pointer cursor to rows. */ + /** + * Stable id used to persist per-user column layout (visibility, order, and + * pinning) to `localStorage`, keyed by this id. When omitted, column layout is + * in-memory only and resets on reload. + */ + tableId?: string; + /** + * Called when the user clicks a row. Adds a pointer cursor to rows. + * + * Prefer `rowHref` for **navigation** (it's keyboard/screen-reader reachable + * and supports cmd/middle-click). Use `onClickRow` for non-navigation side + * effects such as opening a drawer. Mutually exclusive with `rowHref` — if + * both are set, `rowHref` wins. + */ onClickRow?: (row: TRow) => void; + /** + * Makes the whole row navigate to the returned URL. The row's primary cell is + * rendered as a real ``, so the row is reachable by keyboard and screen + * readers and can be opened in a new tab (cmd/ctrl/middle click). Clicking + * anywhere else in the row navigates too. + * + * Requires a react-router context (it uses `useNavigate`/``); only set + * it inside a routed app. + */ + rowHref?: (row: TRow) => string; + /** + * Id (or label) of the column whose cell should carry the `rowHref` ``. + * Defaults to the first visible column. Ignored when `rowHref` is not set. + */ + primaryColumnId?: string; /** * Per-row action items rendered in a kebab-menu column on the right. * The column is omitted when this array is empty or not provided. @@ -323,6 +362,20 @@ export interface UseDataTableReturn> { showAllColumns: () => void; hideAllColumns: () => void; isColumnVisible: (fieldOrId: string) => boolean; + /** Column keys in display order (visible and hidden). */ + columnOrder: string[]; + /** Move the column with `key` to `toIndex` within the ordered column list. */ + moveColumn: (key: string, toIndex: number) => void; + /** Replace the full column order with `keys`. */ + setColumnOrder: (keys: string[]) => void; + /** Per-user pin overrides, keyed by column key (`"none"` = explicitly unpinned). */ + pinnedColumns: Record; + /** + * Set the pin for the column with `key`: `"left"`/`"right"` to pin, `"none"` to + * explicitly unpin (overriding a default `pin`), or `null` to clear the override + * and fall back to the column's default. + */ + setPin: (key: string, side: "left" | "right" | "none" | null) => void; /** * The resolved page size derived from the collection control. @@ -346,6 +399,8 @@ export interface UseDataTableReturn> { // Row interaction (passthrough for DataTable.Provider) onClickRow?: (row: TRow) => void; + rowHref?: (row: TRow) => string; + primaryColumnId?: string; rowActions?: RowAction[]; // Row selection diff --git a/packages/core/src/components/data-table/use-data-table.test.ts b/packages/core/src/components/data-table/use-data-table.test.ts index a32a27bf..e8c8b725 100644 --- a/packages/core/src/components/data-table/use-data-table.test.ts +++ b/packages/core/src/components/data-table/use-data-table.test.ts @@ -1,5 +1,5 @@ import { renderHook, act } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { useDataTable } from "./use-data-table"; import type { CollectionControl } from "@/types/collection"; import type { Column, DataTableData } from "./types"; @@ -247,6 +247,110 @@ describe("useDataTable", () => { }); }); + // ------------------------------------------------------------------------- + // Column order & pinning + // ------------------------------------------------------------------------- + describe("column order & pinning", () => { + it("columnOrder defaults to definition order", () => { + const { result } = renderHook(() => useDataTable({ columns, data: testData })); + expect(result.current.columnOrder).toEqual(["name", "value"]); + }); + + it("moveColumn reorders columnOrder and visibleColumns", () => { + const { result } = renderHook(() => useDataTable({ columns, data: testData })); + + act(() => { + result.current.moveColumn("value", 0); + }); + expect(result.current.columnOrder).toEqual(["value", "name"]); + expect(result.current.visibleColumns.map((c) => c.id)).toEqual(["value", "name"]); + }); + + it("keeps hidden columns hidden after a reorder", () => { + const { result } = renderHook(() => useDataTable({ columns, data: testData })); + + act(() => { + result.current.toggleColumn("name"); + }); + act(() => { + result.current.moveColumn("value", 0); + }); + expect(result.current.isColumnVisible("name")).toBe(false); + expect(result.current.visibleColumns.map((c) => c.id)).toEqual(["value"]); + }); + + it("setPin sets and clears a pin override", () => { + const { result } = renderHook(() => useDataTable({ columns, data: testData })); + + act(() => { + result.current.setPin("name", "left"); + }); + expect(result.current.pinnedColumns).toEqual({ name: "left" }); + + act(() => { + result.current.setPin("name", null); + }); + expect(result.current.pinnedColumns).toEqual({}); + }); + }); + + // ------------------------------------------------------------------------- + // Column state persistence (localStorage, keyed by tableId) + // ------------------------------------------------------------------------- + describe("column state persistence", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("persists visibility to localStorage keyed by tableId", () => { + const { result } = renderHook(() => useDataTable({ columns, data: testData, tableId: "t1" })); + + act(() => { + result.current.toggleColumn("name"); + }); + + const stored = JSON.parse(localStorage.getItem("astw:data-table:v1:t1") as string); + expect(stored.hidden).toContain("name"); + }); + + it("restores persisted state on remount with the same tableId", () => { + const first = renderHook(() => useDataTable({ columns, data: testData, tableId: "t1" })); + act(() => { + first.result.current.toggleColumn("name"); + first.result.current.setPin("value", "right"); + }); + first.unmount(); + + const { result } = renderHook(() => useDataTable({ columns, data: testData, tableId: "t1" })); + expect(result.current.isColumnVisible("name")).toBe(false); + expect(result.current.pinnedColumns).toEqual({ value: "right" }); + }); + + it("does not persist when tableId is absent", () => { + const { result } = renderHook(() => useDataTable({ columns, data: testData })); + act(() => { + result.current.toggleColumn("name"); + }); + expect(localStorage.length).toBe(0); + }); + + it("falls back to defaults on corrupt stored state", () => { + localStorage.setItem("astw:data-table:v1:t1", "{ not valid json"); + const { result } = renderHook(() => useDataTable({ columns, data: testData, tableId: "t1" })); + expect(result.current.visibleColumns).toHaveLength(2); + }); + + it("drops persisted keys no longer present and appends new columns", () => { + localStorage.setItem( + "astw:data-table:v1:t1", + JSON.stringify({ order: ["value", "gone"], hidden: [], pinned: {} }), + ); + const { result } = renderHook(() => useDataTable({ columns, data: testData, tableId: "t1" })); + // "gone" dropped (not in current columns); "name" appended after "value". + expect(result.current.columnOrder).toEqual(["value", "name"]); + }); + }); + // ------------------------------------------------------------------------- // Sort delegation // ------------------------------------------------------------------------- diff --git a/packages/core/src/components/data-table/use-data-table.ts b/packages/core/src/components/data-table/use-data-table.ts index b1daa688..049972ad 100644 --- a/packages/core/src/components/data-table/use-data-table.ts +++ b/packages/core/src/components/data-table/use-data-table.ts @@ -1,6 +1,7 @@ import { useCallback, useMemo, useState } from "react"; import type { CollectionControl, Filter, PageInfo, SortState } from "@/types/collection"; import { usePageCounter } from "./use-page-counter"; +import { usePersistentColumnState, type PersistedColumnState } from "./use-persistent-column-state"; import type { Column, UseDataTableOptions, UseDataTableReturn } from "./types"; /** @@ -45,12 +46,21 @@ export function useDataTable< loading = false, error = null, control, + tableId, onClickRow, + rowHref, + primaryColumnId, rowActions, onSelectionChange, sort: sortOption, } = options; + if (rowHref && onClickRow) { + console.warn( + "[DataTable] Both `rowHref` and `onClickRow` were provided; `rowHref` wins. Use `rowHref` for navigation and `onClickRow` only for non-navigation side effects.", + ); + } + // --------------------------------------------------------------------------- // Data extraction // --------------------------------------------------------------------------- @@ -80,44 +90,129 @@ export function useDataTable< const hasNextPage = control?.getHasNextPage(pageInfo) ?? pageInfo.hasNextPage; // --------------------------------------------------------------------------- - // Column visibility management + // Column visibility / order / pinning (persisted per-user when tableId is set) // --------------------------------------------------------------------------- - const [hiddenColumns, setHiddenColumns] = useState>(new Set()); - const getColumnKey = useCallback((col: Column, colIndex: number): string => { return col.id ?? col.label ?? String(colIndex); }, []); - const visibleColumns = useMemo[]>(() => { - return allColumns.filter((col, i) => !hiddenColumns.has(getColumnKey(col, i))); - }, [allColumns, hiddenColumns, getColumnKey]); - - const toggleColumn = useCallback((fieldOrId: string) => { - setHiddenColumns((prev) => { - const next = new Set(prev); - if (next.has(fieldOrId)) { - next.delete(fieldOrId); - } else { - next.add(fieldOrId); + // Stable key list + key→column map derived from the current column defs. + const columnKeys = useMemo( + () => allColumns.map((col, i) => getColumnKey(col, i)), + [allColumns, getColumnKey], + ); + const columnByKey = useMemo(() => { + const map = new Map>(); + allColumns.forEach((col, i) => map.set(getColumnKey(col, i), col)); + return map; + }, [allColumns, getColumnKey]); + + const defaultColumnState = useMemo( + () => ({ order: columnKeys, hidden: [], pinned: {} }), + [columnKeys], + ); + + const [persisted, setPersisted] = usePersistentColumnState(tableId, defaultColumnState); + + // Reconcile the persisted order against the current column defs: keep persisted + // keys that still exist (in their saved order), then append any new columns in + // definition order. Self-healing across columns added/removed between sessions. + const columnOrder = useMemo(() => { + const present = new Set(columnKeys); + const seen = new Set(); + const result: string[] = []; + for (const key of persisted.order) { + if (present.has(key) && !seen.has(key)) { + result.push(key); + seen.add(key); } - return next; - }); - }, []); + } + for (const key of columnKeys) { + if (!seen.has(key)) { + result.push(key); + seen.add(key); + } + } + return result; + }, [persisted.order, columnKeys]); + + const hiddenColumns = useMemo(() => new Set(persisted.hidden), [persisted.hidden]); + const pinnedColumns = persisted.pinned; + + const visibleColumns = useMemo[]>(() => { + return columnOrder + .filter((key) => !hiddenColumns.has(key)) + .map((key) => columnByKey.get(key)) + .filter((col): col is Column => col != null); + }, [columnOrder, hiddenColumns, columnByKey]); + + const toggleColumn = useCallback( + (fieldOrId: string) => { + setPersisted((prev) => { + const hidden = new Set(prev.hidden); + if (hidden.has(fieldOrId)) { + hidden.delete(fieldOrId); + } else { + hidden.add(fieldOrId); + } + return { ...prev, hidden: [...hidden] }; + }); + }, + [setPersisted], + ); const showAllColumns = useCallback(() => { - setHiddenColumns(new Set()); - }, []); + setPersisted((prev) => ({ ...prev, hidden: [] })); + }, [setPersisted]); const hideAllColumns = useCallback(() => { - const allKeys = new Set(allColumns.map((col, i) => getColumnKey(col, i))); - setHiddenColumns(allKeys); - }, [allColumns, getColumnKey]); + setPersisted((prev) => ({ ...prev, hidden: [...columnKeys] })); + }, [setPersisted, columnKeys]); const isColumnVisible = useCallback( (fieldOrId: string) => !hiddenColumns.has(fieldOrId), [hiddenColumns], ); + const moveColumn = useCallback( + (key: string, toIndex: number) => { + setPersisted((prev) => { + const order = [...columnOrder]; + const from = order.indexOf(key); + if (from === -1) return prev; + const clamped = Math.max(0, Math.min(toIndex, order.length - 1)); + order.splice(from, 1); + order.splice(clamped, 0, key); + return { ...prev, order }; + }); + }, + [setPersisted, columnOrder], + ); + + const setColumnOrder = useCallback( + (keys: string[]) => { + setPersisted((prev) => ({ ...prev, order: keys })); + }, + [setPersisted], + ); + + const setPin = useCallback( + (key: string, side: "left" | "right" | "none" | null) => { + setPersisted((prev) => { + const pinned = { ...prev.pinned }; + // `null` clears the override (reverts to the column's default `pin`); + // `"none"` explicitly unpins a column even if its default is pinned. + if (side === null) { + delete pinned[key]; + } else { + pinned[key] = side; + } + return { ...prev, pinned }; + }); + }, + [setPersisted], + ); + // --------------------------------------------------------------------------- // Pagination actions (delegated to control + page counter sync) // --------------------------------------------------------------------------- @@ -268,8 +363,15 @@ export function useDataTable< showAllColumns, hideAllColumns, isColumnVisible, + columnOrder, + moveColumn, + setColumnOrder, + pinnedColumns, + setPin, control: control as CollectionControl | undefined, onClickRow, + rowHref, + primaryColumnId, rowActions, selectedIds, isRowSelected, diff --git a/packages/core/src/components/data-table/use-persistent-column-state.ts b/packages/core/src/components/data-table/use-persistent-column-state.ts new file mode 100644 index 00000000..33909dff --- /dev/null +++ b/packages/core/src/components/data-table/use-persistent-column-state.ts @@ -0,0 +1,103 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +/** + * Per-user, per-table column layout persisted to `localStorage`. + * + * - `order` — column keys in display order. + * - `hidden` — column keys the user has hidden. + * - `pinned` — column key → edge the column is frozen to (`"none"` explicitly + * unpins a column whose default `pin` is set). + */ +export interface PersistedColumnState { + order: string[]; + hidden: string[]; + pinned: Record; +} + +// Namespace matches the Tailwind class prefix used repo-wide so table state is +// easy to spot in devtools. The `v1` segment lets a future shape change orphan +// old data by bumping the version instead of migrating it. +const STORAGE_PREFIX = "astw:data-table:v1:"; + +function storageKey(tableId: string): string { + return `${STORAGE_PREFIX}${tableId}`; +} + +function isPersistedColumnState(value: unknown): value is PersistedColumnState { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return ( + Array.isArray(v.order) && + Array.isArray(v.hidden) && + !!v.pinned && + typeof v.pinned === "object" && + !Array.isArray(v.pinned) + ); +} + +/** Best-effort read; returns `null` on SSR, missing, corrupt, or blocked storage. */ +function readState(tableId: string): PersistedColumnState | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(storageKey(tableId)); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + return isPersistedColumnState(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** Best-effort write; silently ignores SSR / quota / privacy-mode errors. */ +function writeState(tableId: string, state: PersistedColumnState): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(storageKey(tableId), JSON.stringify(state)); + } catch { + // ignore + } +} + +/** + * SSR-safe `localStorage`-backed column state. + * + * State initializes to `defaults` so server and first client render produce + * identical markup (no hydration mismatch); the persisted value is applied in an + * effect after mount. When `tableId` is `undefined` the hook is pure in-memory + * state — it never reads from or writes to storage. + */ +export function usePersistentColumnState( + tableId: string | undefined, + defaults: PersistedColumnState, +): [PersistedColumnState, (updater: (prev: PersistedColumnState) => PersistedColumnState) => void] { + const [state, setStateRaw] = useState(defaults); + + // Keep the latest defaults available to the hydrate effect without making it a + // dependency — `defaults` is recomputed every render and would thrash it. + const defaultsRef = useRef(defaults); + defaultsRef.current = defaults; + + // Hydrate from storage on mount / when the table id changes. Falls back to the + // current defaults when there's nothing stored (or the id was cleared). This + // read does NOT write back, so an existing stored value is never clobbered. + useEffect(() => { + if (!tableId) return; + setStateRaw(readState(tableId) ?? defaultsRef.current); + }, [tableId]); + + // Write imperatively on each user-driven update — never from an effect that + // watches `state`, which would fire on mount with the pre-hydration default + // and overwrite the stored value. + const setState = useCallback( + (updater: (prev: PersistedColumnState) => PersistedColumnState) => { + setStateRaw((prev) => { + const next = updater(prev); + if (tableId) writeState(tableId, next); + return next; + }); + }, + [tableId], + ); + + return [state, setState]; +} diff --git a/packages/core/src/components/table.tsx b/packages/core/src/components/table.tsx index 5d05a2f3..947ca285 100644 --- a/packages/core/src/components/table.tsx +++ b/packages/core/src/components/table.tsx @@ -5,6 +5,8 @@ import { cn } from "@/lib/utils"; type RootProps = React.ComponentProps<"table"> & { /** Additional CSS classes for the outer scrollable `
` container. Use this to control height, overflow, or container-level layout. */ containerClassName?: string; + /** Ref to the outer scrollable `
` container (e.g. to attach a scroll listener). */ + containerRef?: React.Ref; }; type CellAlign = "left" | "center" | "right"; @@ -40,9 +42,10 @@ const cellAlignClassName = { * * ``` */ -function Root({ className, containerClassName, ...props }: RootProps) { +function Root({ className, containerClassName, containerRef, ...props }: RootProps) { return (
From b886ab79e5cfcf10b75149a0ceb906a88a5fae0c Mon Sep 17 00:00:00 2001 From: itsprade Date: Mon, 13 Jul 2026 15:27:53 +0530 Subject: [PATCH 2/5] fix(data-table): stop ColumnSettings drop indicator from jumping while dragging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The insertion line was a flow element, so inserting it shifted the rows under the cursor, which recomputed the drop target on every reflow — a flicker loop. Draw the indicator as an inset box-shadow on the row instead (no reflow), and remove the inter-row gap so the end-of-section handler no longer fires as the cursor passes between rows. Empty zones get a dashed placeholder. Co-Authored-By: Claude Opus 4.8 --- .../components/data-table/column-settings.tsx | 125 ++++++++++-------- 1 file changed, 68 insertions(+), 57 deletions(-) diff --git a/packages/core/src/components/data-table/column-settings.tsx b/packages/core/src/components/data-table/column-settings.tsx index 0a010f71..df796a71 100644 --- a/packages/core/src/components/data-table/column-settings.tsx +++ b/packages/core/src/components/data-table/column-settings.tsx @@ -36,11 +36,6 @@ const SECTION_PIN: Record = { right: "right", }; -/** Insertion indicator shown at the active drop position. */ -function InsertLine() { - return
; -} - /** Resolve a column's effective pin (override wins; "none" is explicit unpin). */ function effectiveSection( stored: "left" | "right" | "none" | undefined, @@ -128,49 +123,63 @@ function DataTableColumnSettings({ className }: { className?: string }) { resetDrag(); }; - const dropLineAt = (section: Section, index: number) => - dragKey != null && dropTarget?.section === section && dropTarget.index === index; - - const renderRow = (key: string, section: Section, index: number) => ( -
setDragKey(key)} - onDragEnd={resetDrag} - onDragOver={(e) => { - e.preventDefault(); - e.stopPropagation(); - const rect = e.currentTarget.getBoundingClientRect(); - const after = e.clientY > rect.top + rect.height / 2; - setDropTarget({ section, index: after ? index + 1 : index }); - }} - className={cn( - "astw:flex astw:items-center astw:gap-2 astw:rounded-sm astw:py-1 astw:pr-2 astw:pl-1.5 astw:hover:bg-accent", - dragKey === key && "astw:opacity-40", - )} - > - - - - toggleColumn(key)} - aria-label={meta.label.get(key)} - className={CHECKBOX_CLASS} - > - - - - - -
- ); + + + + toggleColumn(key)} + aria-label={meta.label.get(key)} + className={CHECKBOX_CLASS} + > + + + + + +
+ ); + }; const renderSection = (section: Section, title: string) => { const keys = buckets[section]; @@ -180,7 +189,8 @@ function DataTableColumnSettings({ className }: { className?: string }) { data-section={section} onDragOver={(e) => { e.preventDefault(); - // Bare section area (header / padding / empty) → drop at the end. + // Only fires for the header / padding / empty area (rows stop + // propagation) → drop at the end of the section. setDropTarget({ section, index: keys.length }); }} onDrop={(e) => { @@ -188,7 +198,9 @@ function DataTableColumnSettings({ className }: { className?: string }) { handleDrop(); }} className={cn( - "astw:flex astw:flex-col astw:gap-0.5 astw:rounded-md astw:py-1.5", + // No gap between rows: a gap would be "section area" that re-triggers + // the end-of-section handler as the cursor passes between rows. + "astw:flex astw:flex-col astw:rounded-md astw:py-1.5", active && "astw:bg-accent/40", )} > @@ -196,18 +208,17 @@ function DataTableColumnSettings({ className }: { className?: string }) { {title}
{keys.length === 0 ? ( -
+
{t("dropColumnsHere")}
) : ( - keys.map((key, i) => ( -
- {dropLineAt(section, i) && } - {renderRow(key, section, i)} -
- )) + keys.map((key, i) => renderRow(key, section, i, i === keys.length - 1)) )} - {keys.length > 0 && dropLineAt(section, keys.length) && }
); }; From 4a0f9de0f00b62eba5ca1ca69da8444feb0806c5 Mon Sep 17 00:00:00 2001 From: itsprade Date: Mon, 13 Jul 2026 15:31:08 +0530 Subject: [PATCH 3/5] feat(data-table): tooltip on ColumnSettings visibility checkbox Hovering a column's checkbox shows "Hide column" (when visible) or "Show column" (when hidden). Adds showColumn/hideColumn i18n keys (en + ja). Co-Authored-By: Claude Opus 4.8 --- .../components/data-table/column-settings.tsx | 28 ++++++++++++------- .../core/src/components/data-table/i18n.ts | 4 +++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/core/src/components/data-table/column-settings.tsx b/packages/core/src/components/data-table/column-settings.tsx index df796a71..b0668ed5 100644 --- a/packages/core/src/components/data-table/column-settings.tsx +++ b/packages/core/src/components/data-table/column-settings.tsx @@ -4,6 +4,7 @@ import { Checkbox } from "@base-ui/react/checkbox"; import { Check, GripVertical, SlidersHorizontal } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/button"; +import { Tooltip } from "@/components/tooltip"; import { useDataTableContext } from "./data-table-context"; import { useDataTableT } from "./i18n"; @@ -160,16 +161,23 @@ function DataTableColumnSettings({ className }: { className?: string }) { > - toggleColumn(key)} - aria-label={meta.label.get(key)} - className={CHECKBOX_CLASS} - > - - - - + + toggleColumn(key)} + aria-label={meta.label.get(key)} + className={CHECKBOX_CLASS} + /> + } + > + + + + + {t(isColumnVisible(key) ? "hideColumn" : "showColumn")} + {t(isColumnVisible(key) ? "hideColumn" : "showColumn")} -
); }; From de74a416f820e29cbdbf5c1c1445df5f566735c4 Mon Sep 17 00:00:00 2001 From: itsprade Date: Tue, 14 Jul 2026 11:50:08 +0530 Subject: [PATCH 5/5] refactor(data-table): remove rowHref; scope PR to columns only Row interaction is being split out of this PR per review (@IzumiSy): the `rowHref` + `onClickRow` split is a confusing public API, and row interaction should be one explicit surface designed separately. Removes `rowHref` / `primaryColumnId` and the navigation rendering (NavigableRows / wrap / useNavigate), keeping the existing `onClickRow` untouched. `rowHref` was never released, so this is not a breaking change. Column visibility, sticky/pinned columns, ColumnSettings, tableId persistence, and the Table.Root containerRef are unchanged. Docs, catalogue skill, demo lab page, tests, and changeset updated to columns-only. Row-interaction redesign tracked in tailor-inc/platform-planning#1498. Co-Authored-By: Claude Opus 4.8 --- .changeset/data-table-columns-and-row-nav.md | 3 +- catalogue/src/fundamental/components.md | 6 +- .../src/pattern/list/dense-scan/PATTERN.md | 2 +- docs/components/data-table.md | 40 +++---- .../src/pages/data-table-lab/page.tsx | 88 ++------------- .../references/fundamental/components.md | 6 +- .../references/patterns/list-dense-scan.md | 2 +- .../components/data-table/cell-renderers.tsx | 8 +- .../data-table/data-table-context.tsx | 2 - .../components/data-table/data-table.test.tsx | 76 +------------ .../src/components/data-table/data-table.tsx | 102 ++++-------------- .../core/src/components/data-table/types.ts | 26 +---- .../components/data-table/use-data-table.ts | 10 -- 13 files changed, 52 insertions(+), 319 deletions(-) diff --git a/.changeset/data-table-columns-and-row-nav.md b/.changeset/data-table-columns-and-row-nav.md index 131a66a6..9579d848 100644 --- a/.changeset/data-table-columns-and-row-nav.md +++ b/.changeset/data-table-columns-and-row-nav.md @@ -2,10 +2,9 @@ "@tailor-platform/app-shell": minor --- -DataTable: sticky/pinned columns, user column settings, and accessible row-click navigation +DataTable: sticky/pinned columns and user column settings - **Pinned columns** — add `pin: "left" | "right"` to a `Column` (requires `width`) to freeze it during horizontal scroll. The selection column auto-pins left and the row-actions column auto-pins right, and a subtle freeze shadow appears at the frozen edge once content is scrolled under it. - **`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. - **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. -- **`rowHref`** — accessible whole-row navigation. The primary cell renders as a real `` (keyboard/screen-reader reachable, cmd/middle-click opens a new tab) while the whole row stays clickable. Prefer this over a per-row "View" button; keep `onClickRow` for non-navigation side effects. Optional `primaryColumnId` chooses which cell carries the link. - `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 4a49efbd..687ac017 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, **`rowHref`** (accessible whole-row navigation — preferred) / **`onClickRow`** (non-navigation side effects), **`rowActions`** (kebab column), **`onSelectionChange`** (checkbox column), **column pinning** (`pin: "left" | "right"`) and **`DataTable.ColumnSettings`** (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. @@ -405,7 +405,7 @@ const table = useDataTable({ loading: fetching, control, tableId: "purchase-orders", // persist user column layout to localStorage - rowHref: (row) => detailHref(row), // whole-row nav; primary cell becomes a + onClickRow: (row) => navigate(detailHref(row)), // onSelectionChange, rowActions, sort: … }); @@ -421,7 +421,7 @@ const table = useDataTable({ ; ``` -**Row navigation:** prefer **`rowHref`** over `onClickRow` for going to a detail page — it renders the primary cell as a real `` (keyboard/SR reachable, cmd/middle-click opens a new tab) while keeping the whole row clickable. Use `onClickRow` only for non-navigation side effects. Never add a per-row "View" / "Open" / "→" button. +**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` (it must have a `width`) to freeze it during horizontal scroll — selection auto-pins left, row-actions auto-pins right. Drop **`DataTable.ColumnSettings`** in the toolbar to let users show/hide, reorder, and re-pin columns; pass a stable **`tableId`** to persist their layout to `localStorage` (a per-user preference — intentionally not in the URL like filters/sort). diff --git a/catalogue/src/pattern/list/dense-scan/PATTERN.md b/catalogue/src/pattern/list/dense-scan/PATTERN.md index e5efede5..5cb05bb4 100644 --- a/catalogue/src/pattern/list/dense-scan/PATTERN.md +++ b/catalogue/src/pattern/list/dense-scan/PATTERN.md @@ -72,7 +72,7 @@ Omit `fill` on pages that should flow and scroll naturally (forms, dashboards, a - Handle every state: `DataTable` renders the loading skeleton and error row; always provide a **labelled empty state** (what the list is + how to add the first record) rather than a bare empty table - Status Badge colors must use design system tokens (variant prop): the **primary** status column uses **filled** semantic variants; **secondary** status columns (delivery, billing) use **`outline-*`** (see `design-system.md` → Composition & emphasis rules) - Bulk actions toolbar appears only when ≥1 row is selected -- Whole row navigates via `rowHref` (renders the primary cell as an accessible ``); use `onClickRow` only for non-navigation side effects. No per-row "View" / "Open" buttons +- 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: pin key columns with `pin: "left" | "right"`, and offer `DataTable.ColumnSettings` (show/hide + reorder + pin) with a `tableId` so each user's layout persists diff --git a/docs/components/data-table.md b/docs/components/data-table.md index 126275b5..d4b29517 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -177,20 +177,6 @@ function JournalsPage() { Row selection is enabled by providing `onSelectionChange` to `useDataTable`. The `total` value comes from `DataTableData.total`. -## Row navigation - -**Use whole-row navigation, not a per-row "View" button.** Pass `rowHref` to make each row a link to its detail page: - -```tsx -const table = useDataTable({ - columns, - data, - rowHref: (row) => paths.for("/orders/:id", { id: row.id }), -}); -``` - -The primary cell (the first visible column, or `primaryColumnId`) renders as a real ``, so the row is reachable by keyboard and screen readers and can be opened in a new tab (cmd/ctrl/middle-click); clicking anywhere else in the row navigates too. `link`-type cells inside the row keep their own targets (no double navigation). Reserve `onClickRow` for non-navigation side effects (e.g. opening a drawer) and the row-actions kebab for non-navigation actions — don't add a "View" / "Open" / "→" button column. - ## Column pinning, visibility & ordering - **Pin** a column with `pin: "left" | "right"` (it must have a `width`). 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. @@ -227,20 +213,18 @@ const table = useDataTable({ ### Options -| Option | Type | Description | -| ------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `columns` | `Column[]` | Column definitions. Required. | -| `data` | `DataTableData \| undefined` | Fetched data. Pass `undefined` while loading. | -| `loading` | `boolean` | When `true`, renders a loading skeleton. | -| `error` | `Error \| null` | When set, renders an error message in the table body. | -| `control` | `CollectionControl` | Collection control from `useCollectionVariables()`. Required for `DataTable.Pagination` and `DataTable.Filters`. | -| `onClickRow` | `(row: TRow) => void` | Called when the user clicks a row (non-navigation side effects, e.g. opening a drawer). Adds a pointer cursor. Mutually exclusive with `rowHref` — `rowHref` wins if both are set. | -| `rowHref` | `(row: TRow) => string` | Makes the whole row navigate to the returned URL. The primary cell renders as a real `` (keyboard/screen-reader reachable, cmd/middle-click opens a new tab); clicking anywhere else in the row navigates too. Prefer this over an `onClickRow` "View" button. Requires a react-router context. | -| `primaryColumnId` | `string` | Id (or label) of the column whose cell carries the `rowHref` ``. Defaults to the first visible column. Ignored without `rowHref`. | -| `tableId` | `string` | Stable id used to persist per-user column layout (visibility, order, pinning) to `localStorage`. When omitted, column layout is in-memory only and resets on reload. | -| `rowActions` | `RowAction[]` | Per-row action items rendered in a kebab-menu column. The column is omitted when empty or not provided. | -| `onSelectionChange` | `(ids: string[]) => void` | Called with selected row IDs on change. Providing this enables the checkbox column. Rows must have a string `id`. | -| `sort` | `false \| { multiple?: boolean }` | Sort behaviour. `false` disables sorting entirely. `{ multiple: true }` enables multi-column sorting. Omit or pass `{}` for single-column sort (default). | +| Option | Type | Description | +| ------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `columns` | `Column[]` | Column definitions. Required. | +| `data` | `DataTableData \| undefined` | Fetched data. Pass `undefined` while loading. | +| `loading` | `boolean` | When `true`, renders a loading skeleton. | +| `error` | `Error \| null` | When set, renders an error message in the table body. | +| `control` | `CollectionControl` | Collection control from `useCollectionVariables()`. Required for `DataTable.Pagination` and `DataTable.Filters`. | +| `onClickRow` | `(row: TRow) => void` | Called when the user clicks a row. Adds a pointer cursor to rows. | +| `tableId` | `string` | Stable id used to persist per-user column layout (visibility, order, pinning) to `localStorage`. When omitted, column layout is in-memory only and resets on reload. | +| `rowActions` | `RowAction[]` | Per-row action items rendered in a kebab-menu column. The column is omitted when empty or not provided. | +| `onSelectionChange` | `(ids: string[]) => void` | Called with selected row IDs on change. Providing this enables the checkbox column. Rows must have a string `id`. | +| `sort` | `false \| { multiple?: boolean }` | Sort behaviour. `false` disables sorting entirely. `{ multiple: true }` enables multi-column sorting. Omit or pass `{}` for single-column sort (default). | ### `DataTableData` 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 81805391..8ca667e2 100644 --- a/examples/vite-app/src/pages/data-table-lab/page.tsx +++ b/examples/vite-app/src/pages/data-table-lab/page.tsx @@ -7,7 +7,6 @@ import { type AppShellPageProps, } from "@tailor-platform/app-shell"; import { FlaskConical } from "lucide-react"; -import { paths } from "../../routes.generated"; // ─── Dummy data ────────────────────────────────────────────────────────────── // 🧪 Dummy Data: Replace with a real GraphQL-backed source later. @@ -171,8 +170,8 @@ const data = { rows: INVOICES, total: INVOICES.length }; // ─── Page ────────────────────────────────────────────────────────────────── const DataTableLabPage = () => { - // Section 1 — all-in-one column settings + default pins + row actions. - // `tableId` persists the user's layout to localStorage across reloads. + // Column settings + default pins + row actions. `tableId` persists the user's + // layout (visibility, order, pinning) to localStorage across reloads. const settingsTable = useDataTable({ columns: baseColumns.map((c) => (c.id === "id" ? { ...c, pin: "left" as const } : c)), data, @@ -180,55 +179,24 @@ const DataTableLabPage = () => { rowActions, }); - // Section 2 — whole-row click navigation. - const rowClickTable = useDataTable({ - columns: baseColumns.map((c) => (c.id === "id" ? { ...c, pin: "left" as const } : c)), - data, - tableId: "lab-invoices-rowclick", - rowHref: (row) => paths.for("/dashboard/orders/:id", { id: row.id }), - }); - - // Section 3 — whole-row click + a clickable cell. The Customer cell is a - // `link` column, so clicking it navigates to its own target instead of the - // row's; clicking anywhere else opens the row's detail page. - const cellLinkColumns = baseColumns.map((c) => { - if (c.id === "id") return { ...c, pin: "left" as const }; - if (c.id === "customer") { - return column({ - id: "customer", - label: "Customer", - type: "link", - accessor: (r: Invoice) => r.customer, - width: 180, - typeOptions: { href: () => paths.for("/dashboard/products") }, - }); - } - return c; - }); - const cellLinkTable = useDataTable({ - columns: cellLinkColumns, - data, - tableId: "lab-invoices-celllink", - rowHref: (row) => paths.for("/dashboard/orders/:id", { id: row.id }), - }); - return (
- Prototype playground. Compares column-settings surfaces and row-click - navigation for the DataTable. Scroll each table horizontally to see pinned columns stay - put. Column layout (visibility, order, pins) persists per table via tableId. + Prototype playground. Column visibility, ordering, and pinning for the + DataTable. Open Columns to show/hide and drag columns between the Fixed + left / Scrollable / Fixed right zones; scroll horizontally to see pinned columns stay put. + Layout persists per table via tableId.
- Open Columns to show/hide, drag or use the arrows to reorder, and pin - columns left/right. The Invoice column is pinned left and the actions column - is pinned right by default. Changes persist across reloads. + Open Columns to show/hide, drag to reorder, and drag between zones to + pin left/right. The Invoice column is pinned left and the actions column is + pinned right by default. Changes persist across reloads. } > @@ -239,42 +207,6 @@ const DataTableLabPage = () => {
- -
- The entire row is a link to the detail page. The Invoice cell is a real{" "} - <Link>: Tab to it and press Enter, or cmd/middle-click any row to - open it in a new tab. - - } - > - - - - - - -
- -
- Same whole-row navigation, but the Customer cell is its own link. - Clicking the customer goes to Products; clicking anywhere else opens the invoice - detail — no double navigation. - - } - > - - - - - - -
); diff --git a/packages/core/skills/app-shell-patterns/references/fundamental/components.md b/packages/core/skills/app-shell-patterns/references/fundamental/components.md index 4a49efbd..687ac017 100644 --- a/packages/core/skills/app-shell-patterns/references/fundamental/components.md +++ b/packages/core/skills/app-shell-patterns/references/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, **`rowHref`** (accessible whole-row navigation — preferred) / **`onClickRow`** (non-navigation side effects), **`rowActions`** (kebab column), **`onSelectionChange`** (checkbox column), **column pinning** (`pin: "left" | "right"`) and **`DataTable.ColumnSettings`** (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. @@ -405,7 +405,7 @@ const table = useDataTable({ loading: fetching, control, tableId: "purchase-orders", // persist user column layout to localStorage - rowHref: (row) => detailHref(row), // whole-row nav; primary cell becomes a + onClickRow: (row) => navigate(detailHref(row)), // onSelectionChange, rowActions, sort: … }); @@ -421,7 +421,7 @@ const table = useDataTable({ ; ``` -**Row navigation:** prefer **`rowHref`** over `onClickRow` for going to a detail page — it renders the primary cell as a real `` (keyboard/SR reachable, cmd/middle-click opens a new tab) while keeping the whole row clickable. Use `onClickRow` only for non-navigation side effects. Never add a per-row "View" / "Open" / "→" button. +**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` (it must have a `width`) to freeze it during horizontal scroll — selection auto-pins left, row-actions auto-pins right. Drop **`DataTable.ColumnSettings`** in the toolbar to let users show/hide, reorder, and re-pin columns; pass a stable **`tableId`** to persist their layout to `localStorage` (a per-user preference — intentionally not in the URL like filters/sort). diff --git a/packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md b/packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md index 49eb3452..1be1e478 100644 --- a/packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md +++ b/packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md @@ -153,7 +153,7 @@ Omit `fill` on pages that should flow and scroll naturally (forms, dashboards, a - Handle every state: `DataTable` renders the loading skeleton and error row; always provide a **labelled empty state** (what the list is + how to add the first record) rather than a bare empty table - Status Badge colors must use design system tokens (variant prop): the **primary** status column uses **filled** semantic variants; **secondary** status columns (delivery, billing) use **`outline-*`** (see `design-system.md` → Composition & emphasis rules) - Bulk actions toolbar appears only when ≥1 row is selected -- Whole row navigates via `rowHref` (renders the primary cell as an accessible ``); use `onClickRow` only for non-navigation side effects. No per-row "View" / "Open" buttons +- 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: pin key columns with `pin: "left" | "right"`, and offer `DataTable.ColumnSettings` (show/hide + reorder + pin) with a `tableId` so each user's layout persists diff --git a/packages/core/src/components/data-table/cell-renderers.tsx b/packages/core/src/components/data-table/cell-renderers.tsx index 9fd4025c..83596e88 100644 --- a/packages/core/src/components/data-table/cell-renderers.tsx +++ b/packages/core/src/components/data-table/cell-renderers.tsx @@ -148,13 +148,7 @@ function renderLink>( const href = options.href(row); if (!href) return label; return ( - // Stop propagation so a link cell inside a clickable row (onClickRow / - // rowHref) follows the cell's own target instead of the row's. - e.stopPropagation()} - className="astw:text-primary astw:underline-offset-4 astw:hover:underline" - > + {label} ); diff --git a/packages/core/src/components/data-table/data-table-context.tsx b/packages/core/src/components/data-table/data-table-context.tsx index 84d6919b..57d219f5 100644 --- a/packages/core/src/components/data-table/data-table-context.tsx +++ b/packages/core/src/components/data-table/data-table-context.tsx @@ -59,8 +59,6 @@ export interface DataTableContextValue> { // Row interaction onClickRow?: (row: TRow) => void; - rowHref?: (row: TRow) => string; - primaryColumnId?: string; rowActions?: RowAction[]; // Row selection diff --git a/packages/core/src/components/data-table/data-table.test.tsx b/packages/core/src/components/data-table/data-table.test.tsx index 681e5eb1..0ea02b80 100644 --- a/packages/core/src/components/data-table/data-table.test.tsx +++ b/packages/core/src/components/data-table/data-table.test.tsx @@ -1,6 +1,6 @@ import { afterEach, describe, it, expect, vi } from "vitest"; import { cleanup, render, screen, fireEvent } from "@testing-library/react"; -import { MemoryRouter, useLocation } from "react-router"; +import { MemoryRouter } from "react-router"; import type { ReactNode } from "react"; import { createAppShellWrapper } from "../../../tests/test-utils"; import { DataTable } from "./data-table"; @@ -1176,78 +1176,4 @@ describe("DataTable", () => { warn.mockRestore(); }); }); - - // ------------------------------------------------------------------------- - // Row-click navigation (rowHref) - // ------------------------------------------------------------------------- - describe("rowHref navigation", () => { - type Row = { id: string; name: string; status: string }; - const rows: Row[] = [ - { id: "1", name: "Alice", status: "Active" }, - { id: "2", name: "Bob", status: "Inactive" }, - ]; - const cols: Column[] = [ - { id: "name", label: "Name", render: (r) => r.name }, - { id: "status", label: "Status", render: (r) => r.status }, - ]; - - function LocationProbe() { - const loc = useLocation(); - return
{loc.pathname}
; - } - - function NavHarness(props: { onClickRow?: (row: Row) => void; primaryColumnId?: string }) { - const table = useDataTable({ - columns: cols, - data: { rows }, - rowHref: (row) => `/orders/${row.id}`, - onClickRow: props.onClickRow, - primaryColumnId: props.primaryColumnId, - }); - return ( - - - - - - - ); - } - - it("renders the primary cell as a single link per row", () => { - const { container } = render(, { wrapper }); - const bodyRows = container.querySelectorAll('[data-slot="data-table-row"]'); - const firstRowAnchors = bodyRows[0].querySelectorAll("a"); - expect(firstRowAnchors).toHaveLength(1); - expect(firstRowAnchors[0].getAttribute("href")).toBe("/orders/1"); - expect(firstRowAnchors[0].textContent).toBe("Alice"); - }); - - it("navigates when the row is clicked", () => { - const { container } = render(, { wrapper }); - const cell = container.querySelectorAll('[data-slot="data-table-cell"]')[1]; // Status cell - fireEvent.click(cell); - expect(screen.getByTestId("loc").textContent).toBe("/orders/1"); - }); - - it("honours primaryColumnId for the link cell", () => { - const { container } = render(, { wrapper }); - const firstRowAnchor = container - .querySelector('[data-slot="data-table-row"]') - ?.querySelector("a"); - expect(firstRowAnchor?.textContent).toBe("Active"); - }); - - it("warns and lets rowHref win when onClickRow is also provided", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const onClickRow = vi.fn(); - const { container } = render(, { wrapper }); - const cell = container.querySelectorAll('[data-slot="data-table-cell"]')[1]; - fireEvent.click(cell); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("rowHref` wins")); - expect(onClickRow).not.toHaveBeenCalled(); - expect(screen.getByTestId("loc").textContent).toBe("/orders/1"); - warn.mockRestore(); - }); - }); }); diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index 413dcc19..4ddafb93 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -1,13 +1,4 @@ -import { - useContext, - useEffect, - useMemo, - useRef, - type CSSProperties, - type MouseEvent, - type ReactNode, -} from "react"; -import { Link, useNavigate } from "react-router"; +import { useContext, useEffect, useMemo, useRef, type CSSProperties, type ReactNode } from "react"; import { Ellipsis } from "lucide-react"; import { Checkbox } from "@base-ui/react/checkbox"; import { Check, Minus } from "lucide-react"; @@ -387,8 +378,6 @@ function DataTableRoot>({ hasPrevPage: value.hasPrevPage, hasNextPage: value.hasNextPage, onClickRow: value.onClickRow, - rowHref: value.rowHref, - primaryColumnId: value.primaryColumnId, rowActions: value.rowActions, selectedIds: value.selectedIds, isRowSelected: value.isRowSelected, @@ -606,8 +595,6 @@ function DataTableBody({ className }: { className?: string }) { loading, error, onClickRow, - rowHref, - primaryColumnId, rowActions, isRowSelected, toggleRowSelection, @@ -662,35 +649,28 @@ function DataTableBody({ className }: { className?: string }) { ); } - const listProps: DataTableRowListProps> = { - rows, - pinLayout, - hasSelection, - hasRowActions, - isRowSelected, - toggleRowSelection, - rowActions, - rowHref, - primaryColumnId, - onClickRow, - }; - return ( - {/* When rowHref is set the rows navigate via react-router — split into a - child that calls useNavigate so the hook never runs in a non-routed - (static) table. */} - {rowHref ? : } + ); } DataTableBody.displayName = "DataTable.Body"; // ============================================================================= -// Row rendering (shared by static and navigable variants) +// Row rendering // ============================================================================= -interface DataTableRowListProps> { +interface DataTableRowsProps> { rows: TRow[]; pinLayout: PinLayout; hasSelection: boolean; @@ -698,19 +678,11 @@ interface DataTableRowListProps> { isRowSelected: (row: TRow) => boolean; toggleRowSelection?: (row: TRow) => void; rowActions?: RowAction[]; - rowHref?: (row: TRow) => string; - primaryColumnId?: string; onClickRow?: (row: TRow) => void; } -/** Renders rows with `rowHref` navigation wired through `useNavigate`. */ -function NavigableRows>(props: DataTableRowListProps) { - const navigate = useNavigate(); - return ; -} - /** @internal */ -function DataTableRowList>({ +function DataTableRows>({ rows, pinLayout, hasSelection, @@ -718,33 +690,10 @@ function DataTableRowList>({ isRowSelected, toggleRowSelection, rowActions, - rowHref, - primaryColumnId, onClickRow, - navigate, -}: DataTableRowListProps & { navigate?: (to: string) => void }) { +}: DataTableRowsProps) { const t = useDataTableT(); const { ordered, placements, selection, actions } = pinLayout; - const clickable = !!rowHref || !!onClickRow; - - // The primary cell carries the rowHref . Default to the first visible - // column; `primaryColumnId` overrides. - const primaryKey = rowHref - ? (primaryColumnId ?? (ordered.length > 0 ? columnKeyAt(ordered[0], 0) : undefined)) - : undefined; - - const handleRowClick = (e: MouseEvent, row: TRow) => { - if (rowHref && navigate) { - // Let the browser handle modifier/middle clicks (open in new tab) and - // clicks that landed on a link (the primary or a link cell). - if (e.defaultPrevented) return; - if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; - if ((e.target as HTMLElement).closest("a")) return; - navigate(rowHref(row)); - return; - } - onClickRow?.(row); - }; return ( <> @@ -756,8 +705,8 @@ function DataTableRowList>({ key={rowId != null ? String(rowId) : rowIndex} data-slot="data-table-row" aria-selected={hasSelection ? selected : undefined} - className={cn("astw:group", clickable && "astw:cursor-pointer")} - onClick={clickable ? (e) => handleRowClick(e, row) : undefined} + className={cn("astw:group", onClickRow && "astw:cursor-pointer")} + onClick={onClickRow ? () => onClickRow(row) : undefined} > {hasSelection && toggleRowSelection && @@ -791,22 +740,7 @@ function DataTableRowList>({ })()} {ordered?.map((col, colIndex) => { const key = columnKeyAt(col, colIndex); - const rendered = col.render ? col.render(row) : renderTypedCell(row, col); - const isPrimary = primaryKey !== undefined && key === primaryKey; - // Wrap the primary cell in a real for keyboard/SR access and - // new-tab support. The row's closest("a") guard prevents a second - // programmatic navigation when this link is clicked. - const content = - rowHref && isPrimary ? ( - - {rendered} - - ) : ( - rendered - ); + const content = col.render ? col.render(row) : renderTypedCell(row, col); const { style: cellStyle, className: cellClassName } = pinCellProps( placements.get(col), diff --git a/packages/core/src/components/data-table/types.ts b/packages/core/src/components/data-table/types.ts index c609fffc..5f656f53 100644 --- a/packages/core/src/components/data-table/types.ts +++ b/packages/core/src/components/data-table/types.ts @@ -258,30 +258,8 @@ export type UseDataTableOptions< * in-memory only and resets on reload. */ tableId?: string; - /** - * Called when the user clicks a row. Adds a pointer cursor to rows. - * - * Prefer `rowHref` for **navigation** (it's keyboard/screen-reader reachable - * and supports cmd/middle-click). Use `onClickRow` for non-navigation side - * effects such as opening a drawer. Mutually exclusive with `rowHref` — if - * both are set, `rowHref` wins. - */ + /** Called when the user clicks a row. Adds a pointer cursor to rows. */ onClickRow?: (row: TRow) => void; - /** - * Makes the whole row navigate to the returned URL. The row's primary cell is - * rendered as a real ``, so the row is reachable by keyboard and screen - * readers and can be opened in a new tab (cmd/ctrl/middle click). Clicking - * anywhere else in the row navigates too. - * - * Requires a react-router context (it uses `useNavigate`/``); only set - * it inside a routed app. - */ - rowHref?: (row: TRow) => string; - /** - * Id (or label) of the column whose cell should carry the `rowHref` ``. - * Defaults to the first visible column. Ignored when `rowHref` is not set. - */ - primaryColumnId?: string; /** * Per-row action items rendered in a kebab-menu column on the right. * The column is omitted when this array is empty or not provided. @@ -399,8 +377,6 @@ export interface UseDataTableReturn> { // Row interaction (passthrough for DataTable.Provider) onClickRow?: (row: TRow) => void; - rowHref?: (row: TRow) => string; - primaryColumnId?: string; rowActions?: RowAction[]; // Row selection diff --git a/packages/core/src/components/data-table/use-data-table.ts b/packages/core/src/components/data-table/use-data-table.ts index 049972ad..e05ebb17 100644 --- a/packages/core/src/components/data-table/use-data-table.ts +++ b/packages/core/src/components/data-table/use-data-table.ts @@ -48,19 +48,11 @@ export function useDataTable< control, tableId, onClickRow, - rowHref, - primaryColumnId, rowActions, onSelectionChange, sort: sortOption, } = options; - if (rowHref && onClickRow) { - console.warn( - "[DataTable] Both `rowHref` and `onClickRow` were provided; `rowHref` wins. Use `rowHref` for navigation and `onClickRow` only for non-navigation side effects.", - ); - } - // --------------------------------------------------------------------------- // Data extraction // --------------------------------------------------------------------------- @@ -370,8 +362,6 @@ export function useDataTable< setPin, control: control as CollectionControl | undefined, onClickRow, - rowHref, - primaryColumnId, rowActions, selectedIds, isRowSelected,