Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/quiet-footers-rise.md
Original file line number Diff line number Diff line change
@@ -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.
69 changes: 69 additions & 0 deletions decisions/data-table-selection-footer-actions.md
Original file line number Diff line number Diff line change
@@ -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 `<Layout fill>` 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: <Play />,
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.
27 changes: 15 additions & 12 deletions docs-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@
"CollectionVariables",
"Column",
"DataTable",
"DataTableAction",
"DataTableContextValue",
"DataTableData",
"DataTableFilterConfig",
Expand All @@ -421,6 +422,8 @@
"PaginationVariables",
"RowAction",
"SelectOption",
"SelectionAction",
"SelectionActionHelpers",
"SortConfig",
"SortState",
"TableFieldName",
Expand All @@ -443,10 +446,10 @@
"withURLCollectionState"
],
"hashes": {
"typeSurface": "014a9036a3b9b0af",
"outline": "f1bea006bdeb43a4",
"typeSurface": "fe43108015be4a00",
"outline": "bc26d54f88f9cf37",
"snapshot": null,
"outputMd": "dd2da884d7550daf",
"outputMd": "97ae28f7a98c5d97",
"examples": null
}
},
Expand Down Expand Up @@ -927,10 +930,10 @@
"symbols": [],
"hashes": {
"typeSurface": null,
"outline": "cf74b5f060b145d1",
"outline": "8d704967c8c882fd",
"snapshot": null,
"outputMd": "09e99e5035da6e81",
"examples": "48e0d4327812dfde"
"outputMd": "770b33ada6b9317f",
"examples": "04c7dc51378c04c4"
}
},
"interaction-toast": {
Expand Down Expand Up @@ -995,9 +998,9 @@
"symbols": [],
"hashes": {
"typeSurface": null,
"outline": "165e45ba5f128d96",
"outline": "68653902d2c9f861",
"snapshot": null,
"outputMd": "8062ea0df9403fbb",
"outputMd": "916baa3cd2b9cbf1",
"examples": "c0a2bd60e8a04474"
}
},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
Loading
Loading