From caf08a4b4d91eae98f2bf36ae34faa0de4957928 Mon Sep 17 00:00:00 2001 From: itsprade Date: Thu, 3 Sep 2026 20:07:11 +0530 Subject: [PATCH 1/9] feat(examples): add DataTable multi-select footer-actions prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `/data-table-selection` to the vite example: 240 vendor records with a checkbox column, and a footer that swaps its row-count text for a bulk-action bar while a selection is open. The bar reuses the same `DataTable.Pagination` in both states, so the right-hand cluster is identical whether or not rows are selected. Inversion is done by re-pointing the surface tokens on a wrapper inside the footer, so the pagination buttons, the page-size Select and the action Buttons re-theme themselves without any class overrides. Prototype only — nothing under packages/** changes. Co-Authored-By: Claude Opus 5 --- examples/vite-app/src/App.tsx | 1 + .../src/pages/data-table-selection/page.tsx | 440 ++++++++++++++++++ examples/vite-app/src/routes.generated.ts | 1 + 3 files changed, 442 insertions(+) create mode 100644 examples/vite-app/src/pages/data-table-selection/page.tsx diff --git a/examples/vite-app/src/App.tsx b/examples/vite-app/src/App.tsx index c1d0e6146..990615583 100644 --- a/examples/vite-app/src/App.tsx +++ b/examples/vite-app/src/App.tsx @@ -56,6 +56,7 @@ const App = () => { + diff --git a/examples/vite-app/src/pages/data-table-selection/page.tsx b/examples/vite-app/src/pages/data-table-selection/page.tsx new file mode 100644 index 000000000..4807819a0 --- /dev/null +++ b/examples/vite-app/src/pages/data-table-selection/page.tsx @@ -0,0 +1,440 @@ +import { useMemo, useState, type CSSProperties, type ReactNode } from "react"; +import { + Layout, + Button, + DataTable, + useDataTable, + useDataTableContext, + useCollectionVariables, + useToast, + createColumnHelper, + type AppShellPageProps, + type CollectionVariables, + type DataTableData, + type PageInfo, +} from "@tailor-platform/app-shell"; +import { CheckSquare, Pause, Play, Trash2 } from "lucide-react"; + +// ─── Dummy data ────────────────────────────────────────────────────────────── +// 🧪 Dummy Data: Replace with a real GraphQL-backed source later. + +type VendorStatus = "active" | "inactive" | "archived"; + +// A `type` (not `interface`) so it satisfies `Record` — +// `createColumnHelper`/`useDataTable`'s row constraint. +type Vendor = { + id: string; + code: string; + name: string; + category: string; + owner: string; + region: string; + status: VendorStatus; + spend: number; + lastOrder: string; +}; + +const NAMES = [ + "Acme Corp", + "Globex", + "Initech", + "Umbrella", + "Soylent", + "Hooli", + "Stark Industries", + "Wayne Supply", + "Cyberdyne", + "Tyrell Parts", + "Vandelay Imports", + "Gekko Trading", +]; +const CATEGORIES = ["Raw material", "Packaging", "Logistics", "MRO", "Services", "Tooling"]; +const OWNERS = ["A. Kimura", "B. Osei", "C. Lindqvist", "D. Alvarez", "E. Nakamura", "F. Bianchi"]; +const REGIONS = ["North America", "EMEA", "APAC", "LATAM"]; +const STATUSES: VendorStatus[] = ["active", "inactive", "archived"]; + +// Deterministic pseudo-random so the dataset is stable across renders/reloads. +function makeVendors(count: number): Vendor[] { + const rows: Vendor[] = []; + let seed = 90210; + const rand = () => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed / 0x7fffffff; + }; + const pick = (list: readonly T[]): T => list[Math.floor(rand() * list.length)]; + const base = new Date("2026-01-01T00:00:00Z").getTime(); + for (let i = 0; i < count; i++) { + const name = pick(NAMES); + rows.push({ + id: `VEN-${String(2000 + i)}`, + code: `V${String(2000 + i)}`, + // Suffix keeps names unique-ish across a long list without new fixtures. + name: `${name} ${pick(["Ltd.", "GmbH", "K.K.", "Inc.", "SA"])}`, + category: pick(CATEGORIES), + owner: pick(OWNERS), + region: pick(REGIONS), + // Weighted so a random multi-select usually spans two or three statuses — + // which is what makes the per-action counts in the footer interesting. + status: rand() > 0.45 ? "active" : rand() > 0.4 ? "inactive" : pick(STATUSES), + spend: Math.round((rand() * 480_000 + 4_000) * 100) / 100, + lastOrder: new Date(base + Math.floor(rand() * 240) * 86_400_000).toISOString().slice(0, 10), + }); + } + return rows; +} + +const ALL_VENDORS = makeVendors(240); + +// ─── Local data source (stub for a GraphQL query) ──────────────────────────── +// Sync (no simulated latency) so paging while a selection is open stays snappy +// — the point of this page is the footer, not the loading states. + +function selectPage(variables: CollectionVariables): DataTableData { + let rows = ALL_VENDORS; + + if (variables.order?.length) { + const [{ field, direction }] = variables.order; + const dir = direction === "Desc" ? -1 : 1; + // `toSorted` would satisfy the lint rule but the app targets ES2020, so + // sort a copy — ALL_VENDORS must not be mutated. + // oxlint-disable-next-line unicorn/no-array-sort + rows = [...rows].sort((a, b) => { + const av = a[field as keyof Vendor]; + const bv = b[field as keyof Vendor]; + if (av < bv) return -1 * dir; + if (av > bv) return 1 * dir; + return 0; + }); + } + + const total = rows.length; + + // Cursor pagination — cursor === stringified index into the sorted list. + const { first, after, last, before } = variables.pagination; + let startIndex: number; + let endIndex: number; + if (last != null) { + endIndex = before != null ? Number(before) : total; + startIndex = Math.max(0, endIndex - last); + } else { + startIndex = after != null ? Number(after) + 1 : 0; + endIndex = startIndex + (first ?? 25); + } + const page = rows.slice(startIndex, endIndex); + + const pageInfo: PageInfo = { + startCursor: page.length ? String(startIndex) : null, + endCursor: page.length ? String(startIndex + page.length - 1) : null, + hasPreviousPage: startIndex > 0, + hasNextPage: startIndex + page.length < total, + }; + + return { rows: page, pageInfo, total }; +} + +// ─── Columns ───────────────────────────────────────────────────────────────── + +const { column } = createColumnHelper(); + +const columns = [ + column({ + label: "Code", + accessor: (row) => row.code, + type: "text", + width: 96, + pin: "left", + filter: { field: "code", type: "string" }, + }), + column({ + label: "Vendor", + accessor: (row) => row.name, + type: "text", + truncate: true, + sort: { field: "name", type: "string" }, + filter: { field: "name", type: "string" }, + }), + column({ + label: "Category", + accessor: (row) => row.category, + type: "text", + width: 140, + filter: { + field: "category", + type: "enum", + options: CATEGORIES.map((c) => ({ value: c, label: c })), + }, + }), + column({ label: "Owner", accessor: (row) => row.owner, type: "text", width: 140 }), + column({ label: "Region", accessor: (row) => row.region, type: "text", width: 150 }), + column({ + label: "Status", + accessor: (row) => row.status, + type: "badge", + width: 120, + typeOptions: { + badgeVariantMap: { + active: "success", + inactive: "outline-warning", + archived: "neutral", + }, + badgeLabelMap: { active: "Active", inactive: "Inactive", archived: "Archived" }, + }, + filter: { + field: "status", + type: "enum", + options: STATUSES.map((s) => ({ value: s, label: s })), + }, + }), + column({ + label: "YTD spend", + accessor: (row) => row.spend, + type: "money", + width: 140, + sort: { field: "spend", type: "number" }, + filter: { field: "spend", type: "number" }, + }), + column({ + label: "Last order", + accessor: (row) => row.lastOrder, + type: "date", + width: 130, + sort: { field: "lastOrder", type: "date" }, + }), +]; + +// ─── Selection footer bar ──────────────────────────────────────────────────── +// ✅ Reusable Component: candidate for `DataTable.SelectionActions` in core — +// the bulk-action bar that takes over the footer while a selection is open. +// +// Actions live in the footer rather than the toolbar for two reasons: the row +// count already lives here (so the count and the things you can do to it stay +// together), and the footer is the only strip that is always on screen in a +// `` table — the toolbar scrolls away on long pages. + +type SelectionAction = { + id: string; + label: string; + icon?: ReactNode; + /** Rows in the selection this action actually applies to. Shown as "(n)". */ + count: number; + variant?: "secondary" | "ghost" | "destructive"; + onClick: () => void; +}; + +function SelectionBar({ actions }: { actions: SelectionAction[] }) { + const { selectedIds, clearSelection, total } = useDataTableContext(); + + return ( +
+ {/* "20 of 240 selected" — the denominator is the whole filtered + collection, not the current page, which is the scope the actions act + on. `total` is null when the backend returns no count, so the bar + falls back to the bare selected count. */} + + {total === null + ? `${selectedIds.length} selected` + : `${selectedIds.length} of ${total} selected`} + + +
+ {actions.map((action) => ( + + ))} +
+ + +
+ ); +} + +// 🧪 Two candidate treatments for the active footer: `primary` is brand-tinted, +// `neutral` is a plain inverted surface (near-black in light mode, near-white in +// dark). `surface` is a class on the footer itself; `ink` feeds `toneTokens`, +// which re-points the surface tokens for everything inside the bar. +// +// Re-pointing tokens (rather than overriding class by class) is what lets the +// footer keep the *same* `DataTable.Pagination` in both states: its buttons, +// labels and page-size Select read `--foreground` / `--muted-foreground` / +// `--border` / `--accent`, so they re-theme themselves to the inverted surface +// with no `!important` and no descendant selectors. +// +// `ink` deliberately points at tokens `toneTokens` does not itself redefine +// (`--primary-foreground`, `--card`), otherwise the reference would be circular. +const TONES = { + primary: { surface: "bg-primary", ink: "var(--primary-foreground)" }, + neutral: { surface: "bg-foreground", ink: "var(--card)" }, +} as const; + +type FooterTone = keyof typeof TONES; + +function toneTokens(ink: string): CSSProperties { + const tint = (pct: number) => `color-mix(in srgb, ${ink} ${pct}%, transparent)`; + return { + color: ink, + "--foreground": ink, + "--muted-foreground": tint(75), + // Outline controls read as ghost chips on the bar rather than light cards. + "--background": "transparent", + "--border": tint(25), + "--input": tint(25), + "--accent": tint(15), + "--accent-foreground": ink, + "--secondary": tint(15), + "--secondary-foreground": ink, + "--ring": tint(45), + } as CSSProperties; +} + +// ─── Page ──────────────────────────────────────────────────────────────────── + +const DataTableSelectionPage = () => { + const toast = useToast(); + const [tone, setTone] = useState("primary"); + const [selectedIds, setSelectedIds] = useState([]); + + const { variables, control } = useCollectionVariables({ + params: { pageSize: 25 }, + }); + + const data = useMemo(() => selectPage(variables), [variables]); + + const table = useDataTable({ + columns, + data, + loading: false, + control, + // Providing `onSelectionChange` is what adds the checkbox column at the + // left edge (header checkbox = select all on the current page). + onSelectionChange: setSelectedIds, + }); + + // Per-action eligibility, counted across the whole selection — not just the + // visible page — because selection is id-based and survives paging. + const selectedRows = useMemo(() => { + const ids = new Set(selectedIds); + return ALL_VENDORS.filter((v) => ids.has(v.id)); + }, [selectedIds]); + + const countBy = (status: VendorStatus) => + selectedRows.filter((row) => row.status === status).length; + + const actions: SelectionAction[] = [ + { + id: "activate", + label: "Activate", + icon: , + count: countBy("inactive"), + onClick: () => toast.success(`Activated ${countBy("inactive")} vendor(s)`), + }, + { + id: "deactivate", + label: "Deactivate", + icon: , + count: countBy("active"), + onClick: () => toast.success(`Deactivated ${countBy("active")} vendor(s)`), + }, + { + id: "delete", + label: "Delete", + icon: , + variant: "destructive", + count: countBy("archived"), + onClick: () => toast.error(`Deleted ${countBy("archived")} vendor(s)`), + }, + ]; + + const hasSelection = selectedIds.length > 0; + + return ( + // `fill` pins the toolbar and footer so the selection bar stays on screen + // while the 240 rows scroll behind it. + + + +
+

+ 240 vendor records with a checkbox column. Select a few rows — the footer swaps the row + count for the bulk-action bar and inverts its surface. Each action is counted against + the selection — Activate only applies to inactive vendors, Delete only + to archived ones — and disables at zero. Selection is id-based, so it survives paging. +

+ {/* 🧪 Prototype control: compare the two active-footer treatments. */} +
+ {(Object.keys(TONES) as FooterTone[]).map((key) => ( + + ))} +
+
+ + + + + + + {/* `rounded-b-md` matches the Root's own corner radius: the footer's + background is what paints the bottom of the card once a selection + tints it, so without this it would square off the rounded frame. + `min-h-13` keeps both states the same height so switching between + them doesn't nudge the table above. */} + + {/* Inner row carries the tone's token overrides — the footer itself + only paints the surface, so its own tokens stay intact. */} +
+ {hasSelection && } + {/* One Pagination in both states, so the right-hand cluster (rows + per page, page counter, first/prev/next/last) is identical + whether or not a selection is open. While the bar is up its + row-info text is hidden — the bar's own "N selected" owns that + slot — leaving the controls pushed right by their own ml-auto. + min-w-110: below ~440px of leftover space the cluster drops to + its own line instead of squeezing the bar. `whitespace-nowrap` + is inherited, so the page counter stays on one line. */} +
div>div:first-child]:hidden" : "" + }`} + > + +
+
+
+
+
+
+ ); +}; + +DataTableSelectionPage.appShellPageProps = { + meta: { + title: "Multi-select footer", + icon: , + }, +} satisfies AppShellPageProps; + +export default DataTableSelectionPage; diff --git a/examples/vite-app/src/routes.generated.ts b/examples/vite-app/src/routes.generated.ts index e36b62d6d..6d1df97ae 100644 --- a/examples/vite-app/src/routes.generated.ts +++ b/examples/vite-app/src/routes.generated.ts @@ -25,6 +25,7 @@ export type GeneratedRouteParams = { "/dashboard/products": {}; "/data-table": {}; "/data-table-lab": {}; + "/data-table-selection": {}; "/date-picker": {}; "/settings": {}; }; From 9a6439b2ce3cb9b6d77293c2b60f96a0c6fd9e68 Mon Sep 17 00:00:00 2001 From: itsprade Date: Thu, 3 Sep 2026 20:07:12 +0530 Subject: [PATCH 2/9] =?UTF-8?q?docs(data-table):=20open=20question=20?= =?UTF-8?q?=E2=80=94=20footer=20bulk=20actions=20in=20the=20component=20or?= =?UTF-8?q?=20as=20a=20pattern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the footer direction for multi-select bulk actions, notes that `catalogue/src/pattern/interaction/multi-select` currently prescribes a floating bottom bar (built on raw Table.Root, predating DataTable selection) and so needs rewriting either way, and frames the open question for a team call: build the bar into DataTable via `selectionActions`, or keep it a documented pattern. No decision taken. Co-Authored-By: Claude Opus 5 --- .../data-table-selection-footer-actions.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 decisions/data-table-selection-footer-actions.md diff --git a/decisions/data-table-selection-footer-actions.md b/decisions/data-table-selection-footer-actions.md new file mode 100644 index 000000000..373781212 --- /dev/null +++ b/decisions/data-table-selection-footer-actions.md @@ -0,0 +1,63 @@ +# DataTable — multi-select bulk actions in the footer + +**Status:** open — for a team call. No decision taken here. +**Prototype:** `examples/vite-app/src/pages/data-table-selection/page.tsx` (`/data-table-selection` in the vite example). Nothing under `packages/**` or `catalogue/` changes. + +Multi-select already works: passing `onSelectionChange` to `useDataTable` adds the checkbox column, and the footer already reads "N of M row(s) selected". What AppShell has no settled answer for is **where the bulk actions go** once rows are selected. + +Sean suggested putting them in the **footer**, next to the selection count that already lives there, instead of a bar floating over the table. The prototype builds that out so we can look at it: + +``` +┌──────────────────────────────────────────────────────────────────────────────────────┐ +│ ☑ V2016 Globex K.K. Raw material D. Alvarez LATAM Active US$355,974.26 │ +├──────────────────────────────────────────────────────────────────────────────────────┤ +│ 20 of 240 selected │ ▷ Activate (6) ⏸ Deactivate (14) 🗑 Delete (0) │ Clear │ +│ Rows per page 25 Page 1/10 « ‹ › » │ +└──────────────────────────────────────────────────────────────────────────────────────┘ +``` + +Why the footer: the selection count already lives there, its middle is empty in every table we ship, it never covers rows, it needs no overlay or z-index, and in `` it is already pinned on screen. + +## What already exists, and conflicts + +`catalogue/src/pattern/interaction/multi-select` — shipped to agents as part of the `app-shell-patterns` skill — specifies the **floating bottom bar**: `position: fixed`, centered, elevated, appearing when the count goes 0 → 1. Its anti-patterns say bulk actions belong _only_ in that floating bar, and its reference implementation is built on raw `Table.Root` with native checkboxes, predating DataTable's own selection. `pattern/list/dense-scan` points DataTable users at it ("combine with `interaction/multi-select`"). + +So this pattern needs rewriting either way. The question is what it should point at. + +## The question: component or pattern? + +### Option A — build it into DataTable + +Actions are declared once and the footer renders the bar: + +```tsx +const table = useDataTable({ + columns, + data, + control, + onSelectionChange: setSelectedIds, + selectionActions: [ + { id: "activate", label: "Activate", icon: , count: 6, onClick: (ids) => … }, + { id: "delete", label: "Delete", icon: , variant: "destructive", count: 0, onClick: (ids) => … }, + ], +}); +``` + +`interaction/multi-select` then becomes a thin pattern that says "use `selectionActions`". + +- Mirrors `rowActions` — a declarative array on the hook that makes the component render a whole affordance (the kebab column). Same shape, same place. +- Existing tables opt in with one option; wording, spacing, disable-at-zero and responsive behaviour are decided once, for every app, and can be tested. +- Costs: more presentation config on the hook, one opinionated bar, and adoption needs a version bump. + +### Option B — keep it a pattern + +Ship nothing in `packages/**`; rewrite `interaction/multi-select` around the footer, composed from `useDataTableContext()` inside `DataTable.Footer`. + +- No API surface, no version bump — apps and agents pick it up as soon as the skill regenerates. +- Patterns are already how AppShell ships interaction guidance, and agents consume them directly. +- Costs: each app carries the code, so consistency depends on the pattern being followed; behaviour can't be tested in this repo. + +## For the call + +- Component (A) or pattern (B)? +- Either way: who rewrites `interaction/multi-select`, and does the floating bar stay as a documented alternative for non-DataTable lists (its current implementation is `Table.Root`-based)? From dcc9e770f730a8abcdf8763f73bfb0b8aedcadae Mon Sep 17 00:00:00 2001 From: itsprade Date: Tue, 29 Sep 2026 12:50:16 +0530 Subject: [PATCH 3/9] fix(data-table): keep other pages' rows when toggling the header checkbox Selection persists across pages, but the header checkbox did not respect that: checking it replaced the selection with the current page's rows, and unchecking it cleared every page. With 5 rows picked on page 1, select-all on page 2 gave 25 selected instead of 30. The header checkbox is now page-scoped in both directions. selectAllRows adds the page's rows to the selection, and a new deselectAllRows removes only the page's rows; clearSelection still empties everything. deselectAllRows is optional on DataTableContextValue, and a hand-built context without it falls back to clearSelection. Co-Authored-By: Claude Opus 5.5 --- .../data-table/data-table-context.tsx | 14 ++++- .../components/data-table/data-table.test.tsx | 25 ++++++++ .../src/components/data-table/data-table.tsx | 6 +- .../core/src/components/data-table/types.ts | 9 ++- .../data-table/use-data-table.test.ts | 57 +++++++++++++++++++ .../components/data-table/use-data-table.ts | 30 ++++++++-- 6 files changed, 129 insertions(+), 12 deletions(-) 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 85be5f92f..aa5d44132 100644 --- a/packages/core/src/components/data-table/data-table-context.tsx +++ b/packages/core/src/components/data-table/data-table-context.tsx @@ -62,15 +62,23 @@ export interface DataTableContextValue> { rowActions?: RowAction[]; // Row selection - // toggleRowSelection / selectAllRows / clearSelection are undefined when onSelectionChange is not provided + // toggleRowSelection / selectAllRows / deselectAllRows / clearSelection are undefined + // when onSelectionChange is not provided. New members are optional for the + // hand-constructible reason noted under Row expansion. selectedIds: string[]; isRowSelected: (row: TRow) => boolean; toggleRowSelection?: (row: TRow) => void; /** - * Selects all rows on the **current page** only. Cross-page selection is not supported. - * Undefined when `onSelectionChange` is not provided. + * Adds every row on the **current page** to the selection; rows selected on + * other pages are kept. */ selectAllRows?: () => void; + /** + * Removes the **current page's** rows from the selection; rows selected on + * other pages stay selected. + */ + deselectAllRows?: () => void; + /** Empties the selection across all pages. */ clearSelection?: () => void; isAllSelected: boolean; isIndeterminate: boolean; 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 1444d3d50..14a719114 100644 --- a/packages/core/src/components/data-table/data-table.test.tsx +++ b/packages/core/src/components/data-table/data-table.test.tsx @@ -1827,6 +1827,31 @@ describe("DataTable", () => { container.querySelectorAll('[data-slot="data-table-row"][data-state="selected"]'), ).toHaveLength(1); }); + + it("the header checkbox adds and removes only the current page's rows", () => { + const onSelectionChange = vi.fn(); + const pageB: DataTableData = { + rows: [ + { id: "3", name: "Carol", status: "Active" }, + { id: "4", name: "Dave", status: "Inactive" }, + ], + }; + const { rerender } = render(, { + wrapper, + }); + + // Pick Alice on the first page, then move to the second. + fireEvent.click(screen.getAllByRole("checkbox")[1]); + rerender(); + + // Checking the header adds this page without dropping Alice… + fireEvent.click(screen.getByLabelText("Select all rows")); + expect(onSelectionChange).toHaveBeenLastCalledWith(["1", "3", "4"]); + + // …and unchecking it removes only this page's rows. + fireEvent.click(screen.getByLabelText("Select all rows")); + expect(onSelectionChange).toHaveBeenLastCalledWith(["1"]); + }); }); // ------------------------------------------------------------------------- diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index 97271cde7..0a4a7f0e2 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -617,6 +617,7 @@ function DataTableRoot>({ isRowSelected: value.isRowSelected, toggleRowSelection: value.toggleRowSelection, selectAllRows: value.selectAllRows, + deselectAllRows: value.deselectAllRows, clearSelection: value.clearSelection, isAllSelected: value.isAllSelected, isIndeterminate: value.isIndeterminate, @@ -909,6 +910,7 @@ function DataTableHeaders({ className: headerClassName }: { className?: string } setPin, toggleRowSelection, selectAllRows, + deselectAllRows, clearSelection, isAllSelected, isIndeterminate, @@ -962,7 +964,9 @@ function DataTableHeaders({ className: headerClassName }: { className?: string } if (checked) { selectAllRows?.(); } else { - clearSelection?.(); + // Page-scoped, so rows selected on other pages survive. + // Hand-built contexts that predate it fall back to clearing. + (deselectAllRows ?? clearSelection)?.(); } }} aria-label={t("selectAll")} diff --git a/packages/core/src/components/data-table/types.ts b/packages/core/src/components/data-table/types.ts index ddcd71af4..8604d75d5 100644 --- a/packages/core/src/components/data-table/types.ts +++ b/packages/core/src/components/data-table/types.ts @@ -442,8 +442,9 @@ export type UseDataTableOptions< * **Requirement:** Each row must have a string or number `id` field. * Rows without `id` are excluded from selection. * - * **Note:** `selectAllRows` (triggered by the header checkbox) selects only - * the rows on the **current page**, not all pages. + * **Note:** The header checkbox acts on the **current page**: checking it adds + * the page's rows to the selection and unchecking it removes them. Rows + * selected on other pages are kept. */ onSelectionChange?: (ids: string[]) => void; /** @@ -568,7 +569,11 @@ export interface UseDataTableReturn> { selectedIds: string[]; isRowSelected: (row: TRow) => boolean; toggleRowSelection?: (row: TRow) => void; + /** Adds every row on the current page to the selection. */ selectAllRows?: () => void; + /** Removes the current page's rows from the selection; other pages' rows stay selected. */ + deselectAllRows?: () => void; + /** Empties the selection across all pages. */ clearSelection?: () => void; isAllSelected: boolean; isIndeterminate: boolean; 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 50af8e75d..72b21e83e 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 @@ -615,10 +615,67 @@ describe("useDataTable", () => { expect(result.current.toggleRowSelection).toBeUndefined(); expect(result.current.selectAllRows).toBeUndefined(); + expect(result.current.deselectAllRows).toBeUndefined(); expect(result.current.clearSelection).toBeUndefined(); }); }); + // ------------------------------------------------------------------------- + // Selection across pages + // ------------------------------------------------------------------------- + describe("selection across pages", () => { + const pageB: DataTableData = { + rows: [ + { id: "3", name: "Carol", value: 30 }, + { id: "4", name: "Dave", value: 40 }, + ], + }; + + function renderPaged(onSelectionChange = vi.fn()) { + return renderHook( + ({ data }: { data: DataTableData }) => + useDataTable({ columns, data, onSelectionChange }), + { initialProps: { data: testData } }, + ); + } + + it("selectAllRows adds the current page to rows selected on other pages", () => { + const onSelectionChange = vi.fn(); + const { result, rerender } = renderPaged(onSelectionChange); + + act(() => result.current.toggleRowSelection!(testData.rows[0])); + rerender({ data: pageB }); + act(() => result.current.selectAllRows!()); + + expect(result.current.selectedIds).toEqual(["1", "3", "4"]); + expect(onSelectionChange).toHaveBeenLastCalledWith(["1", "3", "4"]); + }); + + it("deselectAllRows removes only the current page's rows", () => { + const onSelectionChange = vi.fn(); + const { result, rerender } = renderPaged(onSelectionChange); + + act(() => result.current.toggleRowSelection!(testData.rows[0])); + rerender({ data: pageB }); + act(() => result.current.selectAllRows!()); + act(() => result.current.deselectAllRows!()); + + expect(result.current.selectedIds).toEqual(["1"]); + expect(onSelectionChange).toHaveBeenLastCalledWith(["1"]); + }); + + it("clearSelection still empties every page", () => { + const { result, rerender } = renderPaged(); + + act(() => result.current.toggleRowSelection!(testData.rows[0])); + rerender({ data: pageB }); + act(() => result.current.selectAllRows!()); + act(() => result.current.clearSelection!()); + + expect(result.current.selectedIds).toEqual([]); + }); + }); + // ------------------------------------------------------------------------- // Rows without id // ------------------------------------------------------------------------- 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 3014fb6ea..e2b8f8d47 100644 --- a/packages/core/src/components/data-table/use-data-table.ts +++ b/packages/core/src/components/data-table/use-data-table.ts @@ -322,14 +322,31 @@ export function useDataTable< } : undefined; + // Page-scoped on purpose: rows selected on other pages are left alone, so the + // header checkbox never silently drops part of a cross-page selection. const selectAllRows = onSelectionChange ? () => { - const allIds = new Set( - rows.map((r) => getRowId(r)).filter((id): id is string => id !== null), - ); - selectedRowIdsRef.current = allIds; - setSelectedRowIds(allIds); - onSelectionChange([...allIds]); + const next = new Set(selectedRowIdsRef.current); + for (const row of rows) { + const id = getRowId(row); + if (id !== null) next.add(id); + } + selectedRowIdsRef.current = next; + setSelectedRowIds(next); + onSelectionChange([...next]); + } + : undefined; + + const deselectAllRows = onSelectionChange + ? () => { + const next = new Set(selectedRowIdsRef.current); + for (const row of rows) { + const id = getRowId(row); + if (id !== null) next.delete(id); + } + selectedRowIdsRef.current = next; + setSelectedRowIds(next); + onSelectionChange([...next]); } : undefined; @@ -467,6 +484,7 @@ export function useDataTable< isRowSelected, toggleRowSelection, selectAllRows, + deselectAllRows, clearSelection, isAllSelected, isIndeterminate, From 945c5ef10348af528a7a71a25404ce65e93cd3b0 Mon Sep 17 00:00:00 2001 From: itsprade Date: Tue, 29 Sep 2026 12:51:13 +0530 Subject: [PATCH 4/9] feat(data-table): add selectionActions and remember selected rows across pages Adds the data half of footer bulk actions (#1738): a selectionActions option on useDataTable, shaped like rowActions, and the selected rows it acts on. The footer bar that renders these actions follows in the next commit. - SelectionAction: id, label, icon, variant, and an optional appliesTo(row) predicate that narrows an action to the selected rows it applies to. onClick receives those rows plus a clearSelection helper. - A non-empty selectionActions array enables row selection on its own, so onSelectionChange stays optional. - selectedRows: selection is now an ordered map of id -> row as last seen, so rows picked on other pages can still be counted and handed to an action. Rows on the current page always win over the remembered copy. selectedRows is required on UseDataTableReturn, like expandedIds, and optional on DataTableContextValue, which is documented as hand-constructible. Co-Authored-By: Claude Opus 5.5 --- .../data-table/data-table-context.tsx | 13 ++- .../src/components/data-table/data-table.tsx | 2 + .../core/src/components/data-table/index.ts | 1 + .../core/src/components/data-table/types.ts | 46 ++++++++++- .../data-table/use-data-table.test.ts | 65 +++++++++++++++ .../components/data-table/use-data-table.ts | 82 +++++++++++-------- packages/core/src/index.ts | 1 + 7 files changed, 170 insertions(+), 40 deletions(-) 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 aa5d44132..14201d438 100644 --- a/packages/core/src/components/data-table/data-table-context.tsx +++ b/packages/core/src/components/data-table/data-table-context.tsx @@ -1,5 +1,5 @@ import { createContext, useContext } from "react"; -import type { Column, RowAction, RowExpansionOptions } from "./types"; +import type { Column, RowAction, RowExpansionOptions, SelectionAction } from "./types"; import type { PageInfo, SortState } from "@/types/collection"; /** @@ -60,12 +60,19 @@ export interface DataTableContextValue> { // Row interaction onClickRow?: (row: TRow) => void; rowActions?: RowAction[]; + /** Bulk actions `DataTable.Footer` shows while rows are selected. */ + selectionActions?: SelectionAction[]; // Row selection // toggleRowSelection / selectAllRows / deselectAllRows / clearSelection are undefined - // when onSelectionChange is not provided. New members are optional for the - // hand-constructible reason noted under Row expansion. + // when selection is not enabled (neither onSelectionChange nor selectionActions given). + // New members are optional for the hand-constructible reason noted under Row expansion. selectedIds: string[]; + /** + * The selected rows, in selection order. Rows on the current page are their + * latest version; rows selected on other pages are the version last loaded. + */ + selectedRows?: TRow[]; isRowSelected: (row: TRow) => boolean; toggleRowSelection?: (row: TRow) => void; /** diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index 0a4a7f0e2..1c99c16f9 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -613,7 +613,9 @@ function DataTableRoot>({ hasNextPage: value.hasNextPage, onClickRow: value.onClickRow, rowActions: value.rowActions, + selectionActions: value.selectionActions, selectedIds: value.selectedIds, + selectedRows: value.selectedRows, isRowSelected: value.isRowSelected, toggleRowSelection: value.toggleRowSelection, selectAllRows: value.selectAllRows, diff --git a/packages/core/src/components/data-table/index.ts b/packages/core/src/components/data-table/index.ts index 27c3ad969..4e469291b 100644 --- a/packages/core/src/components/data-table/index.ts +++ b/packages/core/src/components/data-table/index.ts @@ -24,6 +24,7 @@ export type { MoneyCellOptions, NumberCellOptions, RowAction, + SelectionAction, UseDataTableOptions, UseDataTableReturn, } from "./types"; diff --git a/packages/core/src/components/data-table/types.ts b/packages/core/src/components/data-table/types.ts index 8604d75d5..57facb035 100644 --- a/packages/core/src/components/data-table/types.ts +++ b/packages/core/src/components/data-table/types.ts @@ -436,8 +436,9 @@ export type UseDataTableOptions< rowActions?: RowAction[]; /** * Called with the current array of selected row IDs whenever the selection - * changes. Providing this prop enables the checkbox selection column. - * Selection is ID-based (`row.id`) and persists across page changes. + * changes. Providing this prop — or a non-empty `selectionActions` — enables + * the checkbox selection column. Selection is ID-based (`row.id`) and persists + * across page changes. * * **Requirement:** Each row must have a string or number `id` field. * Rows without `id` are excluded from selection. @@ -447,6 +448,17 @@ export type UseDataTableOptions< * selected on other pages are kept. */ onSelectionChange?: (ids: string[]) => void; + /** + * Bulk actions for the selected rows. While at least one row is selected, + * `DataTable.Footer` turns into an action bar: the selection count, these + * actions, and a Clear button, with the pagination controls kept alongside. + * The bar is omitted when this array is empty or not provided. + * + * Providing a non-empty array also enables row selection, so + * `onSelectionChange` is optional. The first three actions render as buttons; + * the rest collapse into a "More actions" menu. + */ + selectionActions?: SelectionAction[]; /** * Expandable detail rows. Providing this enables the whole feature: a chevron * column is added at the left edge (auto-pinned left, after the selection @@ -489,6 +501,30 @@ export interface RowAction> { onClick: (row: TRow) => void; } +/** + * A bulk action for the selected rows, shown in `DataTable.Footer` while a + * selection is open. See `UseDataTableOptions.selectionActions`. + */ +export interface SelectionAction> { + id: string; + label: string; + icon?: ReactNode; + variant?: "default" | "destructive"; + /** + * Narrows the action to the selected rows it applies to — e.g. "Activate" + * only for inactive rows. When set, the button shows how many selected rows + * qualify ("Activate (6)"), is disabled when none do, and `onClick` receives + * only those rows. Omit it for actions that apply to every selected row. + */ + appliesTo?: (row: TRow) => boolean; + /** + * Called with the selected rows the action applies to, including rows + * selected on other pages (as they were last loaded). Call `clearSelection` + * once the action has done its work. + */ + onClick: (rows: TRow[], helpers: { clearSelection: () => void }) => void; +} + /** * Return type of `useDataTable` hook. */ @@ -564,9 +600,15 @@ export interface UseDataTableReturn> { // Row interaction (passthrough for DataTable.Provider) onClickRow?: (row: TRow) => void; rowActions?: RowAction[]; + selectionActions?: SelectionAction[]; // Row selection selectedIds: string[]; + /** + * The selected rows, in selection order. Rows on the current page are their + * latest version; rows selected on other pages are the version last loaded. + */ + selectedRows: TRow[]; isRowSelected: (row: TRow) => boolean; toggleRowSelection?: (row: TRow) => void; /** Adds every row on the current page to the selection. */ diff --git a/packages/core/src/components/data-table/use-data-table.test.ts b/packages/core/src/components/data-table/use-data-table.test.ts index 72b21e83e..3411b6060 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 @@ -673,6 +673,71 @@ describe("useDataTable", () => { act(() => result.current.clearSelection!()); expect(result.current.selectedIds).toEqual([]); + expect(result.current.selectedRows).toEqual([]); + }); + + it("selectedRows keeps rows selected on other pages, as last seen", () => { + const { result, rerender } = renderPaged(); + + act(() => result.current.toggleRowSelection!(testData.rows[0])); + rerender({ data: pageB }); + act(() => result.current.toggleRowSelection!(pageB.rows[0])); + + expect(result.current.selectedRows).toEqual([testData.rows[0], pageB.rows[0]]); + }); + + it("selectedRows prefers the current page's copy of a selected row", () => { + const { result, rerender } = renderPaged(); + + act(() => result.current.toggleRowSelection!(testData.rows[0])); + // Same row id, refetched with new values — e.g. after a bulk update. + const refetched = { id: "1", name: "Alice", value: 99 }; + rerender({ data: { rows: [refetched, testData.rows[1]] } }); + + expect(result.current.selectedRows).toEqual([refetched]); + }); + + it("selectedRows drops a row once it is deselected", () => { + const { result } = renderPaged(); + + act(() => result.current.toggleRowSelection!(testData.rows[0])); + act(() => result.current.toggleRowSelection!(testData.rows[0])); + + expect(result.current.selectedRows).toEqual([]); + }); + }); + + // ------------------------------------------------------------------------- + // selectionActions + // ------------------------------------------------------------------------- + describe("selectionActions", () => { + const actions = [{ id: "archive", label: "Archive", onClick: vi.fn() }]; + + it("a non-empty array enables selection without onSelectionChange", () => { + const { result } = renderHook(() => + useDataTable({ columns, data: testData, selectionActions: actions }), + ); + + expect(result.current.toggleRowSelection).toBeDefined(); + act(() => result.current.toggleRowSelection!(testData.rows[1])); + expect(result.current.selectedIds).toEqual(["2"]); + expect(result.current.selectedRows).toEqual([testData.rows[1]]); + }); + + it("an empty array does not enable selection", () => { + const { result } = renderHook(() => + useDataTable({ columns, data: testData, selectionActions: [] }), + ); + + expect(result.current.toggleRowSelection).toBeUndefined(); + }); + + it("is passed through for DataTable.Root", () => { + const { result } = renderHook(() => + useDataTable({ columns, data: testData, selectionActions: actions }), + ); + + expect(result.current.selectionActions).toBe(actions); }); }); 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 e2b8f8d47..b33b486b2 100644 --- a/packages/core/src/components/data-table/use-data-table.ts +++ b/packages/core/src/components/data-table/use-data-table.ts @@ -75,6 +75,7 @@ export function useDataTable< onClickRow, rowActions, onSelectionChange, + selectionActions, rowExpansion, sort: sortOption, } = options; @@ -285,81 +286,90 @@ export function useDataTable< return id != null ? String(id) : null; }, []); - const [selectedRowIds, setSelectedRowIds] = useState>(new Set()); - // Mirrors the state so the toggle can compute the next set outside an updater. + // Bulk actions need rows to act on, so offering them turns selection on even + // without an `onSelectionChange` listener. + const selectionEnabled = !!onSelectionChange || (selectionActions?.length ?? 0) > 0; + + // Selected ids in selection order, each mapped to the row as last seen. The + // row is kept so rows selected on other pages can still be counted and handed + // to `selectionActions`; `selectedRows` below prefers the current page's copy. + const [selection, setSelection] = useState>(() => new Map()); + // Mirrors the state so the toggle can compute the next map outside an updater. // Every writer below must also assign it: this render-time sync only catches // up on commit, so without an eager write two dispatches in the same commit // both read the same base and the first is lost. A functional updater got // this for free from `prev`; computing outside one makes it our job. - const selectedRowIdsRef = useRef(selectedRowIds); - selectedRowIdsRef.current = selectedRowIds; + const selectionRef = useRef(selection); + selectionRef.current = selection; const isRowSelected = useCallback( (row: TRow) => { const id = getRowId(row); if (id === null) return false; - return selectedRowIds.has(id); + return selection.has(id); }, - [selectedRowIds, getRowId], + [selection, getRowId], ); // Computed outside the updater: updaters must be pure, and StrictMode // double-invokes them, so dispatching from inside fired `onSelectionChange` - // twice per toggle in dev. Matches `selectAllRows` / `clearSelection`. - const toggleRowSelection = onSelectionChange + // twice per toggle in dev. Every writer goes through here. + const commitSelection = (next: Map) => { + selectionRef.current = next; + setSelection(next); + onSelectionChange?.([...next.keys()]); + }; + + const toggleRowSelection = selectionEnabled ? (row: TRow) => { const id = getRowId(row); if (id === null) return; - const next = new Set(selectedRowIdsRef.current); + const next = new Map(selectionRef.current); if (next.has(id)) { next.delete(id); } else { - next.add(id); + next.set(id, row); } - selectedRowIdsRef.current = next; - setSelectedRowIds(next); - onSelectionChange([...next]); + commitSelection(next); } : undefined; // Page-scoped on purpose: rows selected on other pages are left alone, so the // header checkbox never silently drops part of a cross-page selection. - const selectAllRows = onSelectionChange + const selectAllRows = selectionEnabled ? () => { - const next = new Set(selectedRowIdsRef.current); + const next = new Map(selectionRef.current); for (const row of rows) { const id = getRowId(row); - if (id !== null) next.add(id); + if (id !== null) next.set(id, row); } - selectedRowIdsRef.current = next; - setSelectedRowIds(next); - onSelectionChange([...next]); + commitSelection(next); } : undefined; - const deselectAllRows = onSelectionChange + const deselectAllRows = selectionEnabled ? () => { - const next = new Set(selectedRowIdsRef.current); + const next = new Map(selectionRef.current); for (const row of rows) { const id = getRowId(row); if (id !== null) next.delete(id); } - selectedRowIdsRef.current = next; - setSelectedRowIds(next); - onSelectionChange([...next]); + commitSelection(next); } : undefined; - const clearSelection = onSelectionChange - ? () => { - const empty = new Set(); - selectedRowIdsRef.current = empty; - setSelectedRowIds(empty); - onSelectionChange([]); - } - : undefined; + const clearSelection = selectionEnabled ? () => commitSelection(new Map()) : undefined; + + const selectedIds = useMemo(() => [...selection.keys()], [selection]); - const selectedIds = useMemo(() => [...selectedRowIds], [selectedRowIds]); + const selectedRows = useMemo(() => { + const onPage = new Map(); + for (const row of rows) { + const id = getRowId(row); + if (id !== null && selection.has(id)) onPage.set(id, row); + } + return [...selection].map(([id, lastSeen]) => onPage.get(id) ?? lastSeen); + }, [selection, rows, getRowId]); const selectableCount = rows.filter((r) => getRowId(r) !== null).length; const isAllSelected = @@ -367,9 +377,9 @@ export function useDataTable< rows.every((r) => { const id = getRowId(r); // Rows without id are not selectable — skip them in the check - return id === null || selectedRowIds.has(id); + return id === null || selection.has(id); }); - const isIndeterminate = selectedRowIds.size > 0 && !isAllSelected; + const isIndeterminate = selection.size > 0 && !isAllSelected; // --------------------------------------------------------------------------- // Row expansion @@ -480,7 +490,9 @@ export function useDataTable< control: control as CollectionControl | undefined, onClickRow, rowActions, + selectionActions, selectedIds, + selectedRows, isRowSelected, toggleRowSelection, selectAllRows, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dd96cbb11..70b8eae59 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -302,6 +302,7 @@ export { type DataTableFilterConfig, type HeaderRenderContext, type RowAction, + type SelectionAction, type UseDataTableOptions, type UseDataTableReturn, type MetadataFieldOptions, From 258931fb8c934a150ff2f3deae4b6761af7550e2 Mon Sep 17 00:00:00 2001 From: itsprade Date: Tue, 29 Sep 2026 13:09:58 +0530 Subject: [PATCH 5/9] feat(data-table): turn DataTable.Footer into a bulk-action bar while rows are selected With selectionActions set and at least one row selected, the footer becomes the bulk-action bar (#1738): the count, the actions, and Clear, with the footer's own children (usually Pagination) kept alongside. Tables without selectionActions render exactly as before. - Built on the generic Toolbar (#559): the bar is a Toolbar.Row, so it gets role=toolbar and Arrow/Home/End navigation between its buttons. - The first three actions render as buttons; the rest collapse into a "More actions" menu that opens upward. An action with appliesTo shows its eligible count ("Activate (6)") and disables at zero. - Surface: bg-accent, which stays soft in every theme and both modes. A display:contents wrapper re-points --accent and --muted-foreground so hover states and secondary text stay visible on the tinted bar. - Sticky while open, so on a page-scrolling table the bar rides the bottom of the viewport until the table's end scrolls into view. The DataTable root moves from overflow-hidden to overflow-clip for this: both clip to the rounded frame, but hidden also makes the root a scroll container, which trapped the sticky footer inside the table. - Pagination hides its own "N selected" text while the bar is up and shares the line with it, dropping to a second line only when it doesn't fit. - A persistent polite live region announces the count from the first tick; when the bar closes with focus inside it, focus returns to the header checkbox. Labels in en and ja. Co-Authored-By: Claude Opus 5.5 --- .../src/components/data-table/data-table.tsx | 61 +++- .../core/src/components/data-table/i18n.ts | 23 ++ .../src/components/data-table/pagination.tsx | 17 +- .../data-table/selection-bar.test.tsx | 297 ++++++++++++++++++ .../components/data-table/selection-bar.tsx | 161 ++++++++++ 5 files changed, 553 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/components/data-table/selection-bar.test.tsx create mode 100644 packages/core/src/components/data-table/selection-bar.tsx diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index 1c99c16f9..5fada83e0 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -44,6 +44,12 @@ import { } from "./toolbar"; import { DataTableColumnSettings } from "./column-settings"; import { DataTablePagination } from "./pagination"; +import { + DataTableSelectionAnnouncer, + DataTableSelectionBar, + hasSelectionActions, + isSelectionBarOpen, +} from "./selection-bar"; export type { DataTablePaginationProps } from "./pagination"; // Fallback row count when no pageSize is configured (static / uncontrolled tables) @@ -640,11 +646,15 @@ function DataTableRoot>({ {/* flex-col + min-h-0 (no flex-1): natural height when content fits, but able to shrink when the parent chain constrains height (e.g. ). When shrunk, the Table region scrolls internally - while the Toolbar and Footer (shrink-0) stay visible. */} + while the Toolbar and Footer (shrink-0) stay visible. + overflow-clip, not overflow-hidden: both clip children to the + rounded frame, but `hidden` also makes this a scroll container, + which would pin the sticky selection-actions footer to the table + instead of letting it ride the page's scroll container. */}
[data-slot=toolbar]]:rounded-b-none astw:[&>[data-slot=toolbar]]:border-x-0 astw:[&>[data-slot=toolbar]]:border-t-0", @@ -1862,16 +1872,54 @@ DataTableTable.displayName = "DataTable.Table"; // ============================================================================= /** Use `DataTable.Footer` instead of calling this directly. */ -function DataTableFooter({ children, className }: { children: ReactNode; className?: string }) { +function DataTableFooter({ children, className }: { children?: ReactNode; className?: string }) { + const ctx = useContext(DataTableContext); + const offersBulkActions = hasSelectionActions(ctx); + const selecting = isSelectionBarOpen(ctx); + return (
, where the footer is already pinned. + "astw:sticky astw:bottom-0 astw:z-20", + // The bar's surface, captured for the wrapper below: a token can't be + // re-pointed in terms of itself on a single element. + "astw:[--data-table-selection-surface:var(--accent)]", + ], className, )} > - {children} + {offersBulkActions ? ( + // `contents` keeps the wrapper out of layout. It re-points the hover and + // secondary-text tokens for everything on the tinted bar — the bar's own + // buttons and the consumer's Pagination alike — which would otherwise + // hover to the bar's colour and lose contrast on it. +
+ {selecting && } + {children} + +
+ ) : ( + children + )}
); } @@ -1910,6 +1958,11 @@ export const DataTable = { /** * Footer container for pagination and other footer content. * Place inside `DataTable.Root`, after `DataTable.Table`. + * + * When `useDataTable()` is given `selectionActions`, the footer becomes the + * bulk-action bar while rows are selected — the selection count, the + * actions, and a Clear button — with its own children (usually + * `DataTable.Pagination`) kept alongside. */ Footer: DataTableFooter, /** diff --git a/packages/core/src/components/data-table/i18n.ts b/packages/core/src/components/data-table/i18n.ts index 9243962f2..2e7c44adb 100644 --- a/packages/core/src/components/data-table/i18n.ts +++ b/packages/core/src/components/data-table/i18n.ts @@ -45,6 +45,20 @@ export const dataTableLabels = defineI18nLabels({ selectAll: "Select all rows", selectRow: "Select row", + // Selection actions bar (DataTable.Footer while rows are selected). The + // Clear button's accessible name extends its visible text, so it is still + // unambiguous when read outside the bar. + selectionActionsLabel: "Bulk actions", + // Shorter than Pagination's "N of M row(s) selected": the bar shares the + // footer line with the pagination controls. + selectionCount: (props: { selected: number; total: number | null }) => + props.total === null + ? `${props.selected} selected` + : `${props.selected} of ${props.total} selected`, + selectionMoreActions: "More actions", + selectionClear: "Clear", + selectionClearLabel: "Clear selection", + // Row expansion. `label` is a bare record identity from // `rowExpansion.getLabel` (e.g. "INV-1001"); each locale owns the word // order, and the unnamed fallback, so the accessible name reads naturally. @@ -156,6 +170,15 @@ export const dataTableLabels = defineI18nLabels({ selectAll: "全行を選択", selectRow: "行を選択", + selectionActionsLabel: "一括操作", + selectionCount: (props: { selected: number; total: number | null }) => + props.total === null + ? `${props.selected} 行を選択中` + : `${props.total} 行中 ${props.selected} 行を選択中`, + selectionMoreActions: "その他の操作", + selectionClear: "解除", + selectionClearLabel: "選択を解除", + // Row expansion expandColumnHeader: "展開", expandRow: (props: { label?: string }) => diff --git a/packages/core/src/components/data-table/pagination.tsx b/packages/core/src/components/data-table/pagination.tsx index fdd9479ca..1c30cdccf 100644 --- a/packages/core/src/components/data-table/pagination.tsx +++ b/packages/core/src/components/data-table/pagination.tsx @@ -1,8 +1,10 @@ import { ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight } from "lucide-react"; +import { cn } from "@/lib/utils"; import { Button } from "@/components/button"; import { Select } from "@/components/select"; import { useDataTableContext } from "./data-table-context"; import { useDataTableT } from "./i18n"; +import { isSelectionBarOpen } from "./selection-bar"; export interface DataTablePaginationProps { /** @@ -14,6 +16,7 @@ export interface DataTablePaginationProps { /** Use `DataTable.Pagination` instead of calling this directly. */ export function DataTablePagination({ pageSizeOptions }: DataTablePaginationProps = {}) { + const ctx = useDataTableContext(); const { pageInfo, total, @@ -29,13 +32,16 @@ export function DataTablePagination({ pageSizeOptions }: DataTablePaginationProp setPageSize, selectedIds, toggleRowSelection, - } = useDataTableContext(); + } = ctx; const t = useDataTableT(); const selectionEnabled = toggleRowSelection !== undefined; const selectedCount = selectedIds.length; + // While the footer shows the bulk-action bar, the bar owns the count. + const barOpen = isSelectionBarOpen(ctx); const rowInfoText = (() => { + if (barOpen) return null; if (selectionEnabled && selectedCount > 0 && total !== null) { return t("paginationSelectedOfTotal", { selected: selectedCount, total }); } @@ -49,7 +55,14 @@ export function DataTablePagination({ pageSizeOptions }: DataTablePaginationProp })(); return ( -
+ // Beside the bar, flex-1 instead of w-full lets both share one line and + // wraps the controls onto a line of their own only when they don't fit. +
{rowInfoText &&
{rowInfoText}
}
{pageSizeOptions && pageSizeOptions.length > 0 && ( diff --git a/packages/core/src/components/data-table/selection-bar.test.tsx b/packages/core/src/components/data-table/selection-bar.test.tsx new file mode 100644 index 000000000..0d9d87d97 --- /dev/null +++ b/packages/core/src/components/data-table/selection-bar.test.tsx @@ -0,0 +1,297 @@ +import { afterEach, describe, it, expect, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createAppShellWrapper } from "../../../tests/test-utils"; +import type { CollectionControl } from "@/types/collection"; +import { DataTable } from "./data-table"; +import { useDataTable } from "./use-data-table"; +import type { Column, DataTableData, SelectionAction } from "./types"; + +afterEach(() => { + cleanup(); +}); + +type Vendor = { id: string; name: string; status: "active" | "inactive" | "archived" }; + +const vendors: Vendor[] = [ + { id: "1", name: "Acme", status: "active" }, + { id: "2", name: "Globex", status: "inactive" }, + { id: "3", name: "Initech", status: "archived" }, +]; + +const columns: Column[] = [{ label: "Name", render: (row) => row.name }]; + +function makeControl(): CollectionControl { + return { + filters: [], + addFilter: vi.fn(), + setFilters: vi.fn(), + removeFilter: vi.fn(), + clearFilters: vi.fn(), + sortStates: [], + setSort: vi.fn(), + clearSort: vi.fn(), + pageSize: 10, + setPageSize: vi.fn(), + goToNextPage: vi.fn(), + goToPrevPage: vi.fn(), + resetPage: vi.fn(), + goToFirstPage: vi.fn(), + goToLastPage: vi.fn(), + resetCount: 0, + getHasPrevPage: () => false, + getHasNextPage: (pageInfo) => pageInfo.hasNextPage, + }; +} + +const archive = (onClick = vi.fn()): SelectionAction => ({ + id: "archive", + label: "Archive", + onClick, +}); + +function Harness({ + selectionActions, + onSelectionChange, + data = { rows: vendors, total: 3 }, + footer = "pagination", +}: { + selectionActions?: SelectionAction[]; + onSelectionChange?: (ids: string[]) => void; + data?: DataTableData; + footer?: "pagination" | "empty"; +}) { + const table = useDataTable({ + columns, + data, + control: makeControl(), + onSelectionChange, + selectionActions, + }); + return ( + + + {footer === "pagination" ? ( + + + + ) : ( + + )} + + ); +} + +const wrapper = createAppShellWrapper("en"); + +const rowCheckbox = (index: number) => screen.getAllByLabelText("Select row")[index]; +const queryBar = () => screen.queryByRole("toolbar", { name: "Bulk actions" }); +const getBar = () => screen.getByRole("toolbar", { name: "Bulk actions" }); +const footerOf = (container: HTMLElement) => + container.querySelector('[data-slot="data-table-footer"]')!; + +describe("DataTable selection actions", () => { + it("shows no bar until a row is selected", () => { + const { container } = render(, { wrapper }); + + expect(queryBar()).toBeNull(); + expect(footerOf(container).hasAttribute("data-selecting")).toBe(false); + expect(screen.getByText("3 row(s)")).toBeDefined(); + }); + + it("turns the footer into the bar, owning the count, once a row is selected", () => { + const { container } = render(, { wrapper }); + + fireEvent.click(rowCheckbox(0)); + + const bar = getBar(); + expect(within(bar).getByText("1 of 3 selected")).toBeDefined(); + expect(within(bar).getByRole("button", { name: "Archive" })).toBeDefined(); + expect(within(bar).getByRole("button", { name: "Clear selection" })).toBeDefined(); + const footer = footerOf(container); + expect(footer.hasAttribute("data-selecting")).toBe(true); + // Sticky, so a page-scrolling table keeps the bar on screen. + expect(footer.className).toContain("astw:sticky"); + // Pagination hides its own count while the bar shows it. + expect(screen.queryByText("3 row(s)")).toBeNull(); + expect(screen.getByLabelText("Next page")).toBeDefined(); + }); + + it("falls back to the bare count when the backend returns no total", () => { + render(, { wrapper }); + + fireEvent.click(rowCheckbox(0)); + + expect(within(getBar()).getByText("1 selected")).toBeDefined(); + }); + + it("counts the rows each narrowed action applies to and disables it at zero", () => { + const actions: SelectionAction[] = [ + { + id: "activate", + label: "Activate", + appliesTo: (v) => v.status === "inactive", + onClick: vi.fn(), + }, + { + id: "deactivate", + label: "Deactivate", + appliesTo: (v) => v.status === "active", + onClick: vi.fn(), + }, + archive(), + ]; + render(, { wrapper }); + + fireEvent.click(rowCheckbox(0)); // Acme — active + fireEvent.click(rowCheckbox(2)); // Initech — archived + + const bar = getBar(); + expect(within(bar).getByRole("button", { name: "Activate (0)" })).toHaveProperty( + "disabled", + true, + ); + expect(within(bar).getByRole("button", { name: "Deactivate (1)" })).toHaveProperty( + "disabled", + false, + ); + // No `appliesTo`: applies to every selected row, so no count is shown. + expect(within(bar).getByRole("button", { name: "Archive" })).toHaveProperty("disabled", false); + }); + + it("hands onClick only the eligible rows, plus a clearSelection helper", () => { + const onSelectionChange = vi.fn(); + const onClick = vi.fn((_rows: Vendor[], { clearSelection }: { clearSelection: () => void }) => + clearSelection(), + ); + const actions: SelectionAction[] = [ + { id: "deactivate", label: "Deactivate", appliesTo: (v) => v.status === "active", onClick }, + ]; + render(, { + wrapper, + }); + + fireEvent.click(rowCheckbox(0)); // Acme — active + fireEvent.click(rowCheckbox(1)); // Globex — inactive + fireEvent.click(within(getBar()).getByRole("button", { name: "Deactivate (1)" })); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0]).toEqual([vendors[0]]); + expect(onSelectionChange).toHaveBeenLastCalledWith([]); + expect(queryBar()).toBeNull(); + }); + + it("includes rows selected on other pages", () => { + const onClick = vi.fn(); + const { rerender } = render(, { wrapper }); + + fireEvent.click(rowCheckbox(0)); // Acme, on the first page + const nextPage = { rows: [{ id: "4", name: "Hooli", status: "active" as const }], total: 4 }; + rerender(); + fireEvent.click(rowCheckbox(0)); // Hooli, on the second + + expect(within(getBar()).getByText("2 of 4 selected")).toBeDefined(); + fireEvent.click(within(getBar()).getByRole("button", { name: "Archive" })); + expect(onClick.mock.calls[0][0]).toEqual([vendors[0], nextPage.rows[0]]); + }); + + it("moves actions past the third into a More actions menu", async () => { + const user = userEvent.setup(); + const onDelete = vi.fn(); + const actions: SelectionAction[] = [ + { id: "a", label: "Activate", onClick: vi.fn() }, + { id: "b", label: "Deactivate", onClick: vi.fn() }, + { id: "c", label: "Export", onClick: vi.fn() }, + { + id: "delete", + label: "Delete", + variant: "destructive", + appliesTo: (v) => v.status === "archived", + onClick: onDelete, + }, + ]; + render(, { wrapper }); + + await user.click(rowCheckbox(2)); // Initech — archived + + const bar = getBar(); + expect(within(bar).queryByRole("button", { name: /Delete/ })).toBeNull(); + await user.click(within(bar).getByRole("button", { name: "More actions" })); + const item = await screen.findByRole("menuitem", { name: "Delete (1)" }); + await user.click(item); + + expect(onDelete).toHaveBeenCalledTimes(1); + expect(onDelete.mock.calls[0][0]).toEqual([vendors[2]]); + }); + + it("Clear empties the selection and hands focus back to the header checkbox", async () => { + const user = userEvent.setup(); + const onSelectionChange = vi.fn(); + render(, { + wrapper, + }); + + await user.click(rowCheckbox(0)); + await user.click(within(getBar()).getByRole("button", { name: "Clear selection" })); + + expect(onSelectionChange).toHaveBeenLastCalledWith([]); + expect(queryBar()).toBeNull(); + expect(document.activeElement).toBe(screen.getByLabelText("Select all rows")); + }); + + it("supports arrow-key navigation between the bar's controls", async () => { + const user = userEvent.setup(); + const actions: SelectionAction[] = [ + { id: "a", label: "Activate", onClick: vi.fn() }, + { id: "b", label: "Deactivate", onClick: vi.fn() }, + ]; + render(, { wrapper }); + + await user.click(rowCheckbox(0)); + within(getBar()).getByRole("button", { name: "Activate" }).focus(); + await user.keyboard("{ArrowRight}"); + + expect(document.activeElement).toBe( + within(getBar()).getByRole("button", { name: "Deactivate" }), + ); + }); + + it("announces the count politely from the first selection on", () => { + const { container } = render(, { wrapper }); + const liveRegion = () => container.querySelector('[aria-live="polite"]'); + + // Mounted before anything is selected, so the first tick is announced. + expect(liveRegion()?.textContent).toBe(""); + fireEvent.click(rowCheckbox(0)); + expect(liveRegion()?.textContent).toBe("1 of 3 selected"); + }); + + it("renders the bar in a footer without children", () => { + render(, { wrapper }); + + fireEvent.click(rowCheckbox(0)); + + expect(within(getBar()).getByRole("button", { name: "Archive" })).toBeDefined(); + }); + + it("leaves the footer as it was when no selectionActions are given", () => { + const { container } = render(, { wrapper }); + + fireEvent.click(rowCheckbox(0)); + + expect(queryBar()).toBeNull(); + expect(footerOf(container).hasAttribute("data-selecting")).toBe(false); + expect(screen.getByText("1 of 3 row(s) selected")).toBeDefined(); + expect(container.querySelector('[aria-live="polite"]')).toBeNull(); + }); + + it("closes the bar when the last selected row is deselected", async () => { + render(, { wrapper }); + + fireEvent.click(rowCheckbox(0)); + expect(queryBar()).not.toBeNull(); + fireEvent.click(rowCheckbox(0)); + + await waitFor(() => expect(queryBar()).toBeNull()); + }); +}); diff --git a/packages/core/src/components/data-table/selection-bar.tsx b/packages/core/src/components/data-table/selection-bar.tsx new file mode 100644 index 000000000..ae3c53728 --- /dev/null +++ b/packages/core/src/components/data-table/selection-bar.tsx @@ -0,0 +1,161 @@ +import { useLayoutEffect, useRef } from "react"; +import { Ellipsis } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/button"; +import { Menu } from "@/components/menu"; +import { Toolbar } from "@/components/toolbar"; +import { useDataTableContext, type DataTableContextValue } from "./data-table-context"; +import { useDataTableT } from "./i18n"; +import type { SelectionAction } from "./types"; + +/** Actions past this many collapse into the "More actions" menu. */ +const MAX_INLINE_ACTIONS = 3; + +/** + * Whether the table offers bulk actions at all: selection is on and the hook + * was given at least one `selectionActions` entry. + */ +export function hasSelectionActions>( + ctx: DataTableContextValue | null, +): boolean { + return !!ctx?.toggleRowSelection && (ctx.selectionActions?.length ?? 0) > 0; +} + +/** Whether `DataTable.Footer` is currently showing the bulk-action bar. */ +export function isSelectionBarOpen>( + ctx: DataTableContextValue | null, +): boolean { + return hasSelectionActions(ctx) && (ctx?.selectedIds.length ?? 0) > 0; +} + +/** "3 of 240 selected", or "3 selected" when the backend returns no total. */ +function useSelectionCountText(): string { + const { selectedIds, total } = useDataTableContext(); + const t = useDataTableT(); + return t("selectionCount", { selected: selectedIds.length, total }); +} + +type ResolvedAction> = { + action: SelectionAction; + /** The selected rows this action applies to — what `onClick` receives. */ + rows: TRow[]; + /** Shown as "(n)" only when the action narrows the selection. */ + count: number | null; +}; + +/** + * The bulk-action bar `DataTable.Footer` renders while rows are selected: + * count · actions · overflow menu · Clear. + */ +export function DataTableSelectionBar>() { + const { selectionActions = [], selectedRows = [], clearSelection } = useDataTableContext(); + const t = useDataTableT(); + const countText = useSelectionCountText(); + const rowRef = useRef(null); + + // The bar unmounts as soon as the selection empties, usually from its own + // Clear button or an action that clears. Hand focus to the header checkbox + // instead of letting it drop to . Layout-effect cleanup runs while the + // bar is still in the document, so `contains` sees the focused button. + useLayoutEffect(() => { + const bar = rowRef.current; + return () => { + if (!bar?.contains(document.activeElement)) return; + bar + .closest('[data-slot="data-table"]') + ?.querySelector('[data-slot="data-table-header"] [data-slot="checkbox"]') + ?.focus(); + }; + }, []); + + const clear = () => clearSelection?.(); + const resolved: ResolvedAction[] = selectionActions.map((action) => { + const { appliesTo } = action; + const rows = appliesTo ? selectedRows.filter((row) => appliesTo(row)) : selectedRows; + return { action, rows, count: appliesTo ? rows.length : null }; + }); + const inline = resolved.slice(0, MAX_INLINE_ACTIONS); + const overflow = resolved.slice(MAX_INLINE_ACTIONS); + + return ( + // max-w-full + shrink-0: next to Pagination in the footer's wrapping row, + // the bar keeps its actions on one line and lets Pagination drop below it, + // and only wraps its own buttons once it is wider than the whole footer. + + + + {countText} + + + {inline.map(({ action, rows, count }) => ( + + ))} + {overflow.length > 0 && ( + + + + + } + /> + {/* Opens upward: the bar sits at the bottom of the table, and often + at the bottom of the viewport while it is stuck there. */} + + {overflow.map(({ action, rows, count }) => { + const disabled = rows.length === 0; + return ( + { + if (!disabled) action.onClick(rows, { clearSelection: clear }); + }} + className={cn(action.variant === "destructive" && "astw:text-destructive")} + > + {action.icon} + {action.label} + {count !== null && ({count})} + + ); + })} + + + )} + + + + + ); +} +DataTableSelectionBar.displayName = "DataTable.SelectionBar"; + +/** + * Polite live region for the selection count. It stays mounted while the table + * offers bulk actions, so the first tick (0 → 1, when the bar itself mounts) is + * announced too — a region that mounts together with its text is not. + */ +export function DataTableSelectionAnnouncer({ open }: { open: boolean }) { + const countText = useSelectionCountText(); + return ( + + {open ? countText : ""} + + ); +} From 7c219ebe91b853d036c86202d7b0de14eaa9f533 Mon Sep 17 00:00:00 2001 From: itsprade Date: Tue, 29 Sep 2026 13:11:20 +0530 Subject: [PATCH 6/9] feat(examples): move the bulk-actions demo onto selectionActions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /showcase/data-table-selection was the #496 prototype: a hand-built footer bar on useDataTableContext with a primary/neutral tone toggle. It now uses the real API, so the page shows exactly what the core component does: - selectionActions with appliesTo counts, Activate / Deactivate / Export inline and Delete in the More actions menu behind a confirm dialog (interaction/confirm) - actions mutate local state, so counts and badges update after an action - the generic Toolbar scaffold on top, matching the other DataTable demos - a Pinned footer / Page scroll switch to compare with a page- scrolling table, where the bar rides the bottom of the window dashboard/products drops its ad-hoc "Selected: …" line for Publish / Archive / Delete bulk actions. Co-Authored-By: Claude Opus 5.5 --- .../src/pages/dashboard/products/page.tsx | 35 +- .../showcase/data-table-selection/page.tsx | 311 +++++++----------- 2 files changed, 146 insertions(+), 200 deletions(-) diff --git a/examples/vite-app/src/pages/dashboard/products/page.tsx b/examples/vite-app/src/pages/dashboard/products/page.tsx index 27e5b6b7b..ab25905c0 100644 --- a/examples/vite-app/src/pages/dashboard/products/page.tsx +++ b/examples/vite-app/src/pages/dashboard/products/page.tsx @@ -6,9 +6,9 @@ import { createColumnHelper, type AppShellPageProps, type RowAction, + type SelectionAction, } from "@tailor-platform/app-shell"; import { Package } from "lucide-react"; -import { useState } from "react"; import { type Product, useProductsQuery } from "../../../mock-products"; const productMetadata = { @@ -86,6 +86,33 @@ const rowActions: RowAction[] = [ }, ]; +const names = (rows: Product[]) => rows.map((row) => row.name).join(", "); + +// Bulk actions in the footer while rows are selected. `appliesTo` scopes each +// action to the selected rows it can act on and shows that count. +const selectionActions: SelectionAction[] = [ + { + id: "publish", + label: "Publish", + appliesTo: (row) => row.status === "Draft", + onClick: (rows) => alert(`Publish: ${names(rows)}`), + }, + { + id: "archive", + label: "Archive", + appliesTo: (row) => row.status !== "Archived", + onClick: (rows) => alert(`Archive: ${names(rows)}`), + }, + { + id: "delete", + label: "Delete", + variant: "destructive", + // Same rule as the row action: active products can't be deleted. + appliesTo: (row) => row.status !== "Active", + onClick: (rows) => alert(`Delete: ${names(rows)}`), + }, +]; + const ProductsPage = () => { // Single composed hook: filter/sort/pagination state is persisted to the URL // (bookmarkable, back-button friendly) and the URL seeds the initial state @@ -97,7 +124,6 @@ const ProductsPage = () => { }); const { data, loading } = useProductsQuery(variables); - const [selectedIds, setSelectedIds] = useState([]); const table = useDataTable({ columns, @@ -112,7 +138,7 @@ const ProductsPage = () => { control, rowActions, onClickRow: (row) => alert(`Clicked: ${row.name}`), - onSelectionChange: (ids) => setSelectedIds(ids), + selectionActions, }); return ( @@ -137,9 +163,6 @@ const ProductsPage = () => { - {selectedIds.length > 0 && ( -

Selected: {selectedIds.join(", ")}

- )}
); diff --git a/examples/vite-app/src/pages/showcase/data-table-selection/page.tsx b/examples/vite-app/src/pages/showcase/data-table-selection/page.tsx index 4807819a0..60ade2017 100644 --- a/examples/vite-app/src/pages/showcase/data-table-selection/page.tsx +++ b/examples/vite-app/src/pages/showcase/data-table-selection/page.tsx @@ -1,10 +1,11 @@ -import { useMemo, useState, type CSSProperties, type ReactNode } from "react"; +import { useMemo, useState } from "react"; import { Layout, Button, DataTable, + Dialog, + Toolbar, useDataTable, - useDataTableContext, useCollectionVariables, useToast, createColumnHelper, @@ -12,8 +13,9 @@ import { type CollectionVariables, type DataTableData, type PageInfo, + type SelectionAction, } from "@tailor-platform/app-shell"; -import { CheckSquare, Pause, Play, Trash2 } from "lucide-react"; +import { CheckSquare, Download, Pause, Play, Trash2 } from "lucide-react"; // ─── Dummy data ────────────────────────────────────────────────────────────── // 🧪 Dummy Data: Replace with a real GraphQL-backed source later. @@ -83,20 +85,20 @@ function makeVendors(count: number): Vendor[] { return rows; } -const ALL_VENDORS = makeVendors(240); +const INITIAL_VENDORS = makeVendors(240); // ─── Local data source (stub for a GraphQL query) ──────────────────────────── // Sync (no simulated latency) so paging while a selection is open stays snappy // — the point of this page is the footer, not the loading states. -function selectPage(variables: CollectionVariables): DataTableData { - let rows = ALL_VENDORS; +function selectPage(vendors: Vendor[], variables: CollectionVariables): DataTableData { + let rows = vendors; if (variables.order?.length) { const [{ field, direction }] = variables.order; const dir = direction === "Desc" ? -1 : 1; // `toSorted` would satisfy the lint rule but the app targets ES2020, so - // sort a copy — ALL_VENDORS must not be mutated. + // sort a copy — the vendors state must not be mutated. // oxlint-disable-next-line unicorn/no-array-sort rows = [...rows].sort((a, b) => { const av = a[field as keyof Vendor]; @@ -202,229 +204,150 @@ const columns = [ }), ]; -// ─── Selection footer bar ──────────────────────────────────────────────────── -// ✅ Reusable Component: candidate for `DataTable.SelectionActions` in core — -// the bulk-action bar that takes over the footer while a selection is open. -// -// Actions live in the footer rather than the toolbar for two reasons: the row -// count already lives here (so the count and the things you can do to it stay -// together), and the footer is the only strip that is always on screen in a -// `` table — the toolbar scrolls away on long pages. - -type SelectionAction = { - id: string; - label: string; - icon?: ReactNode; - /** Rows in the selection this action actually applies to. Shown as "(n)". */ - count: number; - variant?: "secondary" | "ghost" | "destructive"; - onClick: () => void; -}; - -function SelectionBar({ actions }: { actions: SelectionAction[] }) { - const { selectedIds, clearSelection, total } = useDataTableContext(); - - return ( -
- {/* "20 of 240 selected" — the denominator is the whole filtered - collection, not the current page, which is the scope the actions act - on. `total` is null when the backend returns no count, so the bar - falls back to the bare selected count. */} - - {total === null - ? `${selectedIds.length} selected` - : `${selectedIds.length} of ${total} selected`} - - -
- {actions.map((action) => ( - - ))} -
- - -
- ); -} - -// 🧪 Two candidate treatments for the active footer: `primary` is brand-tinted, -// `neutral` is a plain inverted surface (near-black in light mode, near-white in -// dark). `surface` is a class on the footer itself; `ink` feeds `toneTokens`, -// which re-points the surface tokens for everything inside the bar. -// -// Re-pointing tokens (rather than overriding class by class) is what lets the -// footer keep the *same* `DataTable.Pagination` in both states: its buttons, -// labels and page-size Select read `--foreground` / `--muted-foreground` / -// `--border` / `--accent`, so they re-theme themselves to the inverted surface -// with no `!important` and no descendant selectors. -// -// `ink` deliberately points at tokens `toneTokens` does not itself redefine -// (`--primary-foreground`, `--card`), otherwise the reference would be circular. -const TONES = { - primary: { surface: "bg-primary", ink: "var(--primary-foreground)" }, - neutral: { surface: "bg-foreground", ink: "var(--card)" }, -} as const; - -type FooterTone = keyof typeof TONES; - -function toneTokens(ink: string): CSSProperties { - const tint = (pct: number) => `color-mix(in srgb, ${ink} ${pct}%, transparent)`; - return { - color: ink, - "--foreground": ink, - "--muted-foreground": tint(75), - // Outline controls read as ghost chips on the bar rather than light cards. - "--background": "transparent", - "--border": tint(25), - "--input": tint(25), - "--accent": tint(15), - "--accent-foreground": ink, - "--secondary": tint(15), - "--secondary-foreground": ink, - "--ring": tint(45), - } as CSSProperties; -} - // ─── Page ──────────────────────────────────────────────────────────────────── +type PendingDelete = { rows: Vendor[]; clearSelection: () => void }; + const DataTableSelectionPage = () => { const toast = useToast(); - const [tone, setTone] = useState("primary"); - const [selectedIds, setSelectedIds] = useState([]); - - const { variables, control } = useCollectionVariables({ - params: { pageSize: 25 }, - }); - - const data = useMemo(() => selectPage(variables), [variables]); - - const table = useDataTable({ - columns, - data, - loading: false, - control, - // Providing `onSelectionChange` is what adds the checkbox column at the - // left edge (header checkbox = select all on the current page). - onSelectionChange: setSelectedIds, - }); - - // Per-action eligibility, counted across the whole selection — not just the - // visible page — because selection is id-based and survives paging. - const selectedRows = useMemo(() => { - const ids = new Set(selectedIds); - return ALL_VENDORS.filter((v) => ids.has(v.id)); - }, [selectedIds]); - - const countBy = (status: VendorStatus) => - selectedRows.filter((row) => row.status === status).length; + // 🧪 Dummy Data: local state stands in for mutations + a refetch. + const [vendors, setVendors] = useState(INITIAL_VENDORS); + // 🧪 Showcase control: compare the pinned footer with a page-scrolling table. + const [fill, setFill] = useState(true); + const [pendingDelete, setPendingDelete] = useState(null); + + const { variables, control } = useCollectionVariables({ params: { pageSize: 25 } }); + const data = useMemo(() => selectPage(vendors, variables), [vendors, variables]); + + const setStatus = (rows: Vendor[], status: VendorStatus) => { + const ids = new Set(rows.map((row) => row.id)); + setVendors((prev) => prev.map((v) => (ids.has(v.id) ? { ...v, status } : v))); + }; - const actions: SelectionAction[] = [ + // 🔽 Bulk actions. `appliesTo` gives each action its "(n)" count and passes + // only the eligible rows to `onClick`; the footer disables an action at 0. + const selectionActions: SelectionAction[] = [ { id: "activate", label: "Activate", icon: , - count: countBy("inactive"), - onClick: () => toast.success(`Activated ${countBy("inactive")} vendor(s)`), + appliesTo: (v) => v.status === "inactive", + onClick: (rows, { clearSelection }) => { + setStatus(rows, "active"); + toast.success(`Activated ${rows.length} vendor(s)`); + clearSelection(); + }, }, { id: "deactivate", label: "Deactivate", icon: , - count: countBy("active"), - onClick: () => toast.success(`Deactivated ${countBy("active")} vendor(s)`), + appliesTo: (v) => v.status === "active", + onClick: (rows, { clearSelection }) => { + setStatus(rows, "inactive"); + toast.success(`Deactivated ${rows.length} vendor(s)`); + clearSelection(); + }, }, { + id: "export", + label: "Export", + icon: , + // No `appliesTo`: applies to every selected row, so no count is shown. + // Keeps the selection — exporting doesn't change the rows. + onClick: (rows) => toast.success(`Exported ${rows.length} vendor(s)`), + }, + { + // Fourth action: lands in the footer's "More actions" menu. id: "delete", label: "Delete", icon: , variant: "destructive", - count: countBy("archived"), - onClick: () => toast.error(`Deleted ${countBy("archived")} vendor(s)`), + appliesTo: (v) => v.status === "archived", + // Destructive: confirm first (interaction/confirm pattern). + onClick: (rows, { clearSelection }) => setPendingDelete({ rows, clearSelection }), }, ]; - const hasSelection = selectedIds.length > 0; + const table = useDataTable({ + columns, + data, + loading: false, + control, + // `selectionActions` alone turns on the checkbox column. + selectionActions, + }); + + const confirmDelete = () => { + if (!pendingDelete) return; + const ids = new Set(pendingDelete.rows.map((row) => row.id)); + setVendors((prev) => prev.filter((v) => !ids.has(v.id))); + toast.error(`Deleted ${pendingDelete.rows.length} vendor(s)`); + pendingDelete.clearSelection(); + setPendingDelete(null); + }; return ( - // `fill` pins the toolbar and footer so the selection bar stays on screen - // while the 240 rows scroll behind it. - - + +

- 240 vendor records with a checkbox column. Select a few rows — the footer swaps the row - count for the bulk-action bar and inverts its surface. Each action is counted against - the selection — Activate only applies to inactive vendors, Delete only - to archived ones — and disables at zero. Selection is id-based, so it survives paging. + 240 vendor records. Select a few rows: the footer becomes the bulk-action bar, and each + action counts the selected rows it applies to — Activate only inactive vendors,{" "} + Delete only archived ones — and disables at zero. Selection survives paging, + and the header checkbox only adds or removes the current page. In Page scroll, + the bar rides the bottom of the window until the table's end scrolls into view.

- {/* 🧪 Prototype control: compare the two active-footer treatments. */} + {/* 🧪 Showcase control */}
- {(Object.keys(TONES) as FooterTone[]).map((key) => ( - - ))} + +
- - - + + + + + + + + + + - {/* `rounded-b-md` matches the Root's own corner radius: the footer's - background is what paints the bottom of the card once a selection - tints it, so without this it would square off the rounded frame. - `min-h-13` keeps both states the same height so switching between - them doesn't nudge the table above. */} - - {/* Inner row carries the tone's token overrides — the footer itself - only paints the surface, so its own tokens stay intact. */} -
- {hasSelection && } - {/* One Pagination in both states, so the right-hand cluster (rows - per page, page counter, first/prev/next/last) is identical - whether or not a selection is open. While the bar is up its - row-info text is hidden — the bar's own "N selected" owns that - slot — leaving the controls pushed right by their own ml-auto. - min-w-110: below ~440px of leftover space the cluster drops to - its own line instead of squeezing the bar. `whitespace-nowrap` - is inherited, so the page counter stays on one line. */} -
div>div:first-child]:hidden" : "" - }`} - > - -
-
+ +
+ + { + if (!open) setPendingDelete(null); + }} + > + + + Delete {pendingDelete?.rows.length} archived vendor(s)? + + They will be removed from the vendor list. This action cannot be undone. + + + + }>Cancel + + + +
); @@ -432,7 +355,7 @@ const DataTableSelectionPage = () => { DataTableSelectionPage.appShellPageProps = { meta: { - title: "Multi-select footer", + title: "Bulk actions", icon: , }, } satisfies AppShellPageProps; From 6846bd083b933d94db3e294804b229ed1eeb4565 Mon Sep 17 00:00:00 2001 From: itsprade Date: Tue, 29 Sep 2026 13:17:28 +0530 Subject: [PATCH 7/9] docs(data-table): document selection actions and rewrite the multi-select pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DataTable docs: a Selection actions section (with accessibility notes), a SelectionAction reference table, the selectionActions option, and the footer/pagination and useDataTableContext notes that go with it. - interaction/multi-select: rewritten around selectionActions. The floating bar on a hand-built Table.Root with native checkboxes is gone, along with its dangling source marker; the example is a DataTable with an appliesTo action, an overflowed destructive action, and a confirm dialog. - list/dense-scan: bulk actions point at the footer bar. - decisions: #496's open question is now a decided record — built into DataTable, what changed from the first sketch (appliesTo instead of count, rows instead of ids, accent instead of primary/neutral), and follow-ups. - changeset: minor, calling out the page-scoped header checkbox. Generated docs/ and the manifest come from pnpm docs:sync. Co-Authored-By: Claude Opus 5.5 --- .changeset/quiet-footers-rise.md | 30 +++ .../data-table-selection-footer-actions.md | 79 ++++--- docs-manifest.json | 25 +- .../components/data-table.docs.outline.md | 87 ++++++- ...interaction-multi-select.docs.examples.tsx | 175 +++++++------- .../interaction-multi-select.docs.outline.md | 65 +++--- .../patterns/list-dense-scan.docs.outline.md | 4 +- docs/components/data-table.md | 87 ++++++- docs/patterns/interaction-multi-select.md | 220 ++++++++---------- docs/patterns/list-dense-scan.md | 4 +- 10 files changed, 458 insertions(+), 318 deletions(-) create mode 100644 .changeset/quiet-footers-rise.md diff --git a/.changeset/quiet-footers-rise.md b/.changeset/quiet-footers-rise.md new file mode 100644 index 000000000..9f843665a --- /dev/null +++ b/.changeset/quiet-footers-rise.md @@ -0,0 +1,30 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add bulk actions to `DataTable`. Pass `selectionActions` to `useDataTable` and, while rows are selected, `DataTable.Footer` becomes a bulk-action bar: the selection count, your actions, and a Clear button, with pagination kept alongside. Give an action `appliesTo` to scope it to the selected rows it can act on — the bar shows that count, disables the action at zero, and hands only those rows to `onClick`. + +```tsx +const table = useDataTable({ + columns, + data, + control, + selectionActions: [ + { + id: "activate", + label: "Activate", + appliesTo: (vendor) => vendor.status === "inactive", + onClick: (vendors, { clearSelection }) => { + activate(vendors); + clearSelection(); + }, + }, + ], +}); +``` + +Selection now also remembers the rows it holds across pages (`selectedRows`), and a non-empty `selectionActions` enables selection on its own. The first three actions render as buttons and the rest collapse into a "More actions" menu. On a page-scrolling table the bar sticks to the bottom of the viewport until the table's end scrolls into view. + +**Behavior change:** the header checkbox is now page-scoped in both directions. Checking it adds the current page's rows to the selection instead of replacing it, and unchecking it removes only the current page's rows instead of clearing every page. `clearSelection` still empties everything, and the new `deselectAllRows` is the page-scoped counterpart of `selectAllRows`. + +The `interaction/multi-select` pattern in the bundled `app-shell-patterns` skill is rewritten around `selectionActions`, replacing the floating bar on a hand-built table. diff --git a/decisions/data-table-selection-footer-actions.md b/decisions/data-table-selection-footer-actions.md index 373781212..e24c11a43 100644 --- a/decisions/data-table-selection-footer-actions.md +++ b/decisions/data-table-selection-footer-actions.md @@ -1,63 +1,66 @@ -# DataTable — multi-select bulk actions in the footer +# Decision: DataTable bulk actions live in the footer, built into the component -**Status:** open — for a team call. No decision taken here. -**Prototype:** `examples/vite-app/src/pages/data-table-selection/page.tsx` (`/data-table-selection` in the vite example). Nothing under `packages/**` or `catalogue/` changes. +> Status: **Decided — built into DataTable as `selectionActions` (Option A below). Implemented by PR #496.** +> Scope: where multi-select bulk actions render, and the `useDataTable` API that declares them. Ticket: tailor-inc/platform-planning#1738. -Multi-select already works: passing `onSelectionChange` to `useDataTable` adds the checkbox column, and the footer already reads "N of M row(s) selected". What AppShell has no settled answer for is **where the bulk actions go** once rows are selected. +## Context -Sean suggested putting them in the **footer**, next to the selection count that already lives there, instead of a bar floating over the table. The prototype builds that out so we can look at it: +Multi-select already worked: `onSelectionChange` added the checkbox column, and the footer read "N of M row(s) selected". AppShell had no settled answer for **where the bulk actions go** once rows are selected. The `interaction/multi-select` pattern prescribed a floating bottom bar built on raw `Table.Root` with native checkboxes, which predated DataTable's own selection. -``` -┌──────────────────────────────────────────────────────────────────────────────────────┐ -│ ☑ V2016 Globex K.K. Raw material D. Alvarez LATAM Active US$355,974.26 │ -├──────────────────────────────────────────────────────────────────────────────────────┤ -│ 20 of 240 selected │ ▷ Activate (6) ⏸ Deactivate (14) 🗑 Delete (0) │ Clear │ -│ Rows per page 25 Page 1/10 « ‹ › » │ -└──────────────────────────────────────────────────────────────────────────────────────┘ -``` - -Why the footer: the selection count already lives there, its middle is empty in every table we ship, it never covers rows, it needs no overlay or z-index, and in `` it is already pinned on screen. +Sean suggested the **footer** instead, next to the count that already lives there. #496 started as a prototype of that plus an open question: build it into DataTable (A), or keep it a documented pattern composed from `useDataTableContext()` (B). -## What already exists, and conflicts +- **Placement:** the footer was agreed at the App-Shell board planning session on 2026-09-18. Its middle is empty in every table we ship, it never covers rows, and in `` it is the only strip that stays on screen. The top toolbar is where apps compose search, filters, column settings and export (#559), and bulk actions would crowd it. +- **Component, not pattern:** Sean's review of the prototype (#pf-app-shell, 2026-09-04): + - row selection is already a built-in concept, so this should be an opinionated treatment in core; + - the consumer controls which actions appear; + - add a pop-out while the footer is off-screen; + - use a softer tone than the neutral bar, which glared in dark mode — maybe `accent`. -`catalogue/src/pattern/interaction/multi-select` — shipped to agents as part of the `app-shell-patterns` skill — specifies the **floating bottom bar**: `position: fixed`, centered, elevated, appearing when the count goes 0 → 1. Its anti-patterns say bulk actions belong _only_ in that floating bar, and its reference implementation is built on raw `Table.Root` with native checkboxes, predating DataTable's own selection. `pattern/list/dense-scan` points DataTable users at it ("combine with `interaction/multi-select`"). +## Decision -So this pattern needs rewriting either way. The question is what it should point at. +**`selectionActions` on `useDataTable`, rendered by `DataTable.Footer`.** -## The question: component or pattern? - -### Option A — build it into DataTable - -Actions are declared once and the footer renders the bar: +The option mirrors `rowActions`: a declarative array on the hook that makes the component render a whole affordance. It is opt-in, and tables without it render exactly as before. A non-empty array also enables selection. ```tsx const table = useDataTable({ columns, data, control, - onSelectionChange: setSelectedIds, selectionActions: [ - { id: "activate", label: "Activate", icon: , count: 6, onClick: (ids) => … }, - { id: "delete", label: "Delete", icon: , variant: "destructive", count: 0, onClick: (ids) => … }, + { + id: "activate", + label: "Activate", + icon: , + appliesTo: (v) => v.status === "inactive", // "Activate (6)", disabled at 0 + onClick: (rows, { clearSelection }) => { … }, // only the eligible rows + }, ], }); ``` -`interaction/multi-select` then becomes a thin pattern that says "use `selectionActions`". +**What changed from the sketch the team first saw:** + +- **`appliesTo(row)` replaces a consumer-supplied `count`.** Selection spans pages, and an app with server pagination can't count rows selected on other pages without keeping its own id→row cache. The table already sees every row the user selects, so it remembers them (`selectedRows`, each row as last loaded, with the current page's copy winning) and does the counting. +- **`onClick(rows, { clearSelection })` replaces `onClick(ids)`.** Rows carry what an action needs. The helper clears the selection without the action reaching back to `table` from inside its own options object. +- **The tone is `accent`, not primary or an inverted neutral.** It stays soft in all three themes and both modes, and matches the tint of selected rows. Primary is near-white in the default theme's dark mode, which brings back the glare Sean flagged. -- Mirrors `rowActions` — a declarative array on the hook that makes the component render a whole affordance (the kebab column). Same shape, same place. -- Existing tables opt in with one option; wording, spacing, disable-at-zero and responsive behaviour are decided once, for every app, and can be tested. -- Costs: more presentation config on the hook, one opinionated bar, and adoption needs a version bump. +**How the bar behaves:** -### Option B — keep it a pattern +- **Layout:** built on the generic `Toolbar` (#559). The bar is a `Toolbar.Row` (role `toolbar`, Arrow/Home/End navigation) containing count · actions · Clear. Pagination stays alongside and hides its own count while the bar is up. +- **Overflow:** three actions render inline and the rest go into a "More actions" menu, as the old pattern already required. +- **Pop-out:** the footer is `position: sticky; bottom: 0` while the bar is open, so on a page-scrolling table it rides the bottom of the viewport and settles back at the table's end. For this, the DataTable root uses `overflow: clip`. #559 had introduced `overflow: hidden` to clip the toolbar to the rounded frame, and that made the root a scroll container, which pinned the sticky footer inside the table. +- **Accessibility:** a persistent polite live region announces the count from the first tick. When the bar closes with focus inside it, focus returns to the header checkbox. +- **Header checkbox:** it is page-scoped in both directions. It previously replaced the selection and cleared every page, which the bar's cross-page count made visible. -Ship nothing in `packages/**`; rewrite `interaction/multi-select` around the footer, composed from `useDataTableContext()` inside `DataTable.Footer`. +## Consequences -- No API surface, no version bump — apps and agents pick it up as soon as the skill regenerates. -- Patterns are already how AppShell ships interaction guidance, and agents consume them directly. -- Costs: each app carries the code, so consistency depends on the pattern being followed; behaviour can't be tested in this repo. +- **`interaction/multi-select` is rewritten** around `selectionActions`. The floating bar is retired: the sticky footer covers the "keep it on screen" need, and lists that want bulk actions should be DataTables. +- **#525 interaction:** if it lands controlled/default `rowSelection`, ids selected outside the UI have no remembered row until their page loads. `appliesTo` counts cover loaded rows only, and this is documented. Whichever of #525 and this lands second adapts the other: the row memory is written wherever selection is written. +- **Toasts vs. the bar:** bulk actions naturally end in a toast, and the default bottom-right toast sits over the footer's pagination for a few seconds. That is tolerable, but worth revisiting when toast placement is next touched. -## For the call +## Not in this decision (follow-ups) -- Component (A) or pattern (B)? -- Either way: who rewrites `interaction/multi-select`, and does the floating bar stay as a documented alternative for non-DataTable lists (its current implementation is `Table.Root`-based)? +- "Select all N" across pages (needs server-side semantics). +- A placeable `DataTable.SelectionActions` for custom placement, e.g. an in-toolbar variant. This follows the "option = default placement, sub-component = custom placement" rule from tailor-inc/platform-planning#1699; add it when a consumer needs it. +- A pending/loading state on an action, tooltips explaining a disabled action, and Escape to clear. diff --git a/docs-manifest.json b/docs-manifest.json index 718997a22..753da77dd 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -421,6 +421,7 @@ "PaginationVariables", "RowAction", "SelectOption", + "SelectionAction", "SortConfig", "SortState", "TableFieldName", @@ -443,10 +444,10 @@ "withURLCollectionState" ], "hashes": { - "typeSurface": "014a9036a3b9b0af", - "outline": "f1bea006bdeb43a4", + "typeSurface": "0d4ba72f71285f5f", + "outline": "550236054e542de5", "snapshot": null, - "outputMd": "dd2da884d7550daf", + "outputMd": "da6979ebef99d190", "examples": null } }, @@ -910,10 +911,10 @@ "symbols": [], "hashes": { "typeSurface": null, - "outline": "cf74b5f060b145d1", + "outline": "af754afbf0ea9be9", "snapshot": null, - "outputMd": "09e99e5035da6e81", - "examples": "48e0d4327812dfde" + "outputMd": "cefbf2068796a92c", + "examples": "a4b15f38a7b209fe" } }, "interaction-toast": { @@ -961,9 +962,9 @@ "symbols": [], "hashes": { "typeSurface": null, - "outline": "165e45ba5f128d96", + "outline": "68653902d2c9f861", "snapshot": null, - "outputMd": "8062ea0df9403fbb", + "outputMd": "916baa3cd2b9cbf1", "examples": "c0a2bd60e8a04474" } }, @@ -1605,7 +1606,7 @@ "packages/core/skills/app-shell-patterns/references/components/combobox.md": "dfb775c7c4307409", "packages/core/skills/app-shell-patterns/references/components/command-palette.md": "7e5bc675a593791a", "packages/core/skills/app-shell-patterns/references/components/csv-importer.md": "53d3c795ca21b9dc", - "packages/core/skills/app-shell-patterns/references/components/data-table.md": "dd2da884d7550daf", + "packages/core/skills/app-shell-patterns/references/components/data-table.md": "da6979ebef99d190", "packages/core/skills/app-shell-patterns/references/components/date-picker.md": "33902be121ff69db", "packages/core/skills/app-shell-patterns/references/components/default-header.md": "375e55f4b12fa2be", "packages/core/skills/app-shell-patterns/references/components/default-sidebar.md": "3422388a0cc78571", @@ -1663,11 +1664,11 @@ "packages/core/skills/app-shell-patterns/references/patterns/form-single-page.md": "10a0f4d91a120752", "packages/core/skills/app-shell-patterns/references/patterns/form-wizard.md": "851afb4672da6673", "packages/core/skills/app-shell-patterns/references/patterns/interaction-confirm.md": "2db70f423c6fdf97", - "packages/core/skills/app-shell-patterns/references/patterns/interaction-multi-select.md": "09e99e5035da6e81", + "packages/core/skills/app-shell-patterns/references/patterns/interaction-multi-select.md": "cefbf2068796a92c", "packages/core/skills/app-shell-patterns/references/patterns/interaction-toast.md": "f68bdab855730f48", - "packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "8062ea0df9403fbb", + "packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "916baa3cd2b9cbf1", "packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "c3fb2588a0699c26", "packages/core/skills/app-shell-patterns/references/migrations.md": "799fd5010635b2c2", - "packages/core/skills/app-shell-patterns/SKILL.md": "814325f4ea06ffae" + "packages/core/skills/app-shell-patterns/SKILL.md": "12527965a47cf42f" } } diff --git a/docs-src/components/data-table.docs.outline.md b/docs-src/components/data-table.docs.outline.md index 9c10ea07d..daf881f5f 100644 --- a/docs-src/components/data-table.docs.outline.md +++ b/docs-src/components/data-table.docs.outline.md @@ -29,6 +29,7 @@ import { type DataTableRootProps, type DataTablePaginationProps, type RowAction, + type SelectionAction, type UseDataTableOptions, type UseDataTableReturn, type MetadataFieldOptions, @@ -150,15 +151,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` | DataTable-specific toolbar container. Optional; pass `columnSettings` for the built-in Columns control. | -| `DataTable.ColumnSettings` | The placeable Columns control (show/hide + reorder + pin). Use it in a generic [`Toolbar`](./toolbar.md) for custom layouts. | -| `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. | -| `DataTable.Footer` | Footer container for pagination and other footer content. Optional. | -| `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. | +| Sub-component | Description | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DataTable.Root` | Context provider. Wraps all other sub-components. Required. | +| `DataTable.Table` | Renders the `
` with headers and body. Required. | +| `DataTable.Toolbar` | DataTable-specific toolbar container. Optional; pass `columnSettings` for the built-in Columns control. | +| `DataTable.ColumnSettings` | The placeable Columns control (show/hide + reorder + pin). Use it in a generic [`Toolbar`](./toolbar.md) for custom layouts. | +| `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. | +| `DataTable.Footer` | Footer container for pagination and other footer content. Optional. Becomes the bulk-action bar while rows are selected — see [Selection actions](#selection-actions). | +| `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. | ### `DataTable.Root` Props @@ -226,7 +227,7 @@ By default `DataTable.Filters` renders the active filter chips plus the **Add fi | Rows selected and `total` is not provided | `Y row(s) selected` | | No selection enabled and no `total` | _(nothing displayed)_ | -Row selection is enabled by providing `onSelectionChange` to `useDataTable`. The `total` value comes from `DataTableData.total`. +Row selection is enabled by providing `onSelectionChange` or `selectionActions` to `useDataTable`. The `total` value comes from `DataTableData.total`. While the footer shows the bulk-action bar (see [Selection actions](#selection-actions)), the bar owns the selection count and this text is omitted. When pagination changes page or page size, `DataTable.Table` resets its own scroll container to the top automatically. That applies whether navigation comes from the built-in `DataTable.Pagination` or from custom controls using the same table context. @@ -374,6 +375,54 @@ The trigger is a native `` elements, so a screen reader counts them: ten records with two expanded announces as twelve rows. Fixing this needs `aria-rowcount` plus explicit `aria-rowindex` on every row (with detail rows sharing their parent's index) and correct interaction with pagination; `role="presentation"` on the detail row would fix the count but remove the panel from screen-reader table navigation. Neither is implemented. +## Selection actions + +Pass `selectionActions` to `useDataTable` and, while at least one row is selected, `DataTable.Footer` becomes a bulk-action bar: the selection count, your actions, and a **Clear** button, with the footer's own children (usually `DataTable.Pagination`) kept alongside. Providing the option also enables row selection, so `onSelectionChange` is optional. There is nothing new to compose in JSX — just keep `DataTable.Footer` in the tree. + +```tsx +const table = useDataTable({ + columns, + data, + control, + selectionActions: [ + { + id: "activate", + label: "Activate", + icon: , + appliesTo: (vendor) => vendor.status === "inactive", + onClick: async (vendors, { clearSelection }) => { + await activateVendors(vendors.map((vendor) => vendor.id)); + clearSelection(); + }, + }, + { + id: "export", + label: "Export", + icon: , + onClick: (vendors) => exportCsv(vendors), + }, + ], +}); + + + + + + +; +``` + +- **`appliesTo` scopes an action to part of the selection.** The button shows how many selected rows qualify — `Activate (6)` — is disabled when none do, and `onClick` receives only those rows. Omit it for actions that apply to every selected row; no count is shown then. +- **Selection spans pages.** The table remembers each selected row as it was last loaded, so counts and `onClick` cover rows selected on other pages too. Rows on the current page are always their latest version, so refetching after an action updates the counts. The header checkbox adds or removes only the current page; **Clear** empties everything. +- **Clear when the work is done.** The bar doesn't clear the selection after an action — call the `clearSelection` helper when it should. An export usually keeps the selection; an archive usually clears it. +- **Three actions stay inline.** The fourth onward collapse into a **More actions** menu, in array order. Put the most frequent first; a destructive action placed last sits safely in the menu. +- **Confirm destructive actions.** `variant: "destructive"` only styles the action. Open a confirm dialog from `onClick` before deleting — see the [confirm pattern](../patterns/interaction-confirm.md). +- **The bar stays reachable.** In `` the footer is already pinned. On a page that scrolls, the bar sticks to the bottom of the viewport while rows are selected and settles back into place at the end of the table. + +### Accessibility + +The bar is a `role="toolbar"` named "Bulk actions", so Arrow, Home, and End move between its controls (see [`Toolbar`](./toolbar.md)) while Tab still reaches each one. The selection count is announced through a polite live region from the first selection on. When the bar closes while focus is inside it — after **Clear**, or an action that clears — focus returns to the header checkbox instead of dropping to ``. + ## `useDataTable` Creates the table state object to pass to `DataTable.Root`. @@ -399,7 +448,8 @@ const table = useDataTable({ | `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`. | +| `onSelectionChange` | `(ids: string[]) => void` | Called with selected row IDs on change. Providing this (or `selectionActions`) enables the checkbox column. Rows must have a string or number `id`. | +| `selectionActions` | `SelectionAction[]` | Bulk actions shown in `DataTable.Footer` while rows are selected. A non-empty array also enables selection. See [Selection actions](#selection-actions). | | `rowExpansion` | `RowExpansionOptions` | Expandable detail rows: `render`, plus optional `canExpand` / `getLabel`, and `expandedIds` + `onChange` together for controlled mode. See [Expandable rows](#expandable-rows). | | `sort` | `false \| { multiple?: boolean }` | Sort behaviour. `false` disables sorting entirely. `{ multiple: true }` enables multi-column sorting. Omit or pass `{}` for single-column sort (default). | @@ -758,6 +808,19 @@ When `caseSensitive` is omitted or `false`, the filter is case-insensitive. When | `isDisabled` | `(row: TRow) => boolean` | Return `true` to disable the action for a given row. | | `onClick` | `(row: TRow) => void` | Called when the action is clicked. | +## `SelectionAction` + +A bulk action for the rows currently selected. See [Selection actions](#selection-actions). + +| Property | Type | Description | +| ----------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `id` | `string` | Stable identifier for the action. | +| `label` | `string` | Button label, also used for its menu item once it overflows into **More actions**. | +| `icon` | `ReactNode` | Optional icon shown before the label. | +| `variant` | `"default" \| "destructive"` | Visual style. A destructive action still needs its own confirmation step. | +| `appliesTo` | `(row: TRow) => boolean` | Scopes the action to the selected rows it applies to: shows their count, disables at zero, and passes only those rows to `onClick`. | +| `onClick` | `(rows: TRow[], helpers: { clearSelection: () => void }) => void` | Called with the selected rows the action applies to, including rows selected on other pages. | + ## `createColumnHelper` Factory that captures the row type once and returns `column` and `inferColumns` with `TRow` already bound. Prefer this over the standalone `column()` function to avoid repeating the generic parameter. @@ -918,6 +981,8 @@ function MyCustomPagination() { } ``` +The selection is here too — `selectedIds`, `selectedRows` (each selected row as last loaded, across pages), `selectAllRows` / `deselectAllRows` for the current page, and `clearSelection` — for the rare case the built-in [selection actions](#selection-actions) bar doesn't fit. + ## SDK Plugin (`@tailor-platform/sdk-plugin-app-shell`) The SDK plugin generates `tableMetadata` from TailorDB table definitions at generate time. This metadata bridges your schema to the DataTable — it specifies how each field should be rendered and filtered (e.g. date pickers for datetime fields, dropdown for enum fields). diff --git a/docs-src/patterns/interaction-multi-select.docs.examples.tsx b/docs-src/patterns/interaction-multi-select.docs.examples.tsx index 50d31eff6..084efade5 100644 --- a/docs-src/patterns/interaction-multi-select.docs.examples.tsx +++ b/docs-src/patterns/interaction-multi-select.docs.examples.tsx @@ -1,9 +1,17 @@ import { useState } from "react"; -import { Button, Table, Menu } from "@tailor-platform/app-shell"; +import { + Button, + DataTable, + Dialog, + useDataTable, + type Column, + type SelectionAction, +} from "@tailor-platform/app-shell"; + type Order = { id: string; number: string; - status: string; + status: "Open" | "Confirmed" | "Shipped"; total: number; }; @@ -15,101 +23,90 @@ const mockOrders: Order[] = [ { id: "5", number: "ORD-005", status: "Open", total: 2100 }, ]; -export function InteractionMultiSelect() { - const orders = mockOrders; - const onArchive = (ids: string[]) => window.alert(`Archiving ${ids.length}`); - const onExport = (ids: string[]) => window.alert(`Exporting ${ids.length}`); +const columns: Column[] = [ + { label: "Order #", render: (order) => order.number }, + { label: "Status", render: (order) => order.status }, + { label: "Total", render: (order) => `$${order.total.toLocaleString()}` }, +]; - const [selectedIds, setSelectedIds] = useState>(new Set()); +type PendingCancel = { orders: Order[]; clearSelection: () => void }; - const toggleRow = (id: string) => { - setSelectedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; +export function InteractionMultiSelect() { + const [pendingCancel, setPendingCancel] = useState(null); - const toggleAll = () => { - if (selectedIds.size === orders.length) { - setSelectedIds(new Set()); - } else { - setSelectedIds(new Set(orders.map((o) => o.id))); - } - }; + const selectionActions: SelectionAction[] = [ + { + id: "confirm", + label: "Confirm", + // Only open orders can be confirmed: the bar shows "Confirm (n)". + appliesTo: (order) => order.status === "Open", + onClick: (orders, { clearSelection }) => { + window.alert(`Confirming ${orders.length} order(s)`); + clearSelection(); + }, + }, + { + id: "export", + label: "Export", + onClick: (orders) => window.alert(`Exporting ${orders.length} order(s)`), + }, + { + id: "assign", + label: "Assign owner", + onClick: (orders) => window.alert(`Assigning ${orders.length} order(s)`), + }, + { + // 4th action: lands in the More actions menu. + id: "cancel", + label: "Cancel", + variant: "destructive", + appliesTo: (order) => order.status !== "Shipped", + // Destructive: confirm first (interaction/confirm). + onClick: (orders, { clearSelection }) => setPendingCancel({ orders, clearSelection }), + }, + ]; - const clearSelection = () => setSelectedIds(new Set()); - const selectedCount = selectedIds.size; + const table = useDataTable({ + columns, + data: { rows: mockOrders, total: mockOrders.length }, + selectionActions, + }); return ( <> - - - - - 0} - onChange={toggleAll} - aria-label="Select all on page" - /> - - Order # - Status - Total - - - - {orders.map((order) => ( - - - toggleRow(order.id)} - aria-label={`Select ${order.number}`} - /> - - {order.number} - {order.status} - ${order.total.toLocaleString()} - - ))} - - + + + + + + - {selectedCount > 0 && ( -
- {selectedCount} selected - - - - - - - - Assign owner - Tag - - Delete - - - -
- )} + { + if (!open) setPendingCancel(null); + }} + > + + + Cancel {pendingCancel?.orders.length} order(s)? + Cancelled orders can't be reopened. + + + }>Keep orders + + + + ); } diff --git a/docs-src/patterns/interaction-multi-select.docs.outline.md b/docs-src/patterns/interaction-multi-select.docs.outline.md index 60c6fc8f0..a09b18fe9 100644 --- a/docs-src/patterns/interaction-multi-select.docs.outline.md +++ b/docs-src/patterns/interaction-multi-select.docs.outline.md @@ -6,13 +6,13 @@ group: interaction-multi-select title: Multi Select category: pattern subcategory: interaction -description: Floating bottom action bar for bulk operations on selected list rows -requiredImports: [Table, Checkbox, Button, Menu] -tags: [bulk, selection, toolbar, floating-bar, multi-select, batch] +description: Bulk operations on selected DataTable rows, from a bar that takes over the table footer +requiredImports: [DataTable, useDataTable, Button, Dialog] +tags: [bulk, selection, bulk-actions, footer, multi-select, batch] do: - ANY list page where rows can be acted on in bulk (archive, assign, export, approve, delete) - - Selection is initiated by clicking a leading-column checkbox on rows - - Selection state needs to persist across pagination and filter changes + - The list is a DataTable — selection and the bulk-action bar are built in; you only declare the actions + - Some actions apply to only part of a selection (approve only pending rows, delete only archived ones) dont: - A list where bulk action is genuinely impossible (single-select only) - A pure picker/selector inside a Dialog whose footer already gates the action @@ -24,49 +24,48 @@ dont: ## When to Use - ANY list page where rows can be acted on in bulk (archive, assign, export, approve, delete) -- Selection is initiated by clicking a leading-column checkbox on rows -- Selection state needs to persist across pagination and filter changes +- The list is a `DataTable` — selection and the bulk-action bar are built in; you only declare the actions +- Some actions apply to only part of a selection (approve only pending rows, delete only archived ones) ## Layout -Floating action bar appears the moment selection count goes from 0 → 1, anchored to the bottom of the viewport, centered horizontally, with elevation. It disappears when selection returns to 0. +Pass `selectionActions` to `useDataTable`. The moment one row is selected, `DataTable.Footer` becomes the bulk-action bar: a tinted strip with the selection count, the actions, and Clear, with pagination kept on the right. It turns back into the normal footer when the selection empties. In `` the footer is already pinned; on a page that scrolls, the bar sticks to the bottom of the viewport until the end of the table is reached. ``` -+---------------------------------------------------------+ -| Layout.Header title [Filter] [Create] | -+---------------------------------------------------------+ -| Layout.Column | -| Table.Root | -| [x] | Col | Col | Col | Col | -| [x] | row | row | row | row | -| [ ] | row | row | row | row | -| [x] | row | row | row | row | -| | -| +--------------------------------------+ | -| | 3 selected [Archive] [Export] [⋯] [Clear] | | -| +--------------------------------------+ | -+---------------------------------------------------------+ ++--------------------------------------------------------------------+ +| Layout.Header title [Create] | ++--------------------------------------------------------------------+ +| Layout.Column | +| DataTable.Root | +| Toolbar [Filters] [Columns] | +| [x] | Col | Col | Col | Col | +| [x] | row | row | row | row | +| [ ] | row | row | row | row | +| [x] | row | row | row | row | +| DataTable.Footer, while rows are selected: | +| 3 of 240 selected | [Confirm (2)] [Export] [⋯] | Clear ‹ 1/10 › | ++--------------------------------------------------------------------+ ``` ## Page Implementation - - ## Constraints -- Count label + Clear button are always present in the bar -- Max 3 inline action buttons — 4th onward collapse behind an overflow `Menu` -- Destructive bulk actions MUST open an `interaction/confirm` dialog -- Filter or sort change must NOT silently clear the selection -- Pagination MUST preserve selection across pages +- Declare bulk actions with `selectionActions` on `useDataTable` — do NOT hand-build the bar, a floating panel, or checkbox state; the footer renders the count, actions, overflow menu, and Clear +- Keep `DataTable.Footer` in the tree — it is where the bar renders +- Give an action `appliesTo` when it fits only some rows: the bar shows the eligible count, disables the action at 0, and passes only those rows to `onClick` +- At most 3 actions render inline, in array order; the 4th onward move to the More actions menu — order by frequency and put a destructive action last +- Destructive bulk actions MUST open an `interaction/confirm` dialog from `onClick` +- Call the `clearSelection` helper from `onClick` once an action that changes the rows has succeeded +- Selection persists across pagination, filter, and sort changes — do NOT clear it on those ## Anti-patterns -- Placing bulk-action buttons in the page header — bulk actions belong only in the floating bar -- Hiding the bar behind row hover or right-click — bar must be visible when selection > 0 -- Omitting the count or the Clear affordance — both are mandatory -- Letting filter/sort changes silently drop selection +- Hand-rolled selection (`useState`, raw ``, `Table.Root`) when the list is a DataTable +- A floating or fixed-position action bar over the table instead of the footer bar +- Placing bulk-action buttons in the page header or the table toolbar +- Counting eligible rows per action by hand instead of `appliesTo` - Firing destructive bulk actions without an interaction/confirm step - Per-row `Menu` actions as a substitute for bulk actions when selection > 0 diff --git a/docs-src/patterns/list-dense-scan.docs.outline.md b/docs-src/patterns/list-dense-scan.docs.outline.md index 0c4c3843f..0e5115510 100644 --- a/docs-src/patterns/list-dense-scan.docs.outline.md +++ b/docs-src/patterns/list-dense-scan.docs.outline.md @@ -64,7 +64,7 @@ Omit `fill` on pages that should flow and scroll naturally (forms, dashboards, a - **Toolbar chips only (`DataTable.Filters`)** — best when filters map cleanly to typed column metadata / enum facets - **Tabs only above `DataTable`** — best when workflows are organized as obvious buckets - **Tabs + chips** — when buckets are primary and finer filters help -- **Bulk selection** — `onSelectionChange` hook on `useDataTable`; combine with `interaction/multi-select` +- **Bulk actions** — `selectionActions` on `useDataTable` turns `DataTable.Footer` into a bulk-action bar while rows are selected; see `interaction/multi-select` - **`Table` primitives** — small static lists without collection hooks ## Constraints @@ -74,7 +74,7 @@ Omit `fill` on pages that should flow and scroll naturally (forms, dashboards, a - Table-first pages use `` so title/toolbar/header/footer stay pinned and only rows scroll - 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 +- Bulk actions go in the footer bar from `selectionActions`, which appears only while ≥1 row is selected — never a separate toolbar or floating bar - Whole row is clickable via `onClickRow`; wrap the primary identifier cell in `` for keyboard/SR access. No per-row "View" / "Open" buttons - Per-row `Menu` (overflow `…`) is reserved for non-navigation actions (Archive, Duplicate, Delete) - Wide lists (many columns / horizontal scroll): pin the column users scan by to the **left** — the record's identifier (invoice / order **reference**) or its **name** (customer, product) — so it stays anchored while the rest scrolls; optionally pin a single high-signal **status** or **total** to the **right**. Keep the pinned set small (≈1 left, at most 1 right) — over-pinning eats the scroll area. Let users override via `` (show/hide + reorder + re-pin) with a stable `tableId` so each user's layout persists diff --git a/docs/components/data-table.md b/docs/components/data-table.md index 6d5b14f72..dc3872507 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -23,6 +23,7 @@ import { type DataTableRootProps, type DataTablePaginationProps, type RowAction, + type SelectionAction, type UseDataTableOptions, type UseDataTableReturn, type MetadataFieldOptions, @@ -144,15 +145,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` | DataTable-specific toolbar container. Optional; pass `columnSettings` for the built-in Columns control. | -| `DataTable.ColumnSettings` | The placeable Columns control (show/hide + reorder + pin). Use it in a generic [`Toolbar`](./toolbar.md) for custom layouts. | -| `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. | -| `DataTable.Footer` | Footer container for pagination and other footer content. Optional. | -| `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. | +| Sub-component | Description | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DataTable.Root` | Context provider. Wraps all other sub-components. Required. | +| `DataTable.Table` | Renders the `
` with headers and body. Required. | +| `DataTable.Toolbar` | DataTable-specific toolbar container. Optional; pass `columnSettings` for the built-in Columns control. | +| `DataTable.ColumnSettings` | The placeable Columns control (show/hide + reorder + pin). Use it in a generic [`Toolbar`](./toolbar.md) for custom layouts. | +| `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. | +| `DataTable.Footer` | Footer container for pagination and other footer content. Optional. Becomes the bulk-action bar while rows are selected — see [Selection actions](#selection-actions). | +| `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. | ### `DataTable.Root` Props @@ -220,7 +221,7 @@ By default `DataTable.Filters` renders the active filter chips plus the **Add fi | Rows selected and `total` is not provided | `Y row(s) selected` | | No selection enabled and no `total` | _(nothing displayed)_ | -Row selection is enabled by providing `onSelectionChange` to `useDataTable`. The `total` value comes from `DataTableData.total`. +Row selection is enabled by providing `onSelectionChange` or `selectionActions` to `useDataTable`. The `total` value comes from `DataTableData.total`. While the footer shows the bulk-action bar (see [Selection actions](#selection-actions)), the bar owns the selection count and this text is omitted. When pagination changes page or page size, `DataTable.Table` resets its own scroll container to the top automatically. That applies whether navigation comes from the built-in `DataTable.Pagination` or from custom controls using the same table context. @@ -368,6 +369,54 @@ The trigger is a native `` elements, so a screen reader counts them: ten records with two expanded announces as twelve rows. Fixing this needs `aria-rowcount` plus explicit `aria-rowindex` on every row (with detail rows sharing their parent's index) and correct interaction with pagination; `role="presentation"` on the detail row would fix the count but remove the panel from screen-reader table navigation. Neither is implemented. +## Selection actions + +Pass `selectionActions` to `useDataTable` and, while at least one row is selected, `DataTable.Footer` becomes a bulk-action bar: the selection count, your actions, and a **Clear** button, with the footer's own children (usually `DataTable.Pagination`) kept alongside. Providing the option also enables row selection, so `onSelectionChange` is optional. There is nothing new to compose in JSX — just keep `DataTable.Footer` in the tree. + +```tsx +const table = useDataTable({ + columns, + data, + control, + selectionActions: [ + { + id: "activate", + label: "Activate", + icon: , + appliesTo: (vendor) => vendor.status === "inactive", + onClick: async (vendors, { clearSelection }) => { + await activateVendors(vendors.map((vendor) => vendor.id)); + clearSelection(); + }, + }, + { + id: "export", + label: "Export", + icon: , + onClick: (vendors) => exportCsv(vendors), + }, + ], +}); + + + + + + +; +``` + +- **`appliesTo` scopes an action to part of the selection.** The button shows how many selected rows qualify — `Activate (6)` — is disabled when none do, and `onClick` receives only those rows. Omit it for actions that apply to every selected row; no count is shown then. +- **Selection spans pages.** The table remembers each selected row as it was last loaded, so counts and `onClick` cover rows selected on other pages too. Rows on the current page are always their latest version, so refetching after an action updates the counts. The header checkbox adds or removes only the current page; **Clear** empties everything. +- **Clear when the work is done.** The bar doesn't clear the selection after an action — call the `clearSelection` helper when it should. An export usually keeps the selection; an archive usually clears it. +- **Three actions stay inline.** The fourth onward collapse into a **More actions** menu, in array order. Put the most frequent first; a destructive action placed last sits safely in the menu. +- **Confirm destructive actions.** `variant: "destructive"` only styles the action. Open a confirm dialog from `onClick` before deleting — see the [confirm pattern](../patterns/interaction-confirm.md). +- **The bar stays reachable.** In `` the footer is already pinned. On a page that scrolls, the bar sticks to the bottom of the viewport while rows are selected and settles back into place at the end of the table. + +### Accessibility + +The bar is a `role="toolbar"` named "Bulk actions", so Arrow, Home, and End move between its controls (see [`Toolbar`](./toolbar.md)) while Tab still reaches each one. The selection count is announced through a polite live region from the first selection on. When the bar closes while focus is inside it — after **Clear**, or an action that clears — focus returns to the header checkbox instead of dropping to ``. + ## `useDataTable` Creates the table state object to pass to `DataTable.Root`. @@ -393,7 +442,8 @@ const table = useDataTable({ | `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`. | +| `onSelectionChange` | `(ids: string[]) => void` | Called with selected row IDs on change. Providing this (or `selectionActions`) enables the checkbox column. Rows must have a string or number `id`. | +| `selectionActions` | `SelectionAction[]` | Bulk actions shown in `DataTable.Footer` while rows are selected. A non-empty array also enables selection. See [Selection actions](#selection-actions). | | `rowExpansion` | `RowExpansionOptions` | Expandable detail rows: `render`, plus optional `canExpand` / `getLabel`, and `expandedIds` + `onChange` together for controlled mode. See [Expandable rows](#expandable-rows). | | `sort` | `false \| { multiple?: boolean }` | Sort behaviour. `false` disables sorting entirely. `{ multiple: true }` enables multi-column sorting. Omit or pass `{}` for single-column sort (default). | @@ -752,6 +802,19 @@ When `caseSensitive` is omitted or `false`, the filter is case-insensitive. When | `isDisabled` | `(row: TRow) => boolean` | Return `true` to disable the action for a given row. | | `onClick` | `(row: TRow) => void` | Called when the action is clicked. | +## `SelectionAction` + +A bulk action for the rows currently selected. See [Selection actions](#selection-actions). + +| Property | Type | Description | +| ----------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `id` | `string` | Stable identifier for the action. | +| `label` | `string` | Button label, also used for its menu item once it overflows into **More actions**. | +| `icon` | `ReactNode` | Optional icon shown before the label. | +| `variant` | `"default" \| "destructive"` | Visual style. A destructive action still needs its own confirmation step. | +| `appliesTo` | `(row: TRow) => boolean` | Scopes the action to the selected rows it applies to: shows their count, disables at zero, and passes only those rows to `onClick`. | +| `onClick` | `(rows: TRow[], helpers: { clearSelection: () => void }) => void` | Called with the selected rows the action applies to, including rows selected on other pages. | + ## `createColumnHelper` Factory that captures the row type once and returns `column` and `inferColumns` with `TRow` already bound. Prefer this over the standalone `column()` function to avoid repeating the generic parameter. @@ -912,6 +975,8 @@ function MyCustomPagination() { } ``` +The selection is here too — `selectedIds`, `selectedRows` (each selected row as last loaded, across pages), `selectAllRows` / `deselectAllRows` for the current page, and `clearSelection` — for the rare case the built-in [selection actions](#selection-actions) bar doesn't fit. + ## SDK Plugin (`@tailor-platform/sdk-plugin-app-shell`) The SDK plugin generates `tableMetadata` from TailorDB table definitions at generate time. This metadata bridges your schema to the DataTable — it specifies how each field should be rendered and filtered (e.g. date pickers for datetime fields, dropdown for enum fields). diff --git a/docs/patterns/interaction-multi-select.md b/docs/patterns/interaction-multi-select.md index 8227e42e2..b604a8693 100644 --- a/docs/patterns/interaction-multi-select.md +++ b/docs/patterns/interaction-multi-select.md @@ -1,6 +1,6 @@ --- title: Multi Select -description: Floating bottom action bar for bulk operations on selected list rows +description: Bulk operations on selected DataTable rows, from a bar that takes over the table footer --- @@ -10,28 +10,27 @@ description: Floating bottom action bar for bulk operations on selected list row ## When to Use - ANY list page where rows can be acted on in bulk (archive, assign, export, approve, delete) -- Selection is initiated by clicking a leading-column checkbox on rows -- Selection state needs to persist across pagination and filter changes +- The list is a `DataTable` — selection and the bulk-action bar are built in; you only declare the actions +- Some actions apply to only part of a selection (approve only pending rows, delete only archived ones) ## Layout -Floating action bar appears the moment selection count goes from 0 → 1, anchored to the bottom of the viewport, centered horizontally, with elevation. It disappears when selection returns to 0. +Pass `selectionActions` to `useDataTable`. The moment one row is selected, `DataTable.Footer` becomes the bulk-action bar: a tinted strip with the selection count, the actions, and Clear, with pagination kept on the right. It turns back into the normal footer when the selection empties. In `` the footer is already pinned; on a page that scrolls, the bar sticks to the bottom of the viewport until the end of the table is reached. ``` -+---------------------------------------------------------+ -| Layout.Header title [Filter] [Create] | -+---------------------------------------------------------+ -| Layout.Column | -| Table.Root | -| [x] | Col | Col | Col | Col | -| [x] | row | row | row | row | -| [ ] | row | row | row | row | -| [x] | row | row | row | row | -| | -| +--------------------------------------+ | -| | 3 selected [Archive] [Export] [⋯] [Clear] | | -| +--------------------------------------+ | -+---------------------------------------------------------+ ++--------------------------------------------------------------------+ +| Layout.Header title [Create] | ++--------------------------------------------------------------------+ +| Layout.Column | +| DataTable.Root | +| Toolbar [Filters] [Columns] | +| [x] | Col | Col | Col | Col | +| [x] | row | row | row | row | +| [ ] | row | row | row | row | +| [x] | row | row | row | row | +| DataTable.Footer, while rows are selected: | +| 3 of 240 selected | [Confirm (2)] [Export] [⋯] | Clear ‹ 1/10 › | ++--------------------------------------------------------------------+ ``` ## Page Implementation @@ -40,120 +39,101 @@ Floating action bar appears the moment selection count goes from 0 → 1, anchor ```tsx function InteractionMultiSelect() { - const orders = mockOrders; - const onArchive = (ids: string[]) => window.alert(`Archiving ${ids.length}`); - const onExport = (ids: string[]) => window.alert(`Exporting ${ids.length}`); - - const [selectedIds, setSelectedIds] = useState>(new Set()); - - const toggleRow = (id: string) => { - setSelectedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - - const toggleAll = () => { - if (selectedIds.size === orders.length) { - setSelectedIds(new Set()); - } else { - setSelectedIds(new Set(orders.map((o) => o.id))); - } - }; - - const clearSelection = () => setSelectedIds(new Set()); - const selectedCount = selectedIds.size; + const [pendingCancel, setPendingCancel] = useState(null); + + const selectionActions: SelectionAction[] = [ + { + id: "confirm", + label: "Confirm", + // Only open orders can be confirmed: the bar shows "Confirm (n)". + appliesTo: (order) => order.status === "Open", + onClick: (orders, { clearSelection }) => { + window.alert(`Confirming ${orders.length} order(s)`); + clearSelection(); + }, + }, + { + id: "export", + label: "Export", + onClick: (orders) => window.alert(`Exporting ${orders.length} order(s)`), + }, + { + id: "assign", + label: "Assign owner", + onClick: (orders) => window.alert(`Assigning ${orders.length} order(s)`), + }, + { + // 4th action: lands in the More actions menu. + id: "cancel", + label: "Cancel", + variant: "destructive", + appliesTo: (order) => order.status !== "Shipped", + // Destructive: confirm first (interaction/confirm). + onClick: (orders, { clearSelection }) => setPendingCancel({ orders, clearSelection }), + }, + ]; + + const table = useDataTable({ + columns, + data: { rows: mockOrders, total: mockOrders.length }, + selectionActions, + }); return ( <> - - - - - 0} - onChange={toggleAll} - aria-label="Select all on page" - /> - - Order # - Status - Total - - - - {orders.map((order) => ( - - - toggleRow(order.id)} - aria-label={`Select ${order.number}`} - /> - - {order.number} - {order.status} - ${order.total.toLocaleString()} - - ))} - - - - {selectedCount > 0 && ( -
- {selectedCount} selected - - - - - - - - Assign owner - Tag - - Delete - - - -
- )} + + + + + + + + { + if (!open) setPendingCancel(null); + }} + > + + + Cancel {pendingCancel?.orders.length} order(s)? + Cancelled orders can't be reopened. + + + }>Keep orders + + + + ); } ``` - - ## Constraints -- Count label + Clear button are always present in the bar -- Max 3 inline action buttons — 4th onward collapse behind an overflow `Menu` -- Destructive bulk actions MUST open an `interaction/confirm` dialog -- Filter or sort change must NOT silently clear the selection -- Pagination MUST preserve selection across pages +- Declare bulk actions with `selectionActions` on `useDataTable` — do NOT hand-build the bar, a floating panel, or checkbox state; the footer renders the count, actions, overflow menu, and Clear +- Keep `DataTable.Footer` in the tree — it is where the bar renders +- Give an action `appliesTo` when it fits only some rows: the bar shows the eligible count, disables the action at 0, and passes only those rows to `onClick` +- At most 3 actions render inline, in array order; the 4th onward move to the More actions menu — order by frequency and put a destructive action last +- Destructive bulk actions MUST open an `interaction/confirm` dialog from `onClick` +- Call the `clearSelection` helper from `onClick` once an action that changes the rows has succeeded +- Selection persists across pagination, filter, and sort changes — do NOT clear it on those ## Anti-patterns -- Placing bulk-action buttons in the page header — bulk actions belong only in the floating bar -- Hiding the bar behind row hover or right-click — bar must be visible when selection > 0 -- Omitting the count or the Clear affordance — both are mandatory -- Letting filter/sort changes silently drop selection +- Hand-rolled selection (`useState`, raw ``, `Table.Root`) when the list is a DataTable +- A floating or fixed-position action bar over the table instead of the footer bar +- Placing bulk-action buttons in the page header or the table toolbar +- Counting eligible rows per action by hand instead of `appliesTo` - Firing destructive bulk actions without an interaction/confirm step - Per-row `Menu` actions as a substitute for bulk actions when selection > 0 diff --git a/docs/patterns/list-dense-scan.md b/docs/patterns/list-dense-scan.md index da8f13aea..32ed8a134 100644 --- a/docs/patterns/list-dense-scan.md +++ b/docs/patterns/list-dense-scan.md @@ -67,7 +67,7 @@ Omit `fill` on pages that should flow and scroll naturally (forms, dashboards, a - **Toolbar chips only (`DataTable.Filters`)** — best when filters map cleanly to typed column metadata / enum facets - **Tabs only above `DataTable`** — best when workflows are organized as obvious buckets - **Tabs + chips** — when buckets are primary and finer filters help -- **Bulk selection** — `onSelectionChange` hook on `useDataTable`; combine with `interaction/multi-select` +- **Bulk actions** — `selectionActions` on `useDataTable` turns `DataTable.Footer` into a bulk-action bar while rows are selected; see `interaction/multi-select` - **`Table` primitives** — small static lists without collection hooks ## Constraints @@ -77,7 +77,7 @@ Omit `fill` on pages that should flow and scroll naturally (forms, dashboards, a - Table-first pages use `` so title/toolbar/header/footer stay pinned and only rows scroll - 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 +- Bulk actions go in the footer bar from `selectionActions`, which appears only while ≥1 row is selected — never a separate toolbar or floating bar - Whole row is clickable via `onClickRow`; wrap the primary identifier cell in `` for keyboard/SR access. No per-row "View" / "Open" buttons - Per-row `Menu` (overflow `…`) is reserved for non-navigation actions (Archive, Duplicate, Delete) - Wide lists (many columns / horizontal scroll): pin the column users scan by to the **left** — the record's identifier (invoice / order **reference**) or its **name** (customer, product) — so it stays anchored while the rest scrolls; optionally pin a single high-signal **status** or **total** to the **right**. Keep the pinned set small (≈1 left, at most 1 right) — over-pinning eats the scroll area. Let users override via `` (show/hide + reorder + re-pin) with a stable `tableId` so each user's layout persists From 6b749f473e9230e2e956c57a1d58b30a4164b7ad Mon Sep 17 00:00:00 2001 From: itsprade Date: Thu, 1 Oct 2026 16:25:33 +0530 Subject: [PATCH 8/9] feat(data-table): canApply, a shared action base, and promise-aware selection actions Addresses Sean's review of #496. Naming and polarity (review point 2), settled before it ships: - appliesTo is renamed canApply and moves into DataTableAction, a new base type with id / label / icon / variant / canApply that both RowAction and SelectionAction extend. One definition can be spread into rowActions and selectionActions; only onClick differs. - RowAction gains canApply, so a row action is disabled where it returns false. isDisabled, which reads the other way round, is deprecated but still honoured: either one can switch the action off. Post-action state (review point 4): - An onClick that returns a promise is waited on. While it is pending, every action and the More actions trigger are disabled, the running action shows a Spinner, and the bar is aria-busy, so a slow request can't be fired twice or overlapped. - When it resolves, the selection clears. The rows may have changed, and copies remembered from other pages can't refresh, so keeping them handed stale rows to the next action and left deleted rows selected. Actions that don't change rows opt out with keepSelection. A rejection keeps the selection for a retry and logs a [DataTable] error rather than rethrowing, which would surface as an unhandled rejection even when the app had already handled it. - A synchronous onClick, such as one that opens a confirm dialog, still owns the selection. - clearSelection is a no-op on an empty selection, so an action that clears itself and then resolves doesn't fire onSelectionChange([]) twice. Docs (review point 3): the Selection actions section says where the pop-out works (nearest scrolling container; it stays put inside a clipping wrapper) and covers the new behaviour. There are DataTableAction / SelectionAction / RowAction tables, and the multi-select pattern and its example use canApply and returned promises. The decision record and changeset are updated, and the demo now uses slow fake requests so the pending state is visible. Co-Authored-By: Claude Opus 5.5 --- .changeset/quiet-footers-rise.md | 13 +- .../data-table-selection-footer-actions.md | 11 +- docs-manifest.json | 17 +- .../components/data-table.docs.outline.md | 77 ++++++--- ...interaction-multi-select.docs.examples.tsx | 28 ++-- .../interaction-multi-select.docs.outline.md | 10 +- docs/components/data-table.md | 77 ++++++--- docs/patterns/interaction-multi-select.md | 30 ++-- .../src/pages/dashboard/products/page.tsx | 34 ++-- .../showcase/data-table-selection/page.tsx | 38 +++-- .../components/data-table/data-table.test.tsx | 86 ++++++++++ .../src/components/data-table/data-table.tsx | 6 +- .../core/src/components/data-table/index.ts | 1 + .../data-table/selection-bar.test.tsx | 153 +++++++++++++++++- .../components/data-table/selection-bar.tsx | 92 ++++++++--- .../core/src/components/data-table/types.ts | 73 +++++++-- .../components/data-table/use-data-table.ts | 10 +- packages/core/src/index.ts | 1 + 18 files changed, 578 insertions(+), 179 deletions(-) diff --git a/.changeset/quiet-footers-rise.md b/.changeset/quiet-footers-rise.md index 9f843665a..95478d175 100644 --- a/.changeset/quiet-footers-rise.md +++ b/.changeset/quiet-footers-rise.md @@ -2,7 +2,7 @@ "@tailor-platform/app-shell": minor --- -Add bulk actions to `DataTable`. Pass `selectionActions` to `useDataTable` and, while rows are selected, `DataTable.Footer` becomes a bulk-action bar: the selection count, your actions, and a Clear button, with pagination kept alongside. Give an action `appliesTo` to scope it to the selected rows it can act on — the bar shows that count, disables the action at zero, and hands only those rows to `onClick`. +Add bulk actions to `DataTable`. Pass `selectionActions` to `useDataTable` and, while rows are selected, `DataTable.Footer` becomes a bulk-action bar: the selection count, your actions, and a Clear button, with pagination kept alongside. Give an action `canApply` to scope it to the selected rows it can act on — the bar shows that count, disables the action at zero, and hands only those rows to `onClick`. Return the promise from `onClick` and the bar waits for it: actions are disabled with a spinner meanwhile, and the selection clears once it resolves (set `keepSelection` for actions such as an export). ```tsx const table = useDataTable({ @@ -13,18 +13,17 @@ const table = useDataTable({ { id: "activate", label: "Activate", - appliesTo: (vendor) => vendor.status === "inactive", - onClick: (vendors, { clearSelection }) => { - activate(vendors); - clearSelection(); - }, + canApply: (vendor) => vendor.status === "inactive", + onClick: (vendors) => activate(vendors), }, ], }); ``` +`RowAction` and `SelectionAction` now share a base type, `DataTableAction` (`id`, `label`, `icon`, `variant`, `canApply`), so one definition can be spread into both `rowActions` and `selectionActions`. `RowAction` gains `canApply`; its `isDisabled`, which reads the other way round, is deprecated but still honoured. + Selection now also remembers the rows it holds across pages (`selectedRows`), and a non-empty `selectionActions` enables selection on its own. The first three actions render as buttons and the rest collapse into a "More actions" menu. On a page-scrolling table the bar sticks to the bottom of the viewport until the table's end scrolls into view. -**Behavior change:** the header checkbox is now page-scoped in both directions. Checking it adds the current page's rows to the selection instead of replacing it, and unchecking it removes only the current page's rows instead of clearing every page. `clearSelection` still empties everything, and the new `deselectAllRows` is the page-scoped counterpart of `selectAllRows`. +**Behavior change:** the header checkbox is now page-scoped in both directions. Checking it adds the current page's rows to the selection instead of replacing it, and unchecking it removes only the current page's rows instead of clearing every page. `clearSelection` still empties everything (and is now a no-op when nothing is selected), and the new `deselectAllRows` is the page-scoped counterpart of `selectAllRows`. The `interaction/multi-select` pattern in the bundled `app-shell-patterns` skill is rewritten around `selectionActions`, replacing the floating bar on a hand-built table. diff --git a/decisions/data-table-selection-footer-actions.md b/decisions/data-table-selection-footer-actions.md index e24c11a43..2609379bd 100644 --- a/decisions/data-table-selection-footer-actions.md +++ b/decisions/data-table-selection-footer-actions.md @@ -32,8 +32,8 @@ const table = useDataTable({ id: "activate", label: "Activate", icon: , - appliesTo: (v) => v.status === "inactive", // "Activate (6)", disabled at 0 - onClick: (rows, { clearSelection }) => { … }, // only the eligible rows + canApply: (v) => v.status === "inactive", // "Activate (6)", disabled at 0 + onClick: (rows) => activate(rows), // only the eligible rows; return the promise }, ], }); @@ -41,8 +41,9 @@ const table = useDataTable({ **What changed from the sketch the team first saw:** -- **`appliesTo(row)` replaces a consumer-supplied `count`.** Selection spans pages, and an app with server pagination can't count rows selected on other pages without keeping its own id→row cache. The table already sees every row the user selects, so it remembers them (`selectedRows`, each row as last loaded, with the current page's copy winning) and does the counting. +- **`canApply(row)` replaces a consumer-supplied `count`.** Selection spans pages, and an app with server pagination can't count rows selected on other pages without keeping its own id→row cache. The table already sees every row the user selects, so it remembers them (`selectedRows`, each row as last loaded, with the current page's copy winning) and does the counting. It was first written `appliesTo`. Sean's review renamed it before it shipped, and moved it into `DataTableAction`, a base type both `RowAction` and `SelectionAction` extend, so one definition serves the row menu and the bar. `RowAction.isDisabled`, which reads the opposite way, is deprecated in its favour but still honoured. - **`onClick(rows, { clearSelection })` replaces `onClick(ids)`.** Rows carry what an action needs. The helper clears the selection without the action reaching back to `table` from inside its own options object. +- **A returned promise is waited on.** While it is pending, every action is disabled, with a spinner on the running one, so a slow request can't be fired twice. When it resolves, the selection clears, unless the action sets `keepSelection`, as an export would. The rows it acted on may have changed, and copies remembered from other pages can't refresh themselves, so keeping them would hand stale rows to the next action. A rejection keeps the selection for a retry and logs a `[DataTable]` error. A synchronous `onClick`, typically one that opens a confirm dialog, leaves the selection alone. - **The tone is `accent`, not primary or an inverted neutral.** It stays soft in all three themes and both modes, and matches the tint of selected rows. Primary is near-white in the default theme's dark mode, which brings back the glare Sean flagged. **How the bar behaves:** @@ -56,11 +57,13 @@ const table = useDataTable({ ## Consequences - **`interaction/multi-select` is rewritten** around `selectionActions`. The floating bar is retired: the sticky footer covers the "keep it on screen" need, and lists that want bulk actions should be DataTables. -- **#525 interaction:** if it lands controlled/default `rowSelection`, ids selected outside the UI have no remembered row until their page loads. `appliesTo` counts cover loaded rows only, and this is documented. Whichever of #525 and this lands second adapts the other: the row memory is written wherever selection is written. +- **#525 interaction:** if it lands controlled/default `rowSelection`, ids selected outside the UI have no remembered row until their page loads. `canApply` counts cover loaded rows only, and this is documented. Whichever of #525 and this lands second adapts the other: the row memory is written wherever selection is written. +- **Where the pop-out works:** it sticks to the nearest scrolling ancestor. Inside a scrolling drawer or panel it rides that container. Inside a wrapper with `overflow: hidden`, it stays in the footer. The docs say so. `overflow: clip` needs Safari 16+, and Sean asked for a check of `clip` together with `border-radius` there. If that misrenders, the fallback is `clip-path: inset(0 round …)`, which also clips without creating a scroll container. - **Toasts vs. the bar:** bulk actions naturally end in a toast, and the default bottom-right toast sits over the footer's pagination for a few seconds. That is tolerable, but worth revisiting when toast placement is next touched. ## Not in this decision (follow-ups) +- Width-aware overflow, so actions move into "⋯" as space runs out rather than at a fixed three. This came from Sean's review. It belongs in the generic `Toolbar`, so every toolbar gets it, and each item then needs a way to describe its menu form. To do with Seiya. - "Select all N" across pages (needs server-side semantics). - A placeable `DataTable.SelectionActions` for custom placement, e.g. an in-toolbar variant. This follows the "option = default placement, sub-component = custom placement" rule from tailor-inc/platform-planning#1699; add it when a consumer needs it. - A pending/loading state on an action, tooltips explaining a disabled action, and Escape to clear. diff --git a/docs-manifest.json b/docs-manifest.json index 753da77dd..3ef87cd74 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -403,6 +403,7 @@ "CollectionVariables", "Column", "DataTable", + "DataTableAction", "DataTableContextValue", "DataTableData", "DataTableFilterConfig", @@ -444,10 +445,10 @@ "withURLCollectionState" ], "hashes": { - "typeSurface": "0d4ba72f71285f5f", - "outline": "550236054e542de5", + "typeSurface": "fc6551ad164ad460", + "outline": "7da9d1c06ab1a20e", "snapshot": null, - "outputMd": "da6979ebef99d190", + "outputMd": "fc7efe94754d5f71", "examples": null } }, @@ -911,10 +912,10 @@ "symbols": [], "hashes": { "typeSurface": null, - "outline": "af754afbf0ea9be9", + "outline": "2b07d9a974858c60", "snapshot": null, - "outputMd": "cefbf2068796a92c", - "examples": "a4b15f38a7b209fe" + "outputMd": "7e2fb7ebd1eb57fe", + "examples": "f19fb01970649857" } }, "interaction-toast": { @@ -1606,7 +1607,7 @@ "packages/core/skills/app-shell-patterns/references/components/combobox.md": "dfb775c7c4307409", "packages/core/skills/app-shell-patterns/references/components/command-palette.md": "7e5bc675a593791a", "packages/core/skills/app-shell-patterns/references/components/csv-importer.md": "53d3c795ca21b9dc", - "packages/core/skills/app-shell-patterns/references/components/data-table.md": "da6979ebef99d190", + "packages/core/skills/app-shell-patterns/references/components/data-table.md": "fc7efe94754d5f71", "packages/core/skills/app-shell-patterns/references/components/date-picker.md": "33902be121ff69db", "packages/core/skills/app-shell-patterns/references/components/default-header.md": "375e55f4b12fa2be", "packages/core/skills/app-shell-patterns/references/components/default-sidebar.md": "3422388a0cc78571", @@ -1664,7 +1665,7 @@ "packages/core/skills/app-shell-patterns/references/patterns/form-single-page.md": "10a0f4d91a120752", "packages/core/skills/app-shell-patterns/references/patterns/form-wizard.md": "851afb4672da6673", "packages/core/skills/app-shell-patterns/references/patterns/interaction-confirm.md": "2db70f423c6fdf97", - "packages/core/skills/app-shell-patterns/references/patterns/interaction-multi-select.md": "cefbf2068796a92c", + "packages/core/skills/app-shell-patterns/references/patterns/interaction-multi-select.md": "7e2fb7ebd1eb57fe", "packages/core/skills/app-shell-patterns/references/patterns/interaction-toast.md": "f68bdab855730f48", "packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "916baa3cd2b9cbf1", "packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "c3fb2588a0699c26", diff --git a/docs-src/components/data-table.docs.outline.md b/docs-src/components/data-table.docs.outline.md index daf881f5f..23af56fbf 100644 --- a/docs-src/components/data-table.docs.outline.md +++ b/docs-src/components/data-table.docs.outline.md @@ -28,6 +28,7 @@ import { type DataTableData, type DataTableRootProps, type DataTablePaginationProps, + type DataTableAction, type RowAction, type SelectionAction, type UseDataTableOptions, @@ -389,16 +390,15 @@ const table = useDataTable({ id: "activate", label: "Activate", icon: , - appliesTo: (vendor) => vendor.status === "inactive", - onClick: async (vendors, { clearSelection }) => { - await activateVendors(vendors.map((vendor) => vendor.id)); - clearSelection(); - }, + canApply: (vendor) => vendor.status === "inactive", + // Returning the promise: the bar waits, then clears the selection. + onClick: (vendors) => activateVendors(vendors.map((vendor) => vendor.id)), }, { id: "export", label: "Export", icon: , + keepSelection: true, // exporting doesn't change the rows onClick: (vendors) => exportCsv(vendors), }, ], @@ -412,12 +412,14 @@ const table = useDataTable({ ; ``` -- **`appliesTo` scopes an action to part of the selection.** The button shows how many selected rows qualify — `Activate (6)` — is disabled when none do, and `onClick` receives only those rows. Omit it for actions that apply to every selected row; no count is shown then. -- **Selection spans pages.** The table remembers each selected row as it was last loaded, so counts and `onClick` cover rows selected on other pages too. Rows on the current page are always their latest version, so refetching after an action updates the counts. The header checkbox adds or removes only the current page; **Clear** empties everything. -- **Clear when the work is done.** The bar doesn't clear the selection after an action — call the `clearSelection` helper when it should. An export usually keeps the selection; an archive usually clears it. +- **`canApply` scopes an action to part of the selection.** The button shows how many selected rows qualify — `Activate (6)` — is disabled when none do, and `onClick` receives only those rows. Omit it for actions that apply to every selected row; no count is shown then. +- **Return the promise from `onClick`.** While it is pending, the bar disables every action and shows a spinner on the running one, so a slow request can't be fired twice. When it resolves, the selection is cleared — the rows may have changed, and copies remembered from other pages can't refresh themselves. Set `keepSelection: true` for actions that don't change the rows, such as an export. If the promise rejects, the selection stays so the action can be retried, and the error is logged as `[DataTable] Selection action "…" failed`; showing it to the user is up to your `onClick`. +- **Synchronous handlers own the selection.** An `onClick` that returns nothing — typically one that opens a confirm dialog — leaves the selection alone; call the `clearSelection` helper once the work is done. +- **Selection spans pages.** The table remembers each selected row as it was last loaded, so counts and `onClick` cover rows selected on other pages too. Rows on the current page are always their latest version. The header checkbox adds or removes only the current page; **Clear** empties everything. - **Three actions stay inline.** The fourth onward collapse into a **More actions** menu, in array order. Put the most frequent first; a destructive action placed last sits safely in the menu. - **Confirm destructive actions.** `variant: "destructive"` only styles the action. Open a confirm dialog from `onClick` before deleting — see the [confirm pattern](../patterns/interaction-confirm.md). -- **The bar stays reachable.** In `` the footer is already pinned. On a page that scrolls, the bar sticks to the bottom of the viewport while rows are selected and settles back into place at the end of the table. +- **The bar stays reachable.** In `` the footer is already pinned. On a page that scrolls, the bar sticks to the bottom of the viewport while rows are selected and settles back into place at the end of the table. It sticks to the nearest scrolling container: inside a scrolling drawer or panel it rides that container instead, and inside a wrapper that clips its overflow (such as a card with `overflow: hidden`) it simply stays in the footer. +- **Share definitions with `rowActions`.** `SelectionAction` and `RowAction` both extend [`DataTableAction`](#datatableaction), so an action defined once — label, icon, `canApply` — can be spread into both arrays with a different `onClick`. ### Accessibility @@ -799,27 +801,50 @@ When `caseSensitive` is omitted or `false`, the filter is case-insensitive. When ## `RowAction` -| Property | Type | Description | -| ------------ | ---------------------------- | ---------------------------------------------------- | -| `id` | `string` | Stable identifier for the action. | -| `label` | `string` | Display label in the kebab menu. | -| `icon` | `ReactNode` | Optional icon shown beside the label. | -| `variant` | `"default" \| "destructive"` | Visual style of the menu item. | -| `isDisabled` | `(row: TRow) => boolean` | Return `true` to disable the action for a given row. | -| `onClick` | `(row: TRow) => void` | Called when the action is clicked. | +A row action in the kebab-menu column. Extends [`DataTableAction`](#datatableaction) (`id`, `label`, `icon`, `variant`, `canApply`). + +| Property | Type | Description | +| ------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| `canApply` | `(row: TRow) => boolean` | Return `false` to disable the action for a given row. | +| `isDisabled` | `(row: TRow) => boolean` | **Deprecated** — use `canApply`, which reads the other way round. Still honoured: the action is disabled if either one says so. | +| `onClick` | `(row: TRow) => void` | Called when the action is clicked. | ## `SelectionAction` -A bulk action for the rows currently selected. See [Selection actions](#selection-actions). +A bulk action for the rows currently selected. Extends [`DataTableAction`](#datatableaction). See [Selection actions](#selection-actions). + +| Property | Type | Description | +| --------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `canApply` | `(row: TRow) => boolean` | Scopes the action to the selected rows it can act on: shows their count, disables at zero, and passes only those rows to `onClick`. | +| `onClick` | `(rows: TRow[], helpers: { clearSelection: () => void }) => void \| Promise` | Called with the selected rows the action can act on, including rows selected on other pages. Return a promise to get the pending state and clear-on-success; see [Selection actions](#selection-actions). | +| `keepSelection` | `boolean` | Keep the selection after the promise resolves — for actions that don't change the rows, such as an export. | + +## `DataTableAction` -| Property | Type | Description | -| ----------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `id` | `string` | Stable identifier for the action. | -| `label` | `string` | Button label, also used for its menu item once it overflows into **More actions**. | -| `icon` | `ReactNode` | Optional icon shown before the label. | -| `variant` | `"default" \| "destructive"` | Visual style. A destructive action still needs its own confirmation step. | -| `appliesTo` | `(row: TRow) => boolean` | Scopes the action to the selected rows it applies to: shows their count, disables at zero, and passes only those rows to `onClick`. | -| `onClick` | `(rows: TRow[], helpers: { clearSelection: () => void }) => void` | Called with the selected rows the action applies to, including rows selected on other pages. | +What `RowAction` and `SelectionAction` share. Define an action once and spread it into both arrays — only `onClick` differs. + +| Property | Type | Description | +| ---------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | `string` | Stable identifier for the action. | +| `label` | `string` | Display label — the menu item for a row action, the button (or **More actions** item) for a selection action. | +| `icon` | `ReactNode` | Optional icon shown beside the label. | +| `variant` | `"default" \| "destructive"` | Visual style. A destructive action still needs its own confirmation step. | +| `canApply` | `(row: TRow) => boolean` | Which rows the action can act on. A row action is disabled where it returns `false`; a selection action counts the rows where it returns `true`. | + +```tsx +const archive: DataTableAction = { + id: "archive", + label: "Archive", + canApply: (order) => order.status !== "Archived", +}; + +useDataTable({ + columns, + data, + rowActions: [{ ...archive, onClick: (order) => archiveOrders([order]) }], + selectionActions: [{ ...archive, onClick: (orders) => archiveOrders(orders) }], +}); +``` ## `createColumnHelper` diff --git a/docs-src/patterns/interaction-multi-select.docs.examples.tsx b/docs-src/patterns/interaction-multi-select.docs.examples.tsx index 084efade5..7bebdd035 100644 --- a/docs-src/patterns/interaction-multi-select.docs.examples.tsx +++ b/docs-src/patterns/interaction-multi-select.docs.examples.tsx @@ -31,6 +31,14 @@ const columns: Column[] = [ type PendingCancel = { orders: Order[]; clearSelection: () => void }; +// Stand-ins for real mutations. +const confirmOrders = async (orders: Order[]) => { + window.alert(`Confirming ${orders.length} order(s)`); +}; +const exportOrders = async (orders: Order[]) => { + window.alert(`Exporting ${orders.length} order(s)`); +}; + export function InteractionMultiSelect() { const [pendingCancel, setPendingCancel] = useState(null); @@ -39,29 +47,31 @@ export function InteractionMultiSelect() { id: "confirm", label: "Confirm", // Only open orders can be confirmed: the bar shows "Confirm (n)". - appliesTo: (order) => order.status === "Open", - onClick: (orders, { clearSelection }) => { - window.alert(`Confirming ${orders.length} order(s)`); - clearSelection(); - }, + canApply: (order) => order.status === "Open", + // Return the promise: the bar disables its actions while it runs, then + // clears the selection. + onClick: (orders) => confirmOrders(orders), }, { id: "export", label: "Export", - onClick: (orders) => window.alert(`Exporting ${orders.length} order(s)`), + // Exporting doesn't change the rows, so keep them selected. + keepSelection: true, + onClick: (orders) => exportOrders(orders), }, { id: "assign", label: "Assign owner", - onClick: (orders) => window.alert(`Assigning ${orders.length} order(s)`), + onClick: async (orders) => window.alert(`Assigning ${orders.length} order(s)`), }, { // 4th action: lands in the More actions menu. id: "cancel", label: "Cancel", variant: "destructive", - appliesTo: (order) => order.status !== "Shipped", - // Destructive: confirm first (interaction/confirm). + canApply: (order) => order.status !== "Shipped", + // Destructive: confirm first (interaction/confirm). Opening the dialog is + // synchronous, so the selection is cleared on confirm instead. onClick: (orders, { clearSelection }) => setPendingCancel({ orders, clearSelection }), }, ]; diff --git a/docs-src/patterns/interaction-multi-select.docs.outline.md b/docs-src/patterns/interaction-multi-select.docs.outline.md index a09b18fe9..49a0659ae 100644 --- a/docs-src/patterns/interaction-multi-select.docs.outline.md +++ b/docs-src/patterns/interaction-multi-select.docs.outline.md @@ -55,10 +55,11 @@ Pass `selectionActions` to `useDataTable`. The moment one row is selected, `Data - Declare bulk actions with `selectionActions` on `useDataTable` — do NOT hand-build the bar, a floating panel, or checkbox state; the footer renders the count, actions, overflow menu, and Clear - Keep `DataTable.Footer` in the tree — it is where the bar renders -- Give an action `appliesTo` when it fits only some rows: the bar shows the eligible count, disables the action at 0, and passes only those rows to `onClick` +- Give an action `canApply` when it fits only some rows: the bar shows the eligible count, disables the action at 0, and passes only those rows to `onClick` +- Return the promise from `onClick` for async work: the bar disables its actions while it runs and clears the selection when it resolves; set `keepSelection: true` on actions that don't change rows (export) - At most 3 actions render inline, in array order; the 4th onward move to the More actions menu — order by frequency and put a destructive action last -- Destructive bulk actions MUST open an `interaction/confirm` dialog from `onClick` -- Call the `clearSelection` helper from `onClick` once an action that changes the rows has succeeded +- Destructive bulk actions MUST open an `interaction/confirm` dialog from `onClick`; that synchronous `onClick` leaves the selection alone, so call the `clearSelection` helper on confirm +- When an action also exists per row, define it once as a `DataTableAction` and spread it into both `rowActions` and `selectionActions` - Selection persists across pagination, filter, and sort changes — do NOT clear it on those ## Anti-patterns @@ -66,6 +67,7 @@ Pass `selectionActions` to `useDataTable`. The moment one row is selected, `Data - Hand-rolled selection (`useState`, raw ``, `Table.Root`) when the list is a DataTable - A floating or fixed-position action bar over the table instead of the footer bar - Placing bulk-action buttons in the page header or the table toolbar -- Counting eligible rows per action by hand instead of `appliesTo` +- Counting eligible rows per action by hand instead of `canApply` +- Fire-and-forget async `onClick` that doesn't return its promise — users can fire it twice, and the selection goes stale - Firing destructive bulk actions without an interaction/confirm step - Per-row `Menu` actions as a substitute for bulk actions when selection > 0 diff --git a/docs/components/data-table.md b/docs/components/data-table.md index dc3872507..c1da972a3 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -22,6 +22,7 @@ import { type DataTableData, type DataTableRootProps, type DataTablePaginationProps, + type DataTableAction, type RowAction, type SelectionAction, type UseDataTableOptions, @@ -383,16 +384,15 @@ const table = useDataTable({ id: "activate", label: "Activate", icon: , - appliesTo: (vendor) => vendor.status === "inactive", - onClick: async (vendors, { clearSelection }) => { - await activateVendors(vendors.map((vendor) => vendor.id)); - clearSelection(); - }, + canApply: (vendor) => vendor.status === "inactive", + // Returning the promise: the bar waits, then clears the selection. + onClick: (vendors) => activateVendors(vendors.map((vendor) => vendor.id)), }, { id: "export", label: "Export", icon: , + keepSelection: true, // exporting doesn't change the rows onClick: (vendors) => exportCsv(vendors), }, ], @@ -406,12 +406,14 @@ const table = useDataTable({ ; ``` -- **`appliesTo` scopes an action to part of the selection.** The button shows how many selected rows qualify — `Activate (6)` — is disabled when none do, and `onClick` receives only those rows. Omit it for actions that apply to every selected row; no count is shown then. -- **Selection spans pages.** The table remembers each selected row as it was last loaded, so counts and `onClick` cover rows selected on other pages too. Rows on the current page are always their latest version, so refetching after an action updates the counts. The header checkbox adds or removes only the current page; **Clear** empties everything. -- **Clear when the work is done.** The bar doesn't clear the selection after an action — call the `clearSelection` helper when it should. An export usually keeps the selection; an archive usually clears it. +- **`canApply` scopes an action to part of the selection.** The button shows how many selected rows qualify — `Activate (6)` — is disabled when none do, and `onClick` receives only those rows. Omit it for actions that apply to every selected row; no count is shown then. +- **Return the promise from `onClick`.** While it is pending, the bar disables every action and shows a spinner on the running one, so a slow request can't be fired twice. When it resolves, the selection is cleared — the rows may have changed, and copies remembered from other pages can't refresh themselves. Set `keepSelection: true` for actions that don't change the rows, such as an export. If the promise rejects, the selection stays so the action can be retried, and the error is logged as `[DataTable] Selection action "…" failed`; showing it to the user is up to your `onClick`. +- **Synchronous handlers own the selection.** An `onClick` that returns nothing — typically one that opens a confirm dialog — leaves the selection alone; call the `clearSelection` helper once the work is done. +- **Selection spans pages.** The table remembers each selected row as it was last loaded, so counts and `onClick` cover rows selected on other pages too. Rows on the current page are always their latest version. The header checkbox adds or removes only the current page; **Clear** empties everything. - **Three actions stay inline.** The fourth onward collapse into a **More actions** menu, in array order. Put the most frequent first; a destructive action placed last sits safely in the menu. - **Confirm destructive actions.** `variant: "destructive"` only styles the action. Open a confirm dialog from `onClick` before deleting — see the [confirm pattern](../patterns/interaction-confirm.md). -- **The bar stays reachable.** In `` the footer is already pinned. On a page that scrolls, the bar sticks to the bottom of the viewport while rows are selected and settles back into place at the end of the table. +- **The bar stays reachable.** In `` the footer is already pinned. On a page that scrolls, the bar sticks to the bottom of the viewport while rows are selected and settles back into place at the end of the table. It sticks to the nearest scrolling container: inside a scrolling drawer or panel it rides that container instead, and inside a wrapper that clips its overflow (such as a card with `overflow: hidden`) it simply stays in the footer. +- **Share definitions with `rowActions`.** `SelectionAction` and `RowAction` both extend [`DataTableAction`](#datatableaction), so an action defined once — label, icon, `canApply` — can be spread into both arrays with a different `onClick`. ### Accessibility @@ -793,27 +795,50 @@ When `caseSensitive` is omitted or `false`, the filter is case-insensitive. When ## `RowAction` -| Property | Type | Description | -| ------------ | ---------------------------- | ---------------------------------------------------- | -| `id` | `string` | Stable identifier for the action. | -| `label` | `string` | Display label in the kebab menu. | -| `icon` | `ReactNode` | Optional icon shown beside the label. | -| `variant` | `"default" \| "destructive"` | Visual style of the menu item. | -| `isDisabled` | `(row: TRow) => boolean` | Return `true` to disable the action for a given row. | -| `onClick` | `(row: TRow) => void` | Called when the action is clicked. | +A row action in the kebab-menu column. Extends [`DataTableAction`](#datatableaction) (`id`, `label`, `icon`, `variant`, `canApply`). + +| Property | Type | Description | +| ------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| `canApply` | `(row: TRow) => boolean` | Return `false` to disable the action for a given row. | +| `isDisabled` | `(row: TRow) => boolean` | **Deprecated** — use `canApply`, which reads the other way round. Still honoured: the action is disabled if either one says so. | +| `onClick` | `(row: TRow) => void` | Called when the action is clicked. | ## `SelectionAction` -A bulk action for the rows currently selected. See [Selection actions](#selection-actions). +A bulk action for the rows currently selected. Extends [`DataTableAction`](#datatableaction). See [Selection actions](#selection-actions). + +| Property | Type | Description | +| --------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `canApply` | `(row: TRow) => boolean` | Scopes the action to the selected rows it can act on: shows their count, disables at zero, and passes only those rows to `onClick`. | +| `onClick` | `(rows: TRow[], helpers: { clearSelection: () => void }) => void \| Promise` | Called with the selected rows the action can act on, including rows selected on other pages. Return a promise to get the pending state and clear-on-success; see [Selection actions](#selection-actions). | +| `keepSelection` | `boolean` | Keep the selection after the promise resolves — for actions that don't change the rows, such as an export. | + +## `DataTableAction` -| Property | Type | Description | -| ----------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `id` | `string` | Stable identifier for the action. | -| `label` | `string` | Button label, also used for its menu item once it overflows into **More actions**. | -| `icon` | `ReactNode` | Optional icon shown before the label. | -| `variant` | `"default" \| "destructive"` | Visual style. A destructive action still needs its own confirmation step. | -| `appliesTo` | `(row: TRow) => boolean` | Scopes the action to the selected rows it applies to: shows their count, disables at zero, and passes only those rows to `onClick`. | -| `onClick` | `(rows: TRow[], helpers: { clearSelection: () => void }) => void` | Called with the selected rows the action applies to, including rows selected on other pages. | +What `RowAction` and `SelectionAction` share. Define an action once and spread it into both arrays — only `onClick` differs. + +| Property | Type | Description | +| ---------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | `string` | Stable identifier for the action. | +| `label` | `string` | Display label — the menu item for a row action, the button (or **More actions** item) for a selection action. | +| `icon` | `ReactNode` | Optional icon shown beside the label. | +| `variant` | `"default" \| "destructive"` | Visual style. A destructive action still needs its own confirmation step. | +| `canApply` | `(row: TRow) => boolean` | Which rows the action can act on. A row action is disabled where it returns `false`; a selection action counts the rows where it returns `true`. | + +```tsx +const archive: DataTableAction = { + id: "archive", + label: "Archive", + canApply: (order) => order.status !== "Archived", +}; + +useDataTable({ + columns, + data, + rowActions: [{ ...archive, onClick: (order) => archiveOrders([order]) }], + selectionActions: [{ ...archive, onClick: (orders) => archiveOrders(orders) }], +}); +``` ## `createColumnHelper` diff --git a/docs/patterns/interaction-multi-select.md b/docs/patterns/interaction-multi-select.md index b604a8693..6e83fbbc3 100644 --- a/docs/patterns/interaction-multi-select.md +++ b/docs/patterns/interaction-multi-select.md @@ -46,29 +46,31 @@ function InteractionMultiSelect() { id: "confirm", label: "Confirm", // Only open orders can be confirmed: the bar shows "Confirm (n)". - appliesTo: (order) => order.status === "Open", - onClick: (orders, { clearSelection }) => { - window.alert(`Confirming ${orders.length} order(s)`); - clearSelection(); - }, + canApply: (order) => order.status === "Open", + // Return the promise: the bar disables its actions while it runs, then + // clears the selection. + onClick: (orders) => confirmOrders(orders), }, { id: "export", label: "Export", - onClick: (orders) => window.alert(`Exporting ${orders.length} order(s)`), + // Exporting doesn't change the rows, so keep them selected. + keepSelection: true, + onClick: (orders) => exportOrders(orders), }, { id: "assign", label: "Assign owner", - onClick: (orders) => window.alert(`Assigning ${orders.length} order(s)`), + onClick: async (orders) => window.alert(`Assigning ${orders.length} order(s)`), }, { // 4th action: lands in the More actions menu. id: "cancel", label: "Cancel", variant: "destructive", - appliesTo: (order) => order.status !== "Shipped", - // Destructive: confirm first (interaction/confirm). + canApply: (order) => order.status !== "Shipped", + // Destructive: confirm first (interaction/confirm). Opening the dialog is + // synchronous, so the selection is cleared on confirm instead. onClick: (orders, { clearSelection }) => setPendingCancel({ orders, clearSelection }), }, ]; @@ -123,10 +125,11 @@ function InteractionMultiSelect() { - Declare bulk actions with `selectionActions` on `useDataTable` — do NOT hand-build the bar, a floating panel, or checkbox state; the footer renders the count, actions, overflow menu, and Clear - Keep `DataTable.Footer` in the tree — it is where the bar renders -- Give an action `appliesTo` when it fits only some rows: the bar shows the eligible count, disables the action at 0, and passes only those rows to `onClick` +- Give an action `canApply` when it fits only some rows: the bar shows the eligible count, disables the action at 0, and passes only those rows to `onClick` +- Return the promise from `onClick` for async work: the bar disables its actions while it runs and clears the selection when it resolves; set `keepSelection: true` on actions that don't change rows (export) - At most 3 actions render inline, in array order; the 4th onward move to the More actions menu — order by frequency and put a destructive action last -- Destructive bulk actions MUST open an `interaction/confirm` dialog from `onClick` -- Call the `clearSelection` helper from `onClick` once an action that changes the rows has succeeded +- Destructive bulk actions MUST open an `interaction/confirm` dialog from `onClick`; that synchronous `onClick` leaves the selection alone, so call the `clearSelection` helper on confirm +- When an action also exists per row, define it once as a `DataTableAction` and spread it into both `rowActions` and `selectionActions` - Selection persists across pagination, filter, and sort changes — do NOT clear it on those ## Anti-patterns @@ -134,6 +137,7 @@ function InteractionMultiSelect() { - Hand-rolled selection (`useState`, raw ``, `Table.Root`) when the list is a DataTable - A floating or fixed-position action bar over the table instead of the footer bar - Placing bulk-action buttons in the page header or the table toolbar -- Counting eligible rows per action by hand instead of `appliesTo` +- Counting eligible rows per action by hand instead of `canApply` +- Fire-and-forget async `onClick` that doesn't return its promise — users can fire it twice, and the selection goes stale - Firing destructive bulk actions without an interaction/confirm step - Per-row `Menu` actions as a substitute for bulk actions when selection > 0 diff --git a/examples/vite-app/src/pages/dashboard/products/page.tsx b/examples/vite-app/src/pages/dashboard/products/page.tsx index ab25905c0..52c4b769a 100644 --- a/examples/vite-app/src/pages/dashboard/products/page.tsx +++ b/examples/vite-app/src/pages/dashboard/products/page.tsx @@ -5,6 +5,7 @@ import { useURLCollectionVariables, createColumnHelper, type AppShellPageProps, + type DataTableAction, type RowAction, type SelectionAction, } from "@tailor-platform/app-shell"; @@ -71,46 +72,43 @@ const columns = [ }), ]; +// Defined once, used by both the row menu and the bulk-action bar — only +// `onClick` differs. `canApply` disables the row action for active products +// and scopes the bulk action to the selected rows it can delete. +const deleteProduct: DataTableAction = { + id: "delete", + label: "Delete", + variant: "destructive", + canApply: (row) => row.status !== "Active", +}; + const rowActions: RowAction[] = [ { id: "edit", label: "Edit", onClick: (row) => alert(`Edit: ${row.name}`), }, - { - id: "delete", - label: "Delete", - variant: "destructive", - isDisabled: (row) => row.status === "Active", - onClick: (row) => alert(`Delete: ${row.name}`), - }, + { ...deleteProduct, onClick: (row) => alert(`Delete: ${row.name}`) }, ]; const names = (rows: Product[]) => rows.map((row) => row.name).join(", "); -// Bulk actions in the footer while rows are selected. `appliesTo` scopes each +// Bulk actions in the footer while rows are selected. `canApply` scopes each // action to the selected rows it can act on and shows that count. const selectionActions: SelectionAction[] = [ { id: "publish", label: "Publish", - appliesTo: (row) => row.status === "Draft", + canApply: (row) => row.status === "Draft", onClick: (rows) => alert(`Publish: ${names(rows)}`), }, { id: "archive", label: "Archive", - appliesTo: (row) => row.status !== "Archived", + canApply: (row) => row.status !== "Archived", onClick: (rows) => alert(`Archive: ${names(rows)}`), }, - { - id: "delete", - label: "Delete", - variant: "destructive", - // Same rule as the row action: active products can't be deleted. - appliesTo: (row) => row.status !== "Active", - onClick: (rows) => alert(`Delete: ${names(rows)}`), - }, + { ...deleteProduct, onClick: (rows) => alert(`Delete: ${names(rows)}`) }, ]; const ProductsPage = () => { diff --git a/examples/vite-app/src/pages/showcase/data-table-selection/page.tsx b/examples/vite-app/src/pages/showcase/data-table-selection/page.tsx index 60ade2017..8bb40673b 100644 --- a/examples/vite-app/src/pages/showcase/data-table-selection/page.tsx +++ b/examples/vite-app/src/pages/showcase/data-table-selection/page.tsx @@ -219,43 +219,49 @@ const DataTableSelectionPage = () => { const { variables, control } = useCollectionVariables({ params: { pageSize: 25 } }); const data = useMemo(() => selectPage(vendors, variables), [vendors, variables]); - const setStatus = (rows: Vendor[], status: VendorStatus) => { + // 🧪 Dummy Data: a fake request, slow enough to see the bar's pending state. + const setStatus = async (rows: Vendor[], status: VendorStatus) => { + await new Promise((resolve) => setTimeout(resolve, 900)); const ids = new Set(rows.map((row) => row.id)); setVendors((prev) => prev.map((v) => (ids.has(v.id) ? { ...v, status } : v))); }; - // 🔽 Bulk actions. `appliesTo` gives each action its "(n)" count and passes + // 🔽 Bulk actions. `canApply` gives each action its "(n)" count and passes // only the eligible rows to `onClick`; the footer disables an action at 0. + // Returning the promise makes the bar wait: actions are disabled with a + // spinner meanwhile, and the selection clears once it resolves. const selectionActions: SelectionAction[] = [ { id: "activate", label: "Activate", icon: , - appliesTo: (v) => v.status === "inactive", - onClick: (rows, { clearSelection }) => { - setStatus(rows, "active"); + canApply: (v) => v.status === "inactive", + onClick: async (rows) => { + await setStatus(rows, "active"); toast.success(`Activated ${rows.length} vendor(s)`); - clearSelection(); }, }, { id: "deactivate", label: "Deactivate", icon: , - appliesTo: (v) => v.status === "active", - onClick: (rows, { clearSelection }) => { - setStatus(rows, "inactive"); + canApply: (v) => v.status === "active", + onClick: async (rows) => { + await setStatus(rows, "inactive"); toast.success(`Deactivated ${rows.length} vendor(s)`); - clearSelection(); }, }, { id: "export", label: "Export", icon: , - // No `appliesTo`: applies to every selected row, so no count is shown. - // Keeps the selection — exporting doesn't change the rows. - onClick: (rows) => toast.success(`Exported ${rows.length} vendor(s)`), + // No `canApply`: applies to every selected row, so no count is shown. + // `keepSelection`: exporting doesn't change the rows. + keepSelection: true, + onClick: async (rows) => { + await new Promise((resolve) => setTimeout(resolve, 600)); + toast.success(`Exported ${rows.length} vendor(s)`); + }, }, { // Fourth action: lands in the footer's "More actions" menu. @@ -263,8 +269,10 @@ const DataTableSelectionPage = () => { label: "Delete", icon: , variant: "destructive", - appliesTo: (v) => v.status === "archived", - // Destructive: confirm first (interaction/confirm pattern). + canApply: (v) => v.status === "archived", + // Destructive: confirm first (interaction/confirm pattern). Opening the + // dialog is synchronous, so the bar leaves the selection alone and the + // dialog clears it on confirm. onClick: (rows, { clearSelection }) => setPendingDelete({ rows, clearSelection }), }, ]; 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 14a719114..1e11f5ec6 100644 --- a/packages/core/src/components/data-table/data-table.test.tsx +++ b/packages/core/src/components/data-table/data-table.test.tsx @@ -1854,6 +1854,92 @@ describe("DataTable", () => { }); }); + // ------------------------------------------------------------------------- + // Row actions + // ------------------------------------------------------------------------- + describe("row actions", () => { + function TestRowActions({ rowActions }: { rowActions: RowAction[] }) { + const table = useDataTable({ columns: testColumns, data: testData, rowActions }); + return ( + + + + ); + } + + // Opens the kebab menu of the row at `index` and returns its "Activate" item. + async function activateItemFor(index: number) { + const user = userEvent.setup(); + await user.click(screen.getAllByRole("button", { name: "Row actions" })[index]); + return screen.findByRole("menuitem", { name: "Activate" }); + } + + it("disables an action for rows where canApply returns false", async () => { + const onClick = vi.fn(); + render( + r.status !== "Active", onClick }, + ]} + />, + { wrapper }, + ); + + // Alice is Active → disabled; Bob is Inactive → enabled. + expect((await activateItemFor(0)).getAttribute("aria-disabled")).toBe("true"); + cleanup(); + render( + r.status !== "Active", onClick }, + ]} + />, + { wrapper }, + ); + const bobItem = await activateItemFor(1); + expect(bobItem.getAttribute("aria-disabled")).not.toBe("true"); + fireEvent.click(bobItem); + expect(onClick).toHaveBeenCalledWith(testData.rows[1]); + }); + + it("still honours the deprecated isDisabled", async () => { + render( + r.status === "Active", + onClick: vi.fn(), + }, + ]} + />, + { wrapper }, + ); + + expect((await activateItemFor(0)).getAttribute("aria-disabled")).toBe("true"); + }); + + it("shares one action definition with selectionActions", () => { + // Type-level check that the shared base spreads into both arrays. + const activate = { + id: "activate", + label: "Activate", + canApply: (r: TestRow) => r.status !== "Active", + }; + const options: UseDataTableOptions = { + columns: testColumns, + data: testData, + rowActions: [{ ...activate, onClick: (row) => void row }], + selectionActions: [{ ...activate, onClick: (rows) => void rows }], + }; + expectTypeOf(options.rowActions![0].canApply).toEqualTypeOf< + ((row: TestRow) => boolean) | undefined + >(); + expect(options.selectionActions).toHaveLength(1); + }); + }); + // ------------------------------------------------------------------------- // Expandable rows // ------------------------------------------------------------------------- diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index 5fada83e0..7d5c1a816 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -1712,7 +1712,11 @@ function RowActionsMenu>({ /> {actions.map((action) => { - const disabled = action.isDisabled?.(row) ?? false; + // `canApply` is the shared, positive form; the deprecated + // `isDisabled` still counts, so either one can switch it off. + const disabled = + (action.canApply ? !action.canApply(row) : false) || + (action.isDisabled?.(row) ?? false); return ( screen.getByRole("toolbar", { name: "Bulk actions" }); const footerOf = (container: HTMLElement) => container.querySelector('[data-slot="data-table-footer"]')!; +/** A promise the test settles by hand, to observe an action while it is pending. */ +function deferred() { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + describe("DataTable selection actions", () => { it("shows no bar until a row is selected", () => { const { container } = render(, { wrapper }); @@ -130,13 +141,13 @@ describe("DataTable selection actions", () => { { id: "activate", label: "Activate", - appliesTo: (v) => v.status === "inactive", + canApply: (v) => v.status === "inactive", onClick: vi.fn(), }, { id: "deactivate", label: "Deactivate", - appliesTo: (v) => v.status === "active", + canApply: (v) => v.status === "active", onClick: vi.fn(), }, archive(), @@ -155,7 +166,7 @@ describe("DataTable selection actions", () => { "disabled", false, ); - // No `appliesTo`: applies to every selected row, so no count is shown. + // No `canApply`: applies to every selected row, so no count is shown. expect(within(bar).getByRole("button", { name: "Archive" })).toHaveProperty("disabled", false); }); @@ -165,7 +176,7 @@ describe("DataTable selection actions", () => { clearSelection(), ); const actions: SelectionAction[] = [ - { id: "deactivate", label: "Deactivate", appliesTo: (v) => v.status === "active", onClick }, + { id: "deactivate", label: "Deactivate", canApply: (v) => v.status === "active", onClick }, ]; render(, { wrapper, @@ -206,7 +217,7 @@ describe("DataTable selection actions", () => { id: "delete", label: "Delete", variant: "destructive", - appliesTo: (v) => v.status === "archived", + canApply: (v) => v.status === "archived", onClick: onDelete, }, ]; @@ -294,4 +305,134 @@ describe("DataTable selection actions", () => { await waitFor(() => expect(queryBar()).toBeNull()); }); + + // --------------------------------------------------------------------------- + // Actions that return a promise + // --------------------------------------------------------------------------- + describe("async actions", () => { + it("disables every action and shows a spinner while the promise is pending", () => { + const pending = deferred(); + const actions: SelectionAction[] = [ + { id: "activate", label: "Activate", onClick: () => pending.promise }, + { id: "export", label: "Export", onClick: vi.fn() }, + ]; + render(, { wrapper }); + + fireEvent.click(rowCheckbox(0)); + const activate = within(getBar()).getByRole("button", { name: "Activate" }); + fireEvent.click(activate); + + expect(getBar().getAttribute("aria-busy")).toBe("true"); + expect(activate).toHaveProperty("disabled", true); + expect(activate.querySelector('[data-slot="spinner"]')).not.toBeNull(); + expect(within(getBar()).getByRole("button", { name: "Export" })).toHaveProperty( + "disabled", + true, + ); + }); + + it("clears the selection once the promise resolves", async () => { + const onSelectionChange = vi.fn(); + const pending = deferred(); + const actions: SelectionAction[] = [ + { id: "activate", label: "Activate", onClick: () => pending.promise }, + ]; + render(, { + wrapper, + }); + + fireEvent.click(rowCheckbox(0)); + fireEvent.click(within(getBar()).getByRole("button", { name: "Activate" })); + expect(queryBar()).not.toBeNull(); + + await act(async () => { + pending.resolve(); + await pending.promise; + }); + + expect(queryBar()).toBeNull(); + expect(onSelectionChange).toHaveBeenLastCalledWith([]); + }); + + it("keeps the selection after the promise when keepSelection is set", async () => { + const exportRows = vi.fn(() => Promise.resolve()); + const actions: SelectionAction[] = [ + { id: "export", label: "Export", keepSelection: true, onClick: exportRows }, + ]; + render(, { wrapper }); + + fireEvent.click(rowCheckbox(0)); + await act(async () => { + fireEvent.click(within(getBar()).getByRole("button", { name: "Export" })); + }); + + expect(exportRows).toHaveBeenCalledTimes(1); + expect(within(getBar()).getByRole("button", { name: "Export" })).toHaveProperty( + "disabled", + false, + ); + expect(getBar().hasAttribute("aria-busy")).toBe(false); + }); + + it("keeps the selection and re-enables the actions when the promise rejects", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const pending = deferred(); + const actions: SelectionAction[] = [ + { id: "activate", label: "Activate", onClick: () => pending.promise }, + ]; + render(, { wrapper }); + + fireEvent.click(rowCheckbox(0)); + fireEvent.click(within(getBar()).getByRole("button", { name: "Activate" })); + await act(async () => { + pending.reject(new Error("network down")); + await pending.promise.catch(() => {}); + }); + + expect(within(getBar()).getByRole("button", { name: "Activate" })).toHaveProperty( + "disabled", + false, + ); + expect(consoleError).toHaveBeenCalledWith( + '[DataTable] Selection action "activate" failed:', + expect.any(Error), + ); + consoleError.mockRestore(); + }); + + it("leaves the selection alone when onClick returns nothing", () => { + // e.g. an onClick that only opens a confirm dialog + const actions: SelectionAction[] = [ + { id: "delete", label: "Delete", onClick: vi.fn() }, + ]; + render(, { wrapper }); + + fireEvent.click(rowCheckbox(0)); + fireEvent.click(within(getBar()).getByRole("button", { name: "Delete" })); + + expect(queryBar()).not.toBeNull(); + expect(getBar().hasAttribute("aria-busy")).toBe(false); + }); + + it("fires a single onSelectionChange([]) when the action also clears itself", async () => { + const onSelectionChange = vi.fn(); + const actions: SelectionAction[] = [ + { + id: "activate", + label: "Activate", + onClick: async (_rows, { clearSelection }) => clearSelection(), + }, + ]; + render(, { + wrapper, + }); + + fireEvent.click(rowCheckbox(0)); + await act(async () => { + fireEvent.click(within(getBar()).getByRole("button", { name: "Activate" })); + }); + + expect(onSelectionChange.mock.calls.filter(([ids]) => ids.length === 0)).toHaveLength(1); + }); + }); }); diff --git a/packages/core/src/components/data-table/selection-bar.tsx b/packages/core/src/components/data-table/selection-bar.tsx index ae3c53728..ba25978cf 100644 --- a/packages/core/src/components/data-table/selection-bar.tsx +++ b/packages/core/src/components/data-table/selection-bar.tsx @@ -1,8 +1,9 @@ -import { useLayoutEffect, useRef } from "react"; +import { useLayoutEffect, useRef, useState } from "react"; import { Ellipsis } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/button"; import { Menu } from "@/components/menu"; +import { Spinner } from "@/components/spinner"; import { Toolbar } from "@/components/toolbar"; import { useDataTableContext, type DataTableContextValue } from "./data-table-context"; import { useDataTableT } from "./i18n"; @@ -37,12 +38,16 @@ function useSelectionCountText(): string { type ResolvedAction> = { action: SelectionAction; - /** The selected rows this action applies to — what `onClick` receives. */ + /** The selected rows this action can act on — what `onClick` receives. */ rows: TRow[]; /** Shown as "(n)" only when the action narrows the selection. */ count: number | null; }; +function isPromiseLike(value: unknown): value is PromiseLike { + return typeof (value as PromiseLike | null | undefined)?.then === "function"; +} + /** * The bulk-action bar `DataTable.Footer` renders while rows are selected: * count · actions · overflow menu · Clear. @@ -68,15 +73,45 @@ export function DataTableSelectionBar>() { }; }, []); + // Id of the action whose promise is still pending. While set, every action + // is disabled, so a slow request can't be fired twice or overlapped by + // another action against the same selection. + const [pendingId, setPendingId] = useState(null); + const busy = pendingId !== null; + const clear = () => clearSelection?.(); const resolved: ResolvedAction[] = selectionActions.map((action) => { - const { appliesTo } = action; - const rows = appliesTo ? selectedRows.filter((row) => appliesTo(row)) : selectedRows; - return { action, rows, count: appliesTo ? rows.length : null }; + const { canApply } = action; + const rows = canApply ? selectedRows.filter((row) => canApply(row)) : selectedRows; + return { action, rows, count: canApply ? rows.length : null }; }); const inline = resolved.slice(0, MAX_INLINE_ACTIONS); const overflow = resolved.slice(MAX_INLINE_ACTIONS); + const run = ({ action, rows }: ResolvedAction) => { + if (busy || rows.length === 0) return; + const result = action.onClick(rows, { clearSelection: clear }); + // Synchronous handlers (e.g. one that opens a confirm dialog) own the + // selection from here; only a returned promise is waited on. + if (!isPromiseLike(result)) return; + setPendingId(action.id); + result.then( + () => { + setPendingId(null); + // The rows it acted on may have changed, and copies remembered from + // other pages can't refresh — clear rather than keep stale rows around. + if (!action.keepSelection) clear(); + }, + (error: unknown) => { + // Keep the selection so the action can be retried. Report instead of + // rethrowing: a rethrow from this chain would surface as an unhandled + // rejection even when the app already handled the error itself. + setPendingId(null); + console.error(`[DataTable] Selection action "${action.id}" failed:`, error); + }, + ); + }; + return ( // max-w-full + shrink-0: next to Pagination in the footer's wrapping row, // the bar keeps its actions on one line and lets Pagination drop below it, @@ -84,6 +119,7 @@ export function DataTableSelectionBar>() { @@ -91,40 +127,46 @@ export function DataTableSelectionBar>() { {countText} - {inline.map(({ action, rows, count }) => ( - - ))} + {inline.map((resolvedAction) => { + const { action, rows, count } = resolvedAction; + return ( + + ); + })} {overflow.length > 0 && ( - + {overflow.some(({ action }) => action.id === pendingId) ? ( + + ) : ( + + )} } /> {/* Opens upward: the bar sits at the bottom of the table, and often at the bottom of the viewport while it is stuck there. */} - {overflow.map(({ action, rows, count }) => { - const disabled = rows.length === 0; + {overflow.map((resolvedAction) => { + const { action, rows, count } = resolvedAction; return ( { - if (!disabled) action.onClick(rows, { clearSelection: clear }); - }} + disabled={busy || rows.length === 0} + onClick={() => run(resolvedAction)} className={cn(action.variant === "destructive" && "astw:text-destructive")} > {action.icon} diff --git a/packages/core/src/components/data-table/types.ts b/packages/core/src/components/data-table/types.ts index 57facb035..82467d175 100644 --- a/packages/core/src/components/data-table/types.ts +++ b/packages/core/src/components/data-table/types.ts @@ -490,13 +490,51 @@ export type UseDataTableOptions< // ============================================================================= /** - * A single row action definition for the actions column. + * What `RowAction` and `SelectionAction` share: how an action looks, and which + * rows it can act on. Define an action once and spread it into both arrays — + * only `onClick` differs, since one row and many rows need different handling. + * + * @example + * ```tsx + * const activate: DataTableAction = { + * id: "activate", + * label: "Activate", + * canApply: (vendor) => vendor.status === "inactive", + * }; + * + * useDataTable({ + * rowActions: [{ ...activate, onClick: (vendor) => activateOne(vendor) }], + * selectionActions: [{ ...activate, onClick: (vendors) => activateMany(vendors) }], + * }); + * ``` */ -export interface RowAction> { +export interface DataTableAction> { id: string; label: string; icon?: ReactNode; variant?: "default" | "destructive"; + /** + * Whether the action can act on `row`. Omit it for actions that apply to + * every row. A row action is disabled for rows where it returns `false`; a + * selection action shows how many selected rows it returns `true` for + * ("Activate (6)"), is disabled when there are none, and passes only those + * rows to `onClick`. + */ + canApply?: (row: TRow) => boolean; +} + +/** + * A single row action definition for the actions column. + */ +export interface RowAction> extends DataTableAction { + /** + * Return `true` to disable the action for a given row. + * + * @deprecated Use `canApply`, which reads the other way round: + * `isDisabled: (row) => x` is `canApply: (row) => !x`. It is shared with + * `SelectionAction`, so one definition serves both. If both are set, the + * action is disabled when either one says so. + */ isDisabled?: (row: TRow) => boolean; onClick: (row: TRow) => void; } @@ -505,24 +543,27 @@ export interface RowAction> { * A bulk action for the selected rows, shown in `DataTable.Footer` while a * selection is open. See `UseDataTableOptions.selectionActions`. */ -export interface SelectionAction> { - id: string; - label: string; - icon?: ReactNode; - variant?: "default" | "destructive"; +export interface SelectionAction< + TRow extends Record, +> extends DataTableAction { /** - * Narrows the action to the selected rows it applies to — e.g. "Activate" - * only for inactive rows. When set, the button shows how many selected rows - * qualify ("Activate (6)"), is disabled when none do, and `onClick` receives - * only those rows. Omit it for actions that apply to every selected row. + * Called with the selected rows the action can act on (see `canApply`), + * including rows selected on other pages, as they were last loaded. + * + * **Return the promise** for asynchronous work. While it is pending, the bar + * disables its actions and shows a spinner on this one; once it resolves, the + * selection is cleared — those rows may have changed, and rows remembered + * from other pages would otherwise be stale — unless `keepSelection` is set. + * A rejected promise leaves the selection as it was, so the action can be + * retried. Synchronous handlers (such as opening a confirm dialog) leave the + * selection alone: call `clearSelection` once the work is done. */ - appliesTo?: (row: TRow) => boolean; + onClick: (rows: TRow[], helpers: { clearSelection: () => void }) => void | Promise; /** - * Called with the selected rows the action applies to, including rows - * selected on other pages (as they were last loaded). Call `clearSelection` - * once the action has done its work. + * Keep the selection after this action's promise resolves — for actions that + * don't change the rows, such as an export. */ - onClick: (rows: TRow[], helpers: { clearSelection: () => void }) => void; + keepSelection?: boolean; } /** 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 b33b486b2..ca03cd868 100644 --- a/packages/core/src/components/data-table/use-data-table.ts +++ b/packages/core/src/components/data-table/use-data-table.ts @@ -358,7 +358,15 @@ export function useDataTable< } : undefined; - const clearSelection = selectionEnabled ? () => commitSelection(new Map()) : undefined; + // A no-op on an empty selection: a selection action may clear inside its + // `onClick` and then be cleared again by the bar once its promise resolves, + // and that second call must not fire another `onSelectionChange([])`. + const clearSelection = selectionEnabled + ? () => { + if (selectionRef.current.size === 0) return; + commitSelection(new Map()); + } + : undefined; const selectedIds = useMemo(() => [...selection.keys()], [selection]); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 70b8eae59..5e6644b61 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -301,6 +301,7 @@ export { type DataTableData, type DataTableFilterConfig, type HeaderRenderContext, + type DataTableAction, type RowAction, type SelectionAction, type UseDataTableOptions, From 4382774a5cd31324304bc2adf8f033ae7e1eabb5 Mon Sep 17 00:00:00 2001 From: itsprade Date: Thu, 8 Oct 2026 11:16:23 +0530 Subject: [PATCH 9/9] feat(data-table): track selection-action requests in the table, and add run() Addresses Sean's second review comment on #496. - The in-flight action now lives in useDataTable (pendingActionId / runSelectionAction) instead of the footer bar. Emptying the selection unmounts the bar, and a remounted bar used to forget the pending request and re-enable its actions. The busy state now survives that. - Clear is disabled while a request is pending, which closes the obvious way to empty the selection mid-request. - onClick's helpers gain run(request), exported as SelectionActionHelpers. An action that only opens a confirm dialog keeps run and passes it the request from the dialog's confirm button. The bar then gives it the same pending state and clear-on-success as a returned promise. The showcase delete, the multi-select pattern example, the docs and the changeset use it. Co-Authored-By: Claude Opus 5.5 --- .changeset/quiet-footers-rise.md | 2 +- docs-manifest.json | 17 ++-- .../components/data-table.docs.outline.md | 12 +-- ...interaction-multi-select.docs.examples.tsx | 16 ++-- .../interaction-multi-select.docs.outline.md | 2 +- docs/components/data-table.md | 12 +-- docs/patterns/interaction-multi-select.md | 12 +-- .../showcase/data-table-selection/page.tsx | 22 +++-- .../data-table/data-table-context.tsx | 7 ++ .../src/components/data-table/data-table.tsx | 2 + .../core/src/components/data-table/index.ts | 1 + .../data-table/selection-bar.test.tsx | 80 +++++++++++++++++++ .../components/data-table/selection-bar.tsx | 60 +++++++------- .../core/src/components/data-table/types.ts | 37 ++++++++- .../components/data-table/use-data-table.ts | 39 +++++++++ packages/core/src/index.ts | 1 + 16 files changed, 250 insertions(+), 72 deletions(-) diff --git a/.changeset/quiet-footers-rise.md b/.changeset/quiet-footers-rise.md index 95478d175..b130fa138 100644 --- a/.changeset/quiet-footers-rise.md +++ b/.changeset/quiet-footers-rise.md @@ -2,7 +2,7 @@ "@tailor-platform/app-shell": minor --- -Add bulk actions to `DataTable`. Pass `selectionActions` to `useDataTable` and, while rows are selected, `DataTable.Footer` becomes a bulk-action bar: the selection count, your actions, and a Clear button, with pagination kept alongside. Give an action `canApply` to scope it to the selected rows it can act on — the bar shows that count, disables the action at zero, and hands only those rows to `onClick`. Return the promise from `onClick` and the bar waits for it: actions are disabled with a spinner meanwhile, and the selection clears once it resolves (set `keepSelection` for actions such as an export). +Add bulk actions to `DataTable`. Pass `selectionActions` to `useDataTable` and, while rows are selected, `DataTable.Footer` becomes a bulk-action bar: the selection count, your actions, and a Clear button, with pagination kept alongside. Give an action `canApply` to scope it to the selected rows it can act on — the bar shows that count, disables the action at zero, and hands only those rows to `onClick`. Return the promise from `onClick` and the bar waits for it: actions are disabled with a spinner meanwhile, and the selection clears once it resolves (set `keepSelection` for actions such as an export). An action that confirms first can hand its later request back through the `run` helper — `run(deleteRows(rows))` from the dialog's confirm button — and get the same handling. Clear is disabled while a request is pending. ```tsx const table = useDataTable({ diff --git a/docs-manifest.json b/docs-manifest.json index f8e870200..c9e0fe144 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -423,6 +423,7 @@ "RowAction", "SelectOption", "SelectionAction", + "SelectionActionHelpers", "SortConfig", "SortState", "TableFieldName", @@ -445,10 +446,10 @@ "withURLCollectionState" ], "hashes": { - "typeSurface": "fc6551ad164ad460", - "outline": "7da9d1c06ab1a20e", + "typeSurface": "fe43108015be4a00", + "outline": "bc26d54f88f9cf37", "snapshot": null, - "outputMd": "fc7efe94754d5f71", + "outputMd": "97ae28f7a98c5d97", "examples": null } }, @@ -929,10 +930,10 @@ "symbols": [], "hashes": { "typeSurface": null, - "outline": "2b07d9a974858c60", + "outline": "8d704967c8c882fd", "snapshot": null, - "outputMd": "7e2fb7ebd1eb57fe", - "examples": "f19fb01970649857" + "outputMd": "770b33ada6b9317f", + "examples": "04c7dc51378c04c4" } }, "interaction-toast": { @@ -1675,7 +1676,7 @@ "packages/core/skills/app-shell-patterns/references/components/combobox.md": "dfb775c7c4307409", "packages/core/skills/app-shell-patterns/references/components/command-palette.md": "7e5bc675a593791a", "packages/core/skills/app-shell-patterns/references/components/csv-importer.md": "53d3c795ca21b9dc", - "packages/core/skills/app-shell-patterns/references/components/data-table.md": "fc7efe94754d5f71", + "packages/core/skills/app-shell-patterns/references/components/data-table.md": "97ae28f7a98c5d97", "packages/core/skills/app-shell-patterns/references/components/date-picker.md": "33902be121ff69db", "packages/core/skills/app-shell-patterns/references/components/default-header.md": "375e55f4b12fa2be", "packages/core/skills/app-shell-patterns/references/components/default-sidebar.md": "3422388a0cc78571", @@ -1733,7 +1734,7 @@ "packages/core/skills/app-shell-patterns/references/patterns/form-single-page.md": "10a0f4d91a120752", "packages/core/skills/app-shell-patterns/references/patterns/form-wizard.md": "851afb4672da6673", "packages/core/skills/app-shell-patterns/references/patterns/interaction-confirm.md": "2db70f423c6fdf97", - "packages/core/skills/app-shell-patterns/references/patterns/interaction-multi-select.md": "7e2fb7ebd1eb57fe", + "packages/core/skills/app-shell-patterns/references/patterns/interaction-multi-select.md": "770b33ada6b9317f", "packages/core/skills/app-shell-patterns/references/patterns/interaction-toast.md": "f68bdab855730f48", "packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "916baa3cd2b9cbf1", "packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "717ef4e7ae835aff", diff --git a/docs-src/components/data-table.docs.outline.md b/docs-src/components/data-table.docs.outline.md index 23af56fbf..8554fa436 100644 --- a/docs-src/components/data-table.docs.outline.md +++ b/docs-src/components/data-table.docs.outline.md @@ -414,7 +414,7 @@ const table = useDataTable({ - **`canApply` scopes an action to part of the selection.** The button shows how many selected rows qualify — `Activate (6)` — is disabled when none do, and `onClick` receives only those rows. Omit it for actions that apply to every selected row; no count is shown then. - **Return the promise from `onClick`.** While it is pending, the bar disables every action and shows a spinner on the running one, so a slow request can't be fired twice. When it resolves, the selection is cleared — the rows may have changed, and copies remembered from other pages can't refresh themselves. Set `keepSelection: true` for actions that don't change the rows, such as an export. If the promise rejects, the selection stays so the action can be retried, and the error is logged as `[DataTable] Selection action "…" failed`; showing it to the user is up to your `onClick`. -- **Synchronous handlers own the selection.** An `onClick` that returns nothing — typically one that opens a confirm dialog — leaves the selection alone; call the `clearSelection` helper once the work is done. +- **Confirm dialogs hand the request back with `run`.** An `onClick` that only opens a confirm dialog returns nothing, so there is nothing to wait on yet. Keep the `run` helper and call it from the dialog's confirm button — `run(deleteRows(rows))` — and the bar shows the same pending state and clears on success. Clear is disabled while a request is pending, and the pending state is kept by the table, so it survives the bar closing and reopening. A synchronous handler that never calls `run` leaves the selection alone; call `clearSelection` when it should. - **Selection spans pages.** The table remembers each selected row as it was last loaded, so counts and `onClick` cover rows selected on other pages too. Rows on the current page are always their latest version. The header checkbox adds or removes only the current page; **Clear** empties everything. - **Three actions stay inline.** The fourth onward collapse into a **More actions** menu, in array order. Put the most frequent first; a destructive action placed last sits safely in the menu. - **Confirm destructive actions.** `variant: "destructive"` only styles the action. Open a confirm dialog from `onClick` before deleting — see the [confirm pattern](../patterns/interaction-confirm.md). @@ -813,11 +813,11 @@ A row action in the kebab-menu column. Extends [`DataTableAction`](#datatableact A bulk action for the rows currently selected. Extends [`DataTableAction`](#datatableaction). See [Selection actions](#selection-actions). -| Property | Type | Description | -| --------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `canApply` | `(row: TRow) => boolean` | Scopes the action to the selected rows it can act on: shows their count, disables at zero, and passes only those rows to `onClick`. | -| `onClick` | `(rows: TRow[], helpers: { clearSelection: () => void }) => void \| Promise` | Called with the selected rows the action can act on, including rows selected on other pages. Return a promise to get the pending state and clear-on-success; see [Selection actions](#selection-actions). | -| `keepSelection` | `boolean` | Keep the selection after the promise resolves — for actions that don't change the rows, such as an export. | +| Property | Type | Description | +| --------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `canApply` | `(row: TRow) => boolean` | Scopes the action to the selected rows it can act on: shows their count, disables at zero, and passes only those rows to `onClick`. | +| `onClick` | `(rows: TRow[], helpers: { clearSelection, run }) => void \| Promise` | Called with the selected rows the action can act on, including rows selected on other pages. Return a promise — or pass a later one to `run`, e.g. from a confirm dialog — to get the pending state and clear-on-success; see [Selection actions](#selection-actions). | +| `keepSelection` | `boolean` | Keep the selection after the promise resolves — for actions that don't change the rows, such as an export. | ## `DataTableAction` diff --git a/docs-src/patterns/interaction-multi-select.docs.examples.tsx b/docs-src/patterns/interaction-multi-select.docs.examples.tsx index 7bebdd035..2d6d09284 100644 --- a/docs-src/patterns/interaction-multi-select.docs.examples.tsx +++ b/docs-src/patterns/interaction-multi-select.docs.examples.tsx @@ -6,6 +6,7 @@ import { useDataTable, type Column, type SelectionAction, + type SelectionActionHelpers, } from "@tailor-platform/app-shell"; type Order = { @@ -29,9 +30,12 @@ const columns: Column[] = [ { label: "Total", render: (order) => `$${order.total.toLocaleString()}` }, ]; -type PendingCancel = { orders: Order[]; clearSelection: () => void }; +type PendingCancel = { orders: Order[]; run: SelectionActionHelpers["run"] }; // Stand-ins for real mutations. +const cancelOrders = async (orders: Order[]) => { + window.alert(`Cancelling ${orders.length} order(s)`); +}; const confirmOrders = async (orders: Order[]) => { window.alert(`Confirming ${orders.length} order(s)`); }; @@ -70,9 +74,9 @@ export function InteractionMultiSelect() { label: "Cancel", variant: "destructive", canApply: (order) => order.status !== "Shipped", - // Destructive: confirm first (interaction/confirm). Opening the dialog is - // synchronous, so the selection is cleared on confirm instead. - onClick: (orders, { clearSelection }) => setPendingCancel({ orders, clearSelection }), + // Destructive: confirm first (interaction/confirm). Keep `run` and hand + // the request back from the dialog's confirm button. + onClick: (orders, { run }) => setPendingCancel({ orders, run }), }, ]; @@ -107,8 +111,8 @@ export function InteractionMultiSelect() { diff --git a/packages/core/src/components/data-table/types.ts b/packages/core/src/components/data-table/types.ts index 82467d175..513ab4813 100644 --- a/packages/core/src/components/data-table/types.ts +++ b/packages/core/src/components/data-table/types.ts @@ -539,6 +539,21 @@ export interface RowAction> extends DataTab onClick: (row: TRow) => void; } +/** The second argument to `SelectionAction.onClick`. */ +export interface SelectionActionHelpers { + /** Empty the selection across all pages. */ + clearSelection: () => void; + /** + * Hand the bar a request it should track — for one that starts after + * `onClick` has returned, typically from a confirm dialog's confirm button. + * The bar then behaves as if `onClick` had returned it: actions (and Clear) + * are disabled while it is pending, and the selection clears once it resolves + * unless the action sets `keepSelection`. Returns the request, so it can be + * awaited too. + */ + run: (request: PromiseLike) => PromiseLike; +} + /** * A bulk action for the selected rows, shown in `DataTable.Footer` while a * selection is open. See `UseDataTableOptions.selectionActions`. @@ -555,10 +570,15 @@ export interface SelectionAction< * selection is cleared — those rows may have changed, and rows remembered * from other pages would otherwise be stale — unless `keepSelection` is set. * A rejected promise leaves the selection as it was, so the action can be - * retried. Synchronous handlers (such as opening a confirm dialog) leave the - * selection alone: call `clearSelection` once the work is done. + * retried. + * + * **Confirming first?** An `onClick` that only opens a confirm dialog returns + * nothing, so the bar has nothing to wait on. Keep `helpers.run` and pass it + * the request when the user confirms — `run(deleteRows(rows))` — and the bar + * treats it exactly like a returned promise. Otherwise a synchronous handler + * leaves the selection alone; call `clearSelection` once the work is done. */ - onClick: (rows: TRow[], helpers: { clearSelection: () => void }) => void | Promise; + onClick: (rows: TRow[], helpers: SelectionActionHelpers) => void | Promise; /** * Keep the selection after this action's promise resolves — for actions that * don't change the rows, such as an export. @@ -658,6 +678,17 @@ export interface UseDataTableReturn> { deselectAllRows?: () => void; /** Empties the selection across all pages. */ clearSelection?: () => void; + /** Id of the selection action whose request is in flight, or `null`. */ + pendingActionId?: string | null; + /** + * Tracks a selection action's request: disables the bar while it is pending + * and clears the selection once it resolves (unless `keepSelection`). This is + * what `SelectionActionHelpers.run` calls. Undefined when selection is off. + */ + runSelectionAction?: ( + action: { id: string; keepSelection?: boolean }, + request: PromiseLike, + ) => void; isAllSelected: boolean; isIndeterminate: boolean; 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 ca03cd868..05d854515 100644 --- a/packages/core/src/components/data-table/use-data-table.ts +++ b/packages/core/src/components/data-table/use-data-table.ts @@ -368,6 +368,43 @@ export function useDataTable< } : undefined; + // Id of the selection action whose request is in flight. It lives here rather + // than in the footer bar so it survives the bar unmounting and remounting + // (the selection emptying and refilling mid-request), and so a request handed + // back later — from a confirm dialog, via `run` — is tracked the same way. + const [pendingActionId, setPendingActionId] = useState(null); + const pendingActionIdRef = useRef(pendingActionId); + pendingActionIdRef.current = pendingActionId; + + const runSelectionAction = selectionEnabled + ? (action: { id: string; keepSelection?: boolean }, request: PromiseLike) => { + // One request at a time: the bar disables its actions while one runs, + // so a second call here only comes from app code racing itself. + if (pendingActionIdRef.current !== null) return; + pendingActionIdRef.current = action.id; + setPendingActionId(action.id); + const settle = () => { + pendingActionIdRef.current = null; + setPendingActionId(null); + }; + request.then( + () => { + settle(); + // The rows it acted on may have changed, and copies remembered from + // other pages can't refresh — clear rather than keep stale rows. + if (!action.keepSelection) clearSelection?.(); + }, + (reason: unknown) => { + // Keep the selection so the action can be retried. Report rather + // than rethrow: a rethrow from this chain would surface as an + // unhandled rejection even when the app already handled the error. + settle(); + console.error(`[DataTable] Selection action "${action.id}" failed:`, reason); + }, + ); + } + : undefined; + const selectedIds = useMemo(() => [...selection.keys()], [selection]); const selectedRows = useMemo(() => { @@ -506,6 +543,8 @@ export function useDataTable< selectAllRows, deselectAllRows, clearSelection, + pendingActionId, + runSelectionAction, isAllSelected, isIndeterminate, expandedIds: expandedIdsList, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5e6644b61..9c9783153 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -304,6 +304,7 @@ export { type DataTableAction, type RowAction, type SelectionAction, + type SelectionActionHelpers, type UseDataTableOptions, type UseDataTableReturn, type MetadataFieldOptions,