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..5ea4ff57 --- /dev/null +++ b/.changeset/data-table-columns-and-row-nav.md @@ -0,0 +1,10 @@ +--- +"@tailor-platform/app-shell": minor +--- + +DataTable: sticky/pinned columns and user column settings + +- **Pinned columns** — add `pin: "left" | "right"` to a `Column` to freeze it during horizontal scroll (sticky offsets are measured from the rendered layout; `width` is optional but recommended for stable sizing). The selection column auto-pins left and the row-actions column auto-pins right, with a subtle freeze shadow at the frozen edge once content scrolls under it. +- **`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. +- `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..3460a974 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, **`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. @@ -404,6 +404,7 @@ const table = useDataTable({ data: fetching ? undefined : mappedFromQuery, loading: fetching, control, + tableId: "purchase-orders", // persist user column layout to localStorage onClickRow: (row) => navigate(detailHref(row)), // onSelectionChange, rowActions, sort: … }); @@ -411,6 +412,7 @@ const table = useDataTable({ + @@ -419,6 +421,10 @@ const table = useDataTable({ ; ``` +**Row navigation:** whole row is clickable via **`onClickRow`** → `navigate(detailHref(row))`; wrap the primary identifier cell in `` for keyboard/SR access. Never add a per-row "View" / "Open" / "→" button. (A first-class row-interaction API is under design — see the row-interaction tracking issue.) + +**Column pinning & settings:** set `pin: "left" | "right"` on a `Column` to freeze it during horizontal scroll — selection auto-pins left, row-actions auto-pins right (a `width` is optional but recommended for stable sizing). Drop **`DataTable.ColumnSettings`** in the toolbar to let users show/hide, reorder, and re-pin columns; pass a stable, **unique** **`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..5cb05bb4 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 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 ## Anti-patterns diff --git a/docs/components/data-table.md b/docs/components/data-table.md index 5b9c8140..0ffd3787 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,27 @@ function JournalsPage() { Row selection is enabled by providing `onSelectionChange` to `useDataTable`. The `total` value comes from `DataTableData.total`. +## Column pinning, visibility & ordering + +- **Pin** a column with `pin: "left" | "right"`. Pinned columns stay visible during horizontal scroll; the selection column auto-pins left and the row-actions column auto-pins right. A subtle shadow appears at the frozen edge once the table is scrolled under it. Sticky offsets are measured from the rendered layout, so a `width` isn't required — but setting `width` on pinned columns is recommended so their size stays stable as content changes. +- **`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, **unique** `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 (state simply isn't persisted). Two tables mounted with the same `tableId` share one storage key and overwrite each other — use a unique id per table (e.g. `:`); a dev-mode warning fires on duplicates. + +```tsx +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 +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. 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. 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` @@ -223,6 +246,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`). Sticky offsets are measured from the rendered layout, so `width` isn't required — but setting `width` on pinned columns is recommended for stable sizing. The selection column auto-pins left and the row-actions column auto-pins right. | | `align` | `"left" \| "right"` | Horizontal alignment. Defaults to `"right"` for `type: "number"` and `type: "money"`; `"left"` otherwise. Pass `"left"` to opt a numeric column out. | | `truncate` | `boolean` | Truncate overflowing text with an ellipsis. Wires up an app-shell `` automatically when the resolved cell value is a string or number — resolved via `accessor` first, then `row[col.id]` as a fallback — so hovering the cell reveals the full value. With `inferColumns`, no explicit `accessor` is needed because `id` is pinned to the field name. Requires another column to anchor the row width (`width` on a neighbor, or a fixed-size column like selection / row actions). | | `accessor` | _(narrowed per `type`)_ | Extracts the raw value. The return type is narrowed per `type` branch — returning an array is a compile error on all typed columns except `badge`, and returning a plain object is a compile error on all typed columns. Untyped columns (`type` omitted) retain `unknown`. `null` and `undefined` are always allowed. | diff --git a/examples/vite-app/src/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..ea55c041 --- /dev/null +++ b/examples/vite-app/src/pages/data-table-lab/page.tsx @@ -0,0 +1,223 @@ +import { + Layout, + Badge, + DataTable, + useDataTable, + createColumnHelper, + type AppShellPageProps, +} from "@tailor-platform/app-shell"; +import { FlaskConical } from "lucide-react"; + +// ─── 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 }), + // Intentionally no `width` — exercises measure-based pinning for an auto-width column. + column({ id: "issued", label: "Issued", type: "date", accessor: (r) => r.issued }), + 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 = () => { + // 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, + tableId: "lab-invoices-settings", + rowActions, + }); + + return ( + + + +
+ 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 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. + + } + > + + + + + + +
+
+
+ ); +}; + +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/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..f728a8ed --- /dev/null +++ b/packages/core/src/components/data-table/column-settings.tsx @@ -0,0 +1,298 @@ +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 { Tooltip } from "@/components/tooltip"; +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", +}; + +/** 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], + ); + + // A single toggle drives the footer button: when every column is visible it + // offers "Hide all", otherwise "Show all" (so a partially-hidden table gets + // everything back in one click). + const allVisible = useMemo( + () => columnOrder.every((key) => isColumnVisible(key)), + [columnOrder, isColumnVisible], + ); + + // 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]; + // `index` was measured against the section's rows *including* dragKey. For + // a same-zone move, filtering dragKey out above shifts every slot at/after + // its original position down by one, so decrement to land where the + // indicator promised. Cross-zone drops (fromIdx === -1) are unaffected. + const fromIdx = buckets[section].indexOf(dragKey); + const insertAt = fromIdx > -1 && fromIdx < index ? index - 1 : index; + target.splice(Math.max(0, Math.min(insertAt, target.length)), 0, dragKey); + setColumnOrder([...next.left, ...next.scrollable, ...next.right]); + setPin(dragKey, SECTION_PIN[section]); + } + resetDrag(); + }; + + const renderRow = (key: string, section: Section, index: number, isLast: boolean) => { + // Insertion indicator: an absolutely-positioned line that sits in the gap + // between rows (straddling the shared border). Being absolute, it never + // shifts the rows under the cursor — a flow element there made the drop + // target recompute on every reflow and the line "jump". + const showBefore = + dragKey != null && dropTarget?.section === section && dropTarget.index === index; + const showAfter = + dragKey != null && + dropTarget?.section === section && + isLast && + dropTarget.index === index + 1; + return ( +
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:relative 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", + )} + > + {showBefore && ( + + )} + {showAfter && ( + + )} + + + + + + toggleColumn(key)} + aria-label={meta.label.get(key)} + className={CHECKBOX_CLASS} + /> + } + > + + + + + {t(isColumnVisible(key) ? "hideColumn" : "showColumn")} + +
+ ); + }; + + const renderSection = (section: Section, title: string) => { + const keys = buckets[section]; + const active = dragKey != null && dropTarget?.section === section; + return ( +
{ + e.preventDefault(); + // 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) => { + e.preventDefault(); + handleDrop(); + }} + className={cn( + // 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", + )} + > +
+ {title} +
+ {keys.length === 0 ? ( +
+ {t("dropColumnsHere")} +
+ ) : ( + keys.map((key, i) => renderRow(key, section, i, i === keys.length - 1)) + )} +
+ ); + }; + + 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..57d219f5 100644 --- a/packages/core/src/components/data-table/data-table-context.tsx +++ b/packages/core/src/components/data-table/data-table-context.tsx @@ -51,6 +51,11 @@ 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; 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..1e42a1b9 100644 --- a/packages/core/src/components/data-table/data-table.test.tsx +++ b/packages/core/src/components/data-table/data-table.test.tsx @@ -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,80 @@ 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("honours a pin without a width (offsets come from measured widths)", () => { + 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 }); + // Width is no longer required to pin — the column is still frozen (offsets + // are measured at runtime), and there is no dev warning. + expect(headByText(container, "A")?.style.position).toBe("sticky"); + expect(headByText(container, "A")?.style.left).toBe("0px"); + expect(warn).not.toHaveBeenCalled(); + 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..6aae1136 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -1,4 +1,14 @@ -import { useContext, type ReactNode } from "react"; +import { + createContext, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type ReactNode, +} from "react"; import { Ellipsis } from "lucide-react"; import { Checkbox } from "@base-ui/react/checkbox"; import { Check, Minus } from "lucide-react"; @@ -14,6 +24,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 +53,210 @@ function nextSortDirection(current: string | undefined): "Asc" | "Desc" | undefi return "Asc"; } +// ============================================================================= +// Column pinning (sticky columns) +// ============================================================================= + +// Fallback widths of the built-in selection / row-actions columns, used as the +// innermost anchors before the real widths are measured. +const SELECTION_WIDTH = 52; +const ACTIONS_WIDTH = 50; +// Keys for the built-in selection / row-actions columns in the measured-width map. +const SELECTION_KEY = "__datatable_selection__"; +const ACTIONS_KEY = "__datatable_actions__"; + +type PinSide = "left" | "right"; +type ColumnWidths = Record; + +/** + * Rendered column widths (px) keyed by column key, published by `DataTable.Table` + * after it measures the header row. Empty until the first measurement; consumers + * fall back to each column's declared `width` (or 0) meanwhile. + */ +const PinMeasureContext = createContext({}); + +// `useLayoutEffect` warns during SSR; the measurement it drives is client-only, +// so fall back to `useEffect` on the server. +const useIsomorphicLayoutEffect = typeof document !== "undefined" ? useLayoutEffect : useEffect; + +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[]; + /** Render key per column (data-col-key + React key), keyed by column reference. */ + keys: Map, string>; + /** 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); +} + +/** Rendered width for a column key: the measured value wins once > 0, else the + * declared width, else 0. */ +function resolveWidth(key: string, declared: number | undefined, widths: ColumnWidths): number { + const measured = widths[key]; + return measured && measured > 0 ? measured : (declared ?? 0); +} + +/** Shallow equality of two width maps — guards the measure effect against + * setting state (and re-rendering) when nothing actually changed. */ +function sameWidths(a: ColumnWidths, b: ColumnWidths): boolean { + const aKeys = Object.keys(a); + if (aKeys.length !== Object.keys(b).length) return false; + return aKeys.every((k) => a[k] === b[k]); +} + +/** + * 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; widths: ColumnWidths }, +): PinLayout { + const { hasSelection, hasRowActions, widths } = opts; + + const keys = new Map, string>(); + columns.forEach((col, index) => keys.set(col, columnKeyAt(col, index))); + + const left: Column[] = []; + const middle: Column[] = []; + const right: Column[] = []; + + columns.forEach((col, index) => { + const pin = effectivePin(col, index, pinnedColumns); + 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 + // using the measured (or declared) width of each preceding pinned column. + let leftOffset = 0; + let selection: PinPlacement | undefined; + if (hasSelection) { + selection = { side: "left", offset: 0, isBoundary: left.length === 0 }; + leftOffset = resolveWidth(SELECTION_KEY, SELECTION_WIDTH, widths); + } + left.forEach((col, i) => { + placements.set(col, { side: "left", offset: leftOffset, isBoundary: i === left.length - 1 }); + leftOffset += resolveWidth(keys.get(col) as string, col.width, widths); + }); + + // Right group, visually [...right, actions?]; offsets accumulate leftward. + let rightOffset = 0; + let actions: PinPlacement | undefined; + if (hasRowActions) { + actions = { side: "right", offset: 0, isBoundary: right.length === 0 }; + rightOffset = resolveWidth(ACTIONS_KEY, ACTIONS_WIDTH, widths); + } + for (let i = right.length - 1; i >= 0; i--) { + const col = right[i]; + placements.set(col, { side: "right", offset: rightOffset, isBoundary: i === 0 }); + rightOffset += resolveWidth(keys.get(col) as string, col.width, widths); + } + + return { ordered: [...left, ...middle, ...right], keys, 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 +267,11 @@ const SKELETON_WIDTHS = [75, 55, 85, 65, 70]; /** @internal */ function DataTableLoaderRows>({ rowCount, - columns, + pinLayout, hasSelection, hasRowActions, }: DataTableLoaderRowsProps) { + const { ordered: columns, keys, 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 +279,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 = keys.get(col) as string; 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 }, className: "astw:pr-2!" }, + "body", + ); + return ( + + {/* size-9 box = the real icon Button's footprint; the visible + pulse stays 24px to read as an ellipsis placeholder */} +
+
+
+ + ); + })()} ))} @@ -169,6 +399,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, @@ -229,13 +464,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, @@ -246,7 +482,13 @@ function DataTableHeaders({ className }: { className?: string }) { isIndeterminate, } = ctx; const t = useDataTableT(); + const widths = useContext(PinMeasureContext); const hasSelection = !!toggleRowSelection; + const hasRowActions = !!(rowActions && rowActions.length > 0); + const { ordered, keys, placements, selection, actions } = useMemo( + () => computePinLayout(columns, pinnedColumns, { hasSelection, hasRowActions, widths }), + [columns, pinnedColumns, hasSelection, hasRowActions, widths], + ); return ( // Sticky within the DataTable.Table scroll container so column headers @@ -258,41 +500,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) => { + const key = keys.get(col) as string; const label = col.label; const isSortable = !!col.sort; @@ -306,14 +556,23 @@ 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 }, className: "astw:pr-2!" }, + "header", + ); + return ( + + {t("actionsHeader")} + + ); + })()} ); @@ -364,6 +631,7 @@ function DataTableBody({ className }: { className?: string }) { } const { visibleColumns: columns, + pinnedColumns, rows, loading, error, @@ -374,10 +642,15 @@ function DataTableBody({ className }: { className?: string }) { pageSize, } = ctx; const t = useDataTableT(); + const widths = useContext(PinMeasureContext); const hasRowActions = !!(rowActions && rowActions.length > 0); 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, widths }), + [columns, pinnedColumns, hasSelection, hasRowActions, widths], + ); const tableBodyProps = { "data-slot": "data-table-body", className, @@ -388,7 +661,7 @@ function DataTableBody({ className }: { className?: string }) { @@ -420,6 +693,52 @@ function DataTableBody({ className }: { className?: string }) { return ( + + + ); +} +DataTableBody.displayName = "DataTable.Body"; + +// ============================================================================= +// Row rendering +// ============================================================================= + +interface DataTableRowsProps> { + rows: TRow[]; + pinLayout: PinLayout; + hasSelection: boolean; + hasRowActions: boolean; + isRowSelected: (row: TRow) => boolean; + toggleRowSelection?: (row: TRow) => void; + rowActions?: RowAction[]; + onClickRow?: (row: TRow) => void; +} + +/** @internal */ +function DataTableRows>({ + rows, + pinLayout, + hasSelection, + hasRowActions, + isRowSelected, + toggleRowSelection, + rowActions, + onClickRow, +}: DataTableRowsProps) { + const t = useDataTableT(); + const { ordered, keys, placements, selection, actions } = pinLayout; + + return ( + <> {rows.map((row, rowIndex) => { const rowId = (row as Record)["id"]; const selected = isRowSelected?.(row) ?? false; @@ -428,38 +747,64 @@ 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")} + className={cn("astw:group", onClickRow && "astw:cursor-pointer")} onClick={onClickRow ? () => onClickRow(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); + {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) => { + const key = keys.get(col) as string; 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", + + 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 +833,7 @@ function DataTableBody({ className }: { className?: string }) { /> } > - {content} + {cellBody} {tooltipLabel} @@ -502,22 +847,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 }, className: "astw:pr-2!" }, + "body", + ); + return ( + e.stopPropagation()} + > + + + ); + })()} ); })} - + ); } -DataTableBody.displayName = "DataTable.Body"; // ============================================================================= // RowActionsMenu (internal — uses app-shell Menu) @@ -573,19 +930,83 @@ function RowActionsMenu>({ /** Use `DataTable.Table` instead of calling this directly. */ function DataTableTable({ className }: { className?: string }) { + const ctx = useContext(DataTableContext); + const containerRef = useRef(null); + const [widths, setWidths] = useState({}); + + const visibleColumns = ctx?.visibleColumns; + const pinnedColumns = ctx?.pinnedColumns; + + // Measure each column's *rendered* width from the (always-present) header row + // and publish it via PinMeasureContext, so sticky offsets reflect real + // geometry rather than declared `width`. This keeps the table on its natural + // `table-auto` sizing — pinning no longer forces `table-fixed` (which would + // require every column to set an explicit width or collapse). Runs before + // paint and re-measures on container/column changes. + useIsomorphicLayoutEffect(() => { + const el = containerRef.current; + if (!el) return; + const measure = () => { + const cells = el.querySelectorAll( + "[data-slot='data-table-header'] [data-col-key]", + ); + const next: ColumnWidths = {}; + cells.forEach((cell) => { + const key = cell.dataset.colKey; + if (key) next[key] = cell.offsetWidth; + }); + setWidths((prev) => (sameWidths(prev, next) ? prev : next)); + }; + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(el); + const table = el.querySelector("table"); + if (table) observer.observe(table); + return () => observer.disconnect(); + }, [visibleColumns, pinnedColumns]); + + // Reflect horizontal scroll position onto the container as data attributes so + // the pinned-column freeze shadows show only while there is content scrolled + // under that edge (left once scrolled from the start; right while more remains + // to the right). Re-runs when the column set changes 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 }); + if (typeof ResizeObserver === "undefined") { + return () => el.removeEventListener("scroll", update); + } + const observer = new ResizeObserver(update); + observer.observe(el); + return () => { + el.removeEventListener("scroll", update); + observer.disconnect(); + }; + }, [visibleColumns, 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 // that scrolls vertically when height is constrained. The sticky header // (DataTableHeaders) stays pinned to the top of this scrollport. - - - - + + + + + + ); } DataTableTable.displayName = "DataTable.Table"; @@ -632,6 +1053,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..ba87c623 100644 --- a/packages/core/src/components/data-table/i18n.ts +++ b/packages/core/src/components/data-table/i18n.ts @@ -13,6 +13,19 @@ 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", + showColumn: "Show column", + hideColumn: "Hide column", + // Row selection selectAll: "Select all rows", selectRow: "Select row", @@ -73,6 +86,20 @@ export const dataTableLabels = defineI18nLabels({ errorPrefix: "エラー:", actionsHeader: "操作", rowActions: "行の操作", + + // Column settings + columns: "列", + columnSettings: "列の設定", + showAllColumns: "すべて表示", + hideAllColumns: "すべて非表示", + dragToReorder: "ドラッグして並び替え", + sectionPinnedLeft: "左に固定", + sectionScrollable: "スクロール", + sectionPinnedRight: "右に固定", + dropColumnsHere: "ここに列をドラッグ", + showColumn: "列を表示", + hideColumn: "列を非表示", + 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..8dbd942d 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). + * + * Sticky offsets for stacked pinned columns are measured from the rendered + * layout, so a `width` is not required — but setting `width` on pinned columns + * is recommended so their size stays stable as row content changes. + */ + 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,6 +252,12 @@ export type UseDataTableOptions< * using `DataTable.Pagination` or `DataTable.Filters`. */ control?: CollectionControl; + /** + * 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. */ onClickRow?: (row: TRow) => void; /** @@ -323,6 +340,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. 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..63589d65 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,146 @@ 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("composes multiple moveColumn calls batched before a re-render", () => { + type Row3 = { id: string; a: string; b: string; c: string }; + const cols: Column[] = [ + { id: "a", label: "A", render: (r) => r.a }, + { id: "b", label: "B", render: (r) => r.b }, + { id: "c", label: "C", render: (r) => r.c }, + ]; + const { result } = renderHook(() => + useDataTable({ columns: cols, data: { rows: [] } }), + ); + + act(() => { + result.current.moveColumn("a", 2); // [a,b,c] -> [b,c,a] + result.current.moveColumn("b", 2); // must build on [b,c,a] -> [c,a,b] + }); + // Each move reconciles from the previous state, so they compose instead of + // the second clobbering the first (which would yield ["a","c","b"]). + expect(result.current.columnOrder).toEqual(["c", "a", "b"]); + }); + + 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("resets to defaults when tableId is cleared (no stale layout leak)", () => { + const { result, rerender } = renderHook( + ({ id }: { id?: string }) => useDataTable({ columns, data: testData, tableId: id }), + { initialProps: { id: "t1" as string | undefined } }, + ); + act(() => { + result.current.toggleColumn("name"); + }); + expect(result.current.isColumnVisible("name")).toBe(false); + + // Clearing tableId switches to in-memory mode — the previous table's + // persisted layout must not leak in. + rerender({ id: undefined }); + expect(result.current.isColumnVisible("name")).toBe(true); + }); + + 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..9a4c17fe 100644 --- a/packages/core/src/components/data-table/use-data-table.ts +++ b/packages/core/src/components/data-table/use-data-table.ts @@ -1,8 +1,34 @@ 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"; +/** + * Reconcile a persisted column order against the current column keys: keep + * persisted keys that still exist (in their saved order), then append any new + * columns in definition order. Pure so `moveColumn` can reconcile from `prev` + * inside its state updater (not a captured value), keeping batched moves correct. + */ +function reconcileColumnOrder(order: string[], columnKeys: string[]): string[] { + const present = new Set(columnKeys); + const seen = new Set(); + const result: string[] = []; + for (const key of order) { + if (present.has(key) && !seen.has(key)) { + result.push(key); + seen.add(key); + } + } + for (const key of columnKeys) { + if (!seen.has(key)) { + result.push(key); + seen.add(key); + } + } + return result; +} + /** * Hook that integrates data management, column visibility, row operations, and * sort/pagination state for the `DataTable.*` compound component. @@ -45,6 +71,7 @@ export function useDataTable< loading = false, error = null, control, + tableId, onClickRow, rowActions, onSelectionChange, @@ -80,44 +107,117 @@ 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); }, []); + // 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( + () => reconcileColumnOrder(persisted.order, columnKeys), + [persisted.order, columnKeys], + ); + + const hiddenColumns = useMemo(() => new Set(persisted.hidden), [persisted.hidden]); + const pinnedColumns = persisted.pinned; + 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); - } - return next; - }); - }, []); + 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) => { + // Reconcile from `prev` (not the captured `columnOrder`) so several moves + // batched before a re-render compose correctly instead of clobbering. + setPersisted((prev) => { + const order = reconcileColumnOrder(prev.order, columnKeys); + 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, columnKeys], + ); + + 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,6 +368,11 @@ export function useDataTable< showAllColumns, hideAllColumns, isColumnVisible, + columnOrder, + moveColumn, + setColumnOrder, + pinnedColumns, + setPin, control: control as CollectionControl | undefined, onClickRow, rowActions, 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..731b3ace --- /dev/null +++ b/packages/core/src/components/data-table/use-persistent-column-state.ts @@ -0,0 +1,127 @@ +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 + } +} + +// Tracks currently-mounted `tableId`s (dev aid). Two tables sharing an id map +// to the same localStorage key and clobber each other's layout, so warn. +const mountedTableIds = new Map(); + +/** + * 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; + + // Warn when two mounted tables share a `tableId` — they persist to the same + // storage key and overwrite each other. Counted so React StrictMode's + // mount/unmount/remount doesn't trip a false positive. + useEffect(() => { + if (!tableId) return; + const count = (mountedTableIds.get(tableId) ?? 0) + 1; + mountedTableIds.set(tableId, count); + if (count > 1) { + console.warn( + `[DataTable] Duplicate tableId "${tableId}": multiple tables share one localStorage key and will clobber each other's column layout. Use a unique id per table (e.g. ":").`, + ); + } + return () => { + const remaining = (mountedTableIds.get(tableId) ?? 1) - 1; + if (remaining <= 0) mountedTableIds.delete(tableId); + else mountedTableIds.set(tableId, remaining); + }; + }, [tableId]); + + // Hydrate from storage on mount / when the table id changes. Falls back to the + // current defaults when there's nothing stored — and when `tableId` is cleared, + // reset to defaults (in-memory mode) rather than leaking the previous table's + // persisted layout. This read does NOT write back, so a stored value is never + // clobbered. + useEffect(() => { + setStateRaw(tableId ? (readState(tableId) ?? defaultsRef.current) : 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 (