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 (
+
+
+ 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 (
+
{
+ 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). */}
+
+
+
+
+
+
+
+
+ );
+}
+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 */}
-