diff --git a/.changeset/quiet-footers-rise.md b/.changeset/quiet-footers-rise.md new file mode 100644 index 000000000..b130fa138 --- /dev/null +++ b/.changeset/quiet-footers-rise.md @@ -0,0 +1,29 @@ +--- +"@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). 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({ + columns, + data, + control, + selectionActions: [ + { + id: "activate", + label: "Activate", + 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 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 new file mode 100644 index 000000000..2609379bd --- /dev/null +++ b/decisions/data-table-selection-footer-actions.md @@ -0,0 +1,69 @@ +# Decision: DataTable bulk actions live in the footer, built into the component + +> 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. + +## Context + +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. + +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). + +- **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`. + +## Decision + +**`selectionActions` on `useDataTable`, rendered by `DataTable.Footer`.** + +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, + selectionActions: [ + { + id: "activate", + label: "Activate", + icon: , + canApply: (v) => v.status === "inactive", // "Activate (6)", disabled at 0 + onClick: (rows) => activate(rows), // only the eligible rows; return the promise + }, + ], +}); +``` + +**What changed from the sketch the team first saw:** + +- **`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:** + +- **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. + +## 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. `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 4466b07c4..ff7b8e2ab 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -403,6 +403,7 @@ "CollectionVariables", "Column", "DataTable", + "DataTableAction", "DataTableContextValue", "DataTableData", "DataTableFilterConfig", @@ -421,6 +422,8 @@ "PaginationVariables", "RowAction", "SelectOption", + "SelectionAction", + "SelectionActionHelpers", "SortConfig", "SortState", "TableFieldName", @@ -443,10 +446,10 @@ "withURLCollectionState" ], "hashes": { - "typeSurface": "014a9036a3b9b0af", - "outline": "f1bea006bdeb43a4", + "typeSurface": "fe43108015be4a00", + "outline": "bc26d54f88f9cf37", "snapshot": null, - "outputMd": "dd2da884d7550daf", + "outputMd": "97ae28f7a98c5d97", "examples": null } }, @@ -927,10 +930,10 @@ "symbols": [], "hashes": { "typeSurface": null, - "outline": "cf74b5f060b145d1", + "outline": "8d704967c8c882fd", "snapshot": null, - "outputMd": "09e99e5035da6e81", - "examples": "48e0d4327812dfde" + "outputMd": "770b33ada6b9317f", + "examples": "04c7dc51378c04c4" } }, "interaction-toast": { @@ -995,9 +998,9 @@ "symbols": [], "hashes": { "typeSurface": null, - "outline": "165e45ba5f128d96", + "outline": "68653902d2c9f861", "snapshot": null, - "outputMd": "8062ea0df9403fbb", + "outputMd": "916baa3cd2b9cbf1", "examples": "c0a2bd60e8a04474" } }, @@ -1673,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": "dd2da884d7550daf", + "packages/core/skills/app-shell-patterns/references/components/data-table.md": "97ae28f7a98c5d97", "packages/core/skills/app-shell-patterns/references/components/date-picker.md": "f39002ad79625dc4", "packages/core/skills/app-shell-patterns/references/components/default-header.md": "375e55f4b12fa2be", "packages/core/skills/app-shell-patterns/references/components/default-sidebar.md": "3422388a0cc78571", @@ -1731,11 +1734,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": "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": "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": "717ef4e7ae835aff", "packages/core/skills/app-shell-patterns/references/migrations.md": "013c3fef2303b096", - "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..8554fa436 100644 --- a/docs-src/components/data-table.docs.outline.md +++ b/docs-src/components/data-table.docs.outline.md @@ -28,7 +28,9 @@ import { type DataTableData, type DataTableRootProps, type DataTablePaginationProps, + type DataTableAction, type RowAction, + type SelectionAction, type UseDataTableOptions, type UseDataTableReturn, type MetadataFieldOptions, @@ -150,15 +152,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 +228,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 +376,55 @@ 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: , + 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), + }, + ], +}); + + + + + + +; +``` + +- **`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`. +- **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). +- **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 + +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 +450,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). | @@ -749,14 +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. 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, 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` + +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` @@ -918,6 +1006,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..2d6d09284 100644 --- a/docs-src/patterns/interaction-multi-select.docs.examples.tsx +++ b/docs-src/patterns/interaction-multi-select.docs.examples.tsx @@ -1,9 +1,18 @@ import { useState } from "react"; -import { Button, Table, Menu } from "@tailor-platform/app-shell"; +import { + Button, + DataTable, + Dialog, + useDataTable, + type Column, + type SelectionAction, + type SelectionActionHelpers, +} from "@tailor-platform/app-shell"; + type Order = { id: string; number: string; - status: string; + status: "Open" | "Confirmed" | "Shipped"; total: number; }; @@ -15,101 +24,103 @@ 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()}` }, +]; + +type PendingCancel = { orders: Order[]; run: SelectionActionHelpers["run"] }; - const [selectedIds, setSelectedIds] = useState>(new Set()); +// 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)`); +}; +const exportOrders = async (orders: Order[]) => { + window.alert(`Exporting ${orders.length} order(s)`); +}; - 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)". + 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", + // Exporting doesn't change the rows, so keep them selected. + keepSelection: true, + onClick: (orders) => exportOrders(orders), + }, + { + id: "assign", + label: "Assign owner", + onClick: async (orders) => window.alert(`Assigning ${orders.length} order(s)`), + }, + { + // 4th action: lands in the More actions menu. + id: "cancel", + label: "Cancel", + variant: "destructive", + canApply: (order) => order.status !== "Shipped", + // Destructive: confirm first (interaction/confirm). Keep `run` and hand + // the request back from the dialog's confirm button. + onClick: (orders, { run }) => setPendingCancel({ orders, run }), + }, + ]; - 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..6386936ae 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,50 @@ 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 `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`; keep the `run` helper and pass it the request from the dialog's confirm button (`run(deleteRows(rows))`) so the bar shows its pending state and clears on success +- 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 -- 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 `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-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..262233525 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -22,7 +22,9 @@ import { type DataTableData, type DataTableRootProps, type DataTablePaginationProps, + type DataTableAction, type RowAction, + type SelectionAction, type UseDataTableOptions, type UseDataTableReturn, type MetadataFieldOptions, @@ -144,15 +146,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 +222,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 +370,55 @@ 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: , + 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), + }, + ], +}); + + + + + + +; +``` + +- **`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`. +- **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). +- **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 + +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 +444,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). | @@ -743,14 +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. 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, 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` + +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` @@ -912,6 +1000,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..654eeaa88 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,105 @@ 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)". + 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", + // Exporting doesn't change the rows, so keep them selected. + keepSelection: true, + onClick: (orders) => exportOrders(orders), + }, + { + id: "assign", + label: "Assign owner", + onClick: async (orders) => window.alert(`Assigning ${orders.length} order(s)`), + }, + { + // 4th action: lands in the More actions menu. + id: "cancel", + label: "Cancel", + variant: "destructive", + canApply: (order) => order.status !== "Shipped", + // Destructive: confirm first (interaction/confirm). Keep `run` and hand + // the request back from the dialog's confirm button. + onClick: (orders, { run }) => setPendingCancel({ orders, run }), + }, + ]; + + 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 `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`; keep the `run` helper and pass it the request from the dialog's confirm button (`run(deleteRows(rows))`) so the bar shows its pending state and clears on success +- 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 -- 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 `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/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 diff --git a/examples/vite-app/src/App.tsx b/examples/vite-app/src/App.tsx index 353ec56fb..d57e21936 100644 --- a/examples/vite-app/src/App.tsx +++ b/examples/vite-app/src/App.tsx @@ -78,6 +78,7 @@ const AppInner = () => { + diff --git a/examples/vite-app/src/pages/dashboard/products/page.tsx b/examples/vite-app/src/pages/dashboard/products/page.tsx index 27e5b6b7b..52c4b769a 100644 --- a/examples/vite-app/src/pages/dashboard/products/page.tsx +++ b/examples/vite-app/src/pages/dashboard/products/page.tsx @@ -5,10 +5,11 @@ import { useURLCollectionVariables, createColumnHelper, type AppShellPageProps, + type DataTableAction, 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 = { @@ -71,19 +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}`), }, + { ...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. `canApply` scopes each +// action to the selected rows it can act on and shows that count. +const selectionActions: SelectionAction[] = [ + { + id: "publish", + label: "Publish", + canApply: (row) => row.status === "Draft", + onClick: (rows) => alert(`Publish: ${names(rows)}`), + }, { - id: "delete", - label: "Delete", - variant: "destructive", - isDisabled: (row) => row.status === "Active", - onClick: (row) => alert(`Delete: ${row.name}`), + id: "archive", + label: "Archive", + canApply: (row) => row.status !== "Archived", + onClick: (rows) => alert(`Archive: ${names(rows)}`), }, + { ...deleteProduct, onClick: (rows) => alert(`Delete: ${names(rows)}`) }, ]; const ProductsPage = () => { @@ -97,7 +122,6 @@ const ProductsPage = () => { }); const { data, loading } = useProductsQuery(variables); - const [selectedIds, setSelectedIds] = useState([]); const table = useDataTable({ columns, @@ -112,7 +136,7 @@ const ProductsPage = () => { control, rowActions, onClickRow: (row) => alert(`Clicked: ${row.name}`), - onSelectionChange: (ids) => setSelectedIds(ids), + selectionActions, }); return ( @@ -137,9 +161,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 new file mode 100644 index 000000000..45bcffbb4 --- /dev/null +++ b/examples/vite-app/src/pages/showcase/data-table-selection/page.tsx @@ -0,0 +1,377 @@ +import { useMemo, useState } from "react"; +import { + Layout, + Button, + DataTable, + Dialog, + Toolbar, + useDataTable, + useCollectionVariables, + useToast, + createColumnHelper, + type AppShellPageProps, + type CollectionVariables, + type DataTableData, + type PageInfo, + type SelectionAction, + type SelectionActionHelpers, +} from "@tailor-platform/app-shell"; +import { CheckSquare, Download, 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 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(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 — 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]; + 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" }, + }), +]; + +// ─── Page ──────────────────────────────────────────────────────────────────── + +type PendingDelete = { rows: Vendor[]; run: SelectionActionHelpers["run"] }; + +const DataTableSelectionPage = () => { + const toast = useToast(); + // 🧪 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]); + + // 🧪 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. `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: , + canApply: (v) => v.status === "inactive", + onClick: async (rows) => { + await setStatus(rows, "active"); + toast.success(`Activated ${rows.length} vendor(s)`); + }, + }, + { + id: "deactivate", + label: "Deactivate", + icon: , + canApply: (v) => v.status === "active", + onClick: async (rows) => { + await setStatus(rows, "inactive"); + toast.success(`Deactivated ${rows.length} vendor(s)`); + }, + }, + { + id: "export", + label: "Export", + icon: , + // 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. + id: "delete", + label: "Delete", + icon: , + variant: "destructive", + canApply: (v) => v.status === "archived", + // Destructive: confirm first (interaction/confirm pattern). Opening the + // dialog returns nothing, so keep `run` and hand the request back on + // confirm — the bar then shows its pending state and clears on success. + onClick: (rows, { run }) => setPendingDelete({ rows, run }), + }, + ]; + + const table = useDataTable({ + columns, + data, + loading: false, + control, + // `selectionActions` alone turns on the checkbox column. + selectionActions, + }); + + // 🧪 Dummy Data: a fake slow delete request. + const deleteVendors = async (rows: Vendor[]) => { + await new Promise((resolve) => setTimeout(resolve, 900)); + const ids = new Set(rows.map((row) => row.id)); + setVendors((prev) => prev.filter((v) => !ids.has(v.id))); + toast.error(`Deleted ${rows.length} vendor(s)`); + }; + + const confirmDelete = () => { + if (!pendingDelete) return; + pendingDelete.run(deleteVendors(pendingDelete.rows)); + setPendingDelete(null); + }; + + return ( + + + +
+

+ 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. +

+ {/* 🧪 Showcase control */} +
+ + +
+
+ + + + + + + + + + + + + + + + + + + { + 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 + + + + +
+
+ ); +}; + +DataTableSelectionPage.appShellPageProps = { + meta: { + title: "Bulk actions", + 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 82218a12e..e29ab4a89 100644 --- a/examples/vite-app/src/routes.generated.ts +++ b/examples/vite-app/src/routes.generated.ts @@ -32,6 +32,7 @@ export type GeneratedRouteParams = { "/showcase/csv-importer": {}; "/showcase/data-table": {}; "/showcase/data-table-lab": {}; + "/showcase/data-table-selection": {}; "/showcase/date-picker": {}; "/showcase/document-progress": {}; "/showcase/dropdown": {}; 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..140dd667a 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,18 +60,40 @@ 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 / clearSelection are undefined when onSelectionChange is not provided + // toggleRowSelection / selectAllRows / deselectAllRows / clearSelection are undefined + // 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; /** - * 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; + /** Id of the selection action whose request is in flight, or `null`. */ + pendingActionId?: string | null; + /** See `UseDataTableReturn.runSelectionAction`. */ + runSelectionAction?: ( + action: { id: string; keepSelection?: boolean }, + request: PromiseLike, + ) => 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..1e11f5ec6 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,117 @@ 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"]); + }); + }); + + // ------------------------------------------------------------------------- + // 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); + }); }); // ------------------------------------------------------------------------- diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index 97271cde7..f042aae5c 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) @@ -613,11 +619,16 @@ 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, + deselectAllRows: value.deselectAllRows, clearSelection: value.clearSelection, + pendingActionId: value.pendingActionId, + runSelectionAction: value.runSelectionAction, isAllSelected: value.isAllSelected, isIndeterminate: value.isIndeterminate, expandedIds: value.expandedIds, @@ -637,11 +648,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", @@ -909,6 +924,7 @@ function DataTableHeaders({ className: headerClassName }: { className?: string } setPin, toggleRowSelection, selectAllRows, + deselectAllRows, clearSelection, isAllSelected, isIndeterminate, @@ -962,7 +978,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")} @@ -1696,7 +1714,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 ( , 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 + )}
); } @@ -1904,6 +1964,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/index.ts b/packages/core/src/components/data-table/index.ts index 27c3ad969..bc72bb919 100644 --- a/packages/core/src/components/data-table/index.ts +++ b/packages/core/src/components/data-table/index.ts @@ -23,7 +23,10 @@ export type { MetadataFieldOptions, MoneyCellOptions, NumberCellOptions, + DataTableAction, RowAction, + SelectionAction, + SelectionActionHelpers, UseDataTableOptions, UseDataTableReturn, } from "./types"; 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..594f1a1fa --- /dev/null +++ b/packages/core/src/components/data-table/selection-bar.test.tsx @@ -0,0 +1,518 @@ +import { afterEach, describe, it, expect, vi } from "vitest"; +import { act, 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"]')!; + +/** 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 }); + + 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", + canApply: (v) => v.status === "inactive", + onClick: vi.fn(), + }, + { + id: "deactivate", + label: "Deactivate", + canApply: (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 `canApply`: 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", canApply: (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", + canApply: (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()); + }); + + // --------------------------------------------------------------------------- + // 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); + }); + + it("disables Clear while an action is pending", () => { + 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(within(getBar()).getByRole("button", { name: "Clear selection" })).toHaveProperty( + "disabled", + true, + ); + }); + + it("keeps the pending state when the bar unmounts and remounts mid-request", async () => { + 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" })); + // Untick the only selected row — the bar unmounts — then tick it again. + fireEvent.click(rowCheckbox(0)); + expect(queryBar()).toBeNull(); + fireEvent.click(rowCheckbox(0)); + + expect(getBar().getAttribute("aria-busy")).toBe("true"); + expect(within(getBar()).getByRole("button", { name: "Activate" })).toHaveProperty( + "disabled", + true, + ); + + await act(async () => { + pending.resolve(); + await pending.promise; + }); + expect(queryBar()).toBeNull(); + }); + + it("tracks a request handed back through run, e.g. from a confirm dialog", async () => { + const onSelectionChange = vi.fn(); + const pending = deferred(); + let confirm: (() => void) | undefined; + const actions: SelectionAction[] = [ + { + id: "delete", + label: "Delete", + // Opens a "dialog": nothing is returned, the request starts later. + onClick: (_rows, { run }) => { + confirm = () => void run(pending.promise); + }, + }, + ]; + render(, { + wrapper, + }); + + fireEvent.click(rowCheckbox(0)); + fireEvent.click(within(getBar()).getByRole("button", { name: "Delete" })); + expect(getBar().hasAttribute("aria-busy")).toBe(false); + + act(() => confirm?.()); + expect(getBar().getAttribute("aria-busy")).toBe("true"); + expect(within(getBar()).getByRole("button", { name: "Clear selection" })).toHaveProperty( + "disabled", + true, + ); + + await act(async () => { + pending.resolve(); + await pending.promise; + }); + expect(queryBar()).toBeNull(); + expect(onSelectionChange).toHaveBeenLastCalledWith([]); + }); + }); +}); 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..415e86202 --- /dev/null +++ b/packages/core/src/components/data-table/selection-bar.tsx @@ -0,0 +1,209 @@ +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 { Spinner } from "@/components/spinner"; +import { Toolbar } from "@/components/toolbar"; +import { useDataTableContext, type DataTableContextValue } from "./data-table-context"; +import { useDataTableT } from "./i18n"; +import type { SelectionAction, SelectionActionHelpers } 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 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. + */ +export function DataTableSelectionBar>() { + const { + selectionActions = [], + selectedRows = [], + clearSelection, + pendingActionId = null, + runSelectionAction, + } = 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(); + }; + }, []); + + // The in-flight action is tracked by the table, not here, so the bar's + // disabled state survives it unmounting and remounting mid-request. While + // set, every action — and Clear — is disabled, so a slow request can't be + // fired twice or overlapped by another action against the same selection. + const pendingId = pendingActionId; + const busy = pendingId !== null; + + const clear = () => clearSelection?.(); + const resolved: ResolvedAction[] = selectionActions.map((action) => { + 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; + // `run` lets a handler that only opens a confirm dialog hand the request + // back later, from the dialog's confirm button, and still get the pending + // state and clear-on-success. + const helpers: SelectionActionHelpers = { + clearSelection: clear, + run: (request) => { + runSelectionAction?.(action, request); + return request; + }, + }; + const result = action.onClick(rows, helpers); + // A synchronous handler that never calls `run` owns the selection. + if (isPromiseLike(result)) runSelectionAction?.(action, result); + }; + + 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((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((resolvedAction) => { + const { action, rows, count } = resolvedAction; + return ( + run(resolvedAction)} + 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 : ""} + + ); +} diff --git a/packages/core/src/components/data-table/types.ts b/packages/core/src/components/data-table/types.ts index ddcd71af4..513ab4813 100644 --- a/packages/core/src/components/data-table/types.ts +++ b/packages/core/src/components/data-table/types.ts @@ -436,16 +436,29 @@ 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. * - * **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; + /** + * 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 @@ -477,17 +490,102 @@ 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; } +/** 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`. + */ +export interface SelectionAction< + TRow extends Record, +> extends DataTableAction { + /** + * 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. + * + * **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: SelectionActionHelpers) => void | Promise; + /** + * Keep the selection after this action's promise resolves — for actions that + * don't change the rows, such as an export. + */ + keepSelection?: boolean; +} + /** * Return type of `useDataTable` hook. */ @@ -563,13 +661,34 @@ 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. */ 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; + /** 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.test.ts b/packages/core/src/components/data-table/use-data-table.test.ts index 50af8e75d..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 @@ -615,10 +615,132 @@ 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([]); + 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); + }); + }); + // ------------------------------------------------------------------------- // 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..05d854515 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,64 +286,135 @@ 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; - const selectAllRows = onSelectionChange + // 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 = selectionEnabled ? () => { - 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 Map(selectionRef.current); + for (const row of rows) { + const id = getRowId(row); + if (id !== null) next.set(id, row); + } + commitSelection(next); } : undefined; - const clearSelection = onSelectionChange + const deselectAllRows = selectionEnabled ? () => { - const empty = new Set(); - selectedRowIdsRef.current = empty; - setSelectedRowIds(empty); - onSelectionChange([]); + const next = new Map(selectionRef.current); + for (const row of rows) { + const id = getRowId(row); + if (id !== null) next.delete(id); + } + commitSelection(next); } : undefined; - const selectedIds = useMemo(() => [...selectedRowIds], [selectedRowIds]); + // 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; + + // 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(() => { + 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 = @@ -350,9 +422,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 @@ -463,11 +535,16 @@ export function useDataTable< control: control as CollectionControl | undefined, onClickRow, rowActions, + selectionActions, selectedIds, + selectedRows, isRowSelected, toggleRowSelection, selectAllRows, + deselectAllRows, clearSelection, + pendingActionId, + runSelectionAction, isAllSelected, isIndeterminate, expandedIds: expandedIdsList, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dd96cbb11..9c9783153 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -301,7 +301,10 @@ export { type DataTableData, type DataTableFilterConfig, type HeaderRenderContext, + type DataTableAction, type RowAction, + type SelectionAction, + type SelectionActionHelpers, type UseDataTableOptions, type UseDataTableReturn, type MetadataFieldOptions,