Repository navigation
feat(data-table): bulk selection actions in the footer - #496
Conversation
Adds `/data-table-selection` to the vite example: 240 vendor records with a checkbox column, and a footer that swaps its row-count text for a bulk-action bar while a selection is open. The bar reuses the same `DataTable.Pagination` in both states, so the right-hand cluster is identical whether or not rows are selected. Inversion is done by re-pointing the surface tokens on a wrapper inside the footer, so the pagination buttons, the page-size Select and the action Buttons re-theme themselves without any class overrides. Prototype only — nothing under packages/** changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Code Metrics Report
Details | | main (ecc3f93) | #496 (75cb5da) | +/- |
|---------------------|----------------|----------------|------|
| Coverage | 87.1% | 87.1% | 0.0% |
| Files | 178 | 178 | 0 |
| Lines | 5467 | 5467 | 0 |
| Covered | 4764 | 4764 | 0 |
+ | Test Execution Time | 2m7s | 2m3s | -4s |Reported by octocov |
…t or as a pattern Records the footer direction for multi-select bulk actions, notes that `catalogue/src/pattern/interaction/multi-select` currently prescribes a floating bottom bar (built on raw Table.Root, predating DataTable selection) and so needs rewriting either way, and frames the open question for a team call: build the bar into DataTable via `selectionActions`, or keep it a documented pattern. No decision taken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a473622 to
9a6439b
Compare
Brings the prototype up to date with main (58 commits, incl. the generic Toolbar #559 and the docs-src pipeline #396). main moved every demo page under pages/showcase/ (#513), so the prototype moves with them, unchanged: /data-table-selection -> /showcase/data-table-selection, listed in the sidebar's Showcase group. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…kbox Selection persists across pages, but the header checkbox did not respect that: checking it replaced the selection with the current page's rows, and unchecking it cleared every page. With 5 rows picked on page 1, select-all on page 2 gave 25 selected instead of 30. The header checkbox is now page-scoped in both directions. selectAllRows adds the page's rows to the selection, and a new deselectAllRows removes only the page's rows; clearSelection still empties everything. deselectAllRows is optional on DataTableContextValue, and a hand-built context without it falls back to clearSelection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oss pages Adds the data half of footer bulk actions (#1738): a selectionActions option on useDataTable, shaped like rowActions, and the selected rows it acts on. The footer bar that renders these actions follows in the next commit. - SelectionAction: id, label, icon, variant, and an optional appliesTo(row) predicate that narrows an action to the selected rows it applies to. onClick receives those rows plus a clearSelection helper. - A non-empty selectionActions array enables row selection on its own, so onSelectionChange stays optional. - selectedRows: selection is now an ordered map of id -> row as last seen, so rows picked on other pages can still be counted and handed to an action. Rows on the current page always win over the remembered copy. selectedRows is required on UseDataTableReturn, like expandedIds, and optional on DataTableContextValue, which is documented as hand-constructible. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rows are selected With selectionActions set and at least one row selected, the footer becomes the bulk-action bar (#1738): the count, the actions, and Clear, with the footer's own children (usually Pagination) kept alongside. Tables without selectionActions render exactly as before. - Built on the generic Toolbar (#559): the bar is a Toolbar.Row, so it gets role=toolbar and Arrow/Home/End navigation between its buttons. - The first three actions render as buttons; the rest collapse into a "More actions" menu that opens upward. An action with appliesTo shows its eligible count ("Activate (6)") and disables at zero. - Surface: bg-accent, which stays soft in every theme and both modes. A display:contents wrapper re-points --accent and --muted-foreground so hover states and secondary text stay visible on the tinted bar. - Sticky while open, so on a page-scrolling table the bar rides the bottom of the viewport until the table's end scrolls into view. The DataTable root moves from overflow-hidden to overflow-clip for this: both clip to the rounded frame, but hidden also makes the root a scroll container, which trapped the sticky footer inside the table. - Pagination hides its own "N selected" text while the bar is up and shares the line with it, dropping to a second line only when it doesn't fit. - A persistent polite live region announces the count from the first tick; when the bar closes with focus inside it, focus returns to the header checkbox. Labels in en and ja. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/showcase/data-table-selection was the #496 prototype: a hand-built footer bar on useDataTableContext with a primary/neutral tone toggle. It now uses the real API, so the page shows exactly what the core component does: - selectionActions with appliesTo counts, Activate / Deactivate / Export inline and Delete in the More actions menu behind a confirm dialog (interaction/confirm) - actions mutate local state, so counts and badges update after an action - the generic Toolbar scaffold on top, matching the other DataTable demos - a Pinned footer / Page scroll switch to compare <Layout fill> with a page- scrolling table, where the bar rides the bottom of the window dashboard/products drops its ad-hoc "Selected: …" line for Publish / Archive / Delete bulk actions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lect pattern - DataTable docs: a Selection actions section (with accessibility notes), a SelectionAction reference table, the selectionActions option, and the footer/pagination and useDataTableContext notes that go with it. - interaction/multi-select: rewritten around selectionActions. The floating bar on a hand-built Table.Root with native checkboxes is gone, along with its dangling source marker; the example is a DataTable with an appliesTo action, an overflowed destructive action, and a confirm dialog. - list/dense-scan: bulk actions point at the footer bar. - decisions: #496's open question is now a decided record — built into DataTable, what changed from the first sketch (appliesTo instead of count, rows instead of ids, accent instead of primary/neutral), and follow-ups. - changeset: minor, calling out the page-scoped header checkbox. Generated docs/ and the manifest come from pnpm docs:sync. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
A few things from testing this locally, using the showcase page with simulated async paging: 1. Collapse actions into "⋯" based on width 2. A common base type for
Consumers will end up writing the same "Activate" action twice with inverted predicates. The callbacks will reasonably differ, since one record and many records need different handling. But the display fields and the per-row eligibility check ( 3. Scroll behaviour from 4. Post-action state, unless the consumer clears the selection themselves
|
…election actions Addresses Sean's review of #496. Naming and polarity (review point 2), settled before it ships: - appliesTo is renamed canApply and moves into DataTableAction, a new base type with id / label / icon / variant / canApply that both RowAction and SelectionAction extend. One definition can be spread into rowActions and selectionActions; only onClick differs. - RowAction gains canApply, so a row action is disabled where it returns false. isDisabled, which reads the other way round, is deprecated but still honoured: either one can switch the action off. Post-action state (review point 4): - An onClick that returns a promise is waited on. While it is pending, every action and the More actions trigger are disabled, the running action shows a Spinner, and the bar is aria-busy, so a slow request can't be fired twice or overlapped. - When it resolves, the selection clears. The rows may have changed, and copies remembered from other pages can't refresh, so keeping them handed stale rows to the next action and left deleted rows selected. Actions that don't change rows opt out with keepSelection. A rejection keeps the selection for a retry and logs a [DataTable] error rather than rethrowing, which would surface as an unhandled rejection even when the app had already handled it. - A synchronous onClick, such as one that opens a confirm dialog, still owns the selection. - clearSelection is a no-op on an empty selection, so an action that clears itself and then resolves doesn't fire onSelectionChange([]) twice. Docs (review point 3): the Selection actions section says where the pop-out works (nearest scrolling container; it stays put inside a clipping wrapper) and covers the new behaviour. There are DataTableAction / SelectionAction / RowAction tables, and the multi-select pattern and its example use canApply and returned promises. The decision record and changeset are updated, and the demo now uses slow fake requests so the pending state is visible. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in 12 commits from main, including the 1.16.0 release and the docs-kit change that moves the root guides into docs-src/guides (#561). The only conflict was docs-manifest.json, a generated file. It was taken from main and regenerated with pnpm docs:sync. That also refreshes the snapshot hashes for dialog, menu, select and sheet, which were stale on main: #555 updated their test snapshots after the manifest was last synced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks Sean, really useful testing. Points 2–4 are addressed in 1. Width-aware overflow. Agreed, it belongs in the generic 2. Shared base and naming. Done before it ships. 3. 4. Post-action state.
The showcase now uses slow fake requests, so the pending state is visible. |
|
Thanks, 2 and 3 look good. Two remaining gaps in the pending handling: Clear stays active while an action is pending. We should disable the Clear button while an async action is pending. Right now it stays active, and pressing it undoes the disabled state on the action buttons. The cause is that the pending state lives in the bar: when the selection becomes empty the bar unmounts, and a remounted bar has forgotten the pending action. Emptying the selection by unticking rows has the same effect, which is probably an edge case we can live with, but we should close off the obvious route. The confirm-dialog path gets none of the new handling. For delete, |
docs-manifest.json was the only conflict. It was taken from main and regenerated with pnpm docs:sync. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dd run() Addresses Sean's second review comment on #496. - The in-flight action now lives in useDataTable (pendingActionId / runSelectionAction) instead of the footer bar. Emptying the selection unmounts the bar, and a remounted bar used to forget the pending request and re-enable its actions. The busy state now survives that. - Clear is disabled while a request is pending, which closes the obvious way to empty the selection mid-request. - onClick's helpers gain run(request), exported as SelectionActionHelpers. An action that only opens a confirm dialog keeps run and passes it the request from the dialog's confirm button. The bar then gives it the same pending state and clear-on-success as a returned promise. The showcase delete, the multi-select pattern example, the docs and the changeset use it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks Sean, both are in Clear while pending. Clear is now disabled while a request is pending. I also moved the pending state out of the bar and into Confirm-dialog path. Went with your Also merged the latest main. Ready for another look. Width-aware overflow in |
Resolve docs-manifest.json conflict: keep the PR's data-table hash and main's date-picker hash. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What this does
Adds bulk actions to
DataTable(tailor-inc/platform-planning#1738). PassselectionActionstouseDataTableand, while rows are selected,DataTable.Footerbecomes a bulk-action bar: the selection count, your actions, and Clear, with pagination kept alongside. When nothing is selected, the footer looks exactly as it does today.This PR started as the prototype and open question from 3 Sep. The direction was settled with Sean in #pf-app-shell and at the 18 Sep board planning: build it into DataTable, place it in the footer, and let the consumer control which actions appear. Sean's two review comments are addressed in
6b749f47and4382774a.API
It is one option, shaped like
rowActions. There is nothing new to compose in JSX.SelectionActionandRowActionboth extend a new base,DataTableAction, so one definition can be spread into both arrays and onlyonClickdiffers.id,label,icon,variantDataTableAction).variant: "destructive"is styling only; the app still confirms destructive actions itself (interaction/confirm).canApply(row)onClick. A row action is disabled where it returnsfalse.onClick(rows, { clearSelection, run })runand passes it the request from the dialog's confirm button —run(deleteRows(rows))— for the same handling. A synchronous handler that never callsrunowns the selection.keepSelectionNew exports:
SelectionAction,SelectionActionHelpersandDataTableAction. New on the return value and context:selectedRows(each selected row as last loaded, across pages) anddeselectAllRows(page-scoped).Behaviour
selectionActions, aDataTable.Footer, and at least one selected row.selectionActionsalso turns on the checkbox column by itself, soonSelectionChangebecomes optional.run) is pending, every action, the ⋯ trigger and Clear are disabled. The pending state is kept by the table rather than the bar, so it survives the bar unmounting and remounting mid-request. Meanwhile the running action shows a spinner, and the bar isaria-busy, so a slow request can't be fired twice. When the promise resolves, the selection clears: those rows may have changed, and copies remembered from other pages can't refresh. That fixes the stale counts and deleted-but-still-selected rows from the review. If the promise rejects, the selection stays for a retry, and a[DataTable] Selection action "…" failederror is logged.<Layout fill>the footer is already pinned. On a page-scrolling table, the bar isposition: stickywhile rows are selected, so it rides the bottom of the viewport and settles back into place at the end of the table. There is no JS. It sticks to the nearest scrolling container: inside a scrolling drawer it rides that drawer, and inside a wrapper withoverflow: hiddenit stays in the footer. The docs say so.Toolbar(feat: add generic toolbar #559). The bar is aToolbar.Row, so it getsrole="toolbar"and Arrow/Home/End navigation.accent, which stays soft in all three themes and in both light and dark mode. Hover and secondary text are re-pointed so they stay legible on the tinted bar.Compatibility
selectionActionsrender exactly as before, and a test asserts this. New context members are optional.selectedRowsis required onUseDataTableReturn, likeexpandedIdswas, so only a hand-built return value would need it.RowAction.isDisabledis deprecated in favour ofcanApply, which reads the other way round. It still works: the action is disabled if either one says so.clearSelectionstill empties everything. Tables whose selection stays on one page see no difference. The fix is its own commit (dcc9e770) so it can be split out if preferred, and the changeset calls it out.clearSelectionis now a no-op when nothing is selected, so an action that clears itself and then resolves doesn't fireonSelectionChange([])twice.overflow-hiddentooverflow-clip. Both clip the toolbar to the rounded frame, buthiddenalso makes the root a scroll container, which pinned the sticky footer inside the table.clipkeeps your clipping without that side effect.Commits
fix(data-table): the header checkbox keeps other pages' rows.feat(data-table):selectionActionsand remembered selected rows. Selection becomes an ordered map of id → row as last seen, and the current page's copy wins.feat(data-table): the footer bar (selection-bar.tsx), plus the Pagination and Footer changes and en/ja labels.feat(examples):/showcase/data-table-selectionmoves onto the real API, and/dashboard/productsreplaces its "Selected: …" line with bulk actions.docs(data-table): docs-src sections;interaction/multi-selectis rewritten aroundselectionActions;list/dense-scanis updated;decisions/data-table-selection-footer-actions.mdis now Decided; and a minor changeset.feat(data-table), from review:canApplyandDataTableAction,RowAction.canApply, promise-aware actions withkeepSelection, and matching docs, pattern, demo and tests.feat(data-table), from the second review: the pending state moves intouseDataTable(pendingActionId/runSelectionAction); Clear is disabled while pending;run(request)is added to the helpers (SelectionActionHelpers) so a confirm dialog can hand its request back. The demo delete, pattern example and docs use it.The rest are the original prototype commits and two merges of
main. The latest merge, after 1.16.0, regenerateddocs-manifest.json, and that also refreshes snapshot hashes for dialog/menu/select/sheet, which #555 had left stale on main.Worth discussing
canApply(row)replaces the consumer-suppliedcountfrom the first sketch. Selection spans pages, and an app with server pagination can't count rows on other pages without its own cache; the table already sees every row the user selects. It was first writtenappliesToand was renamed in review before shipping.rowSelection. With a controlled selection, rows that haven't loaded yet are counted once they load; this is documented. Whichever lands second adapts, and the row memory just goes wherever selection is written.corner-shape: squircle(since feat(themes): theme foundation with mode/palette axes and AppearanceSwitcher #306) oroverflow: clip. It's being looked into separately, and if it'soverflow: clip, it gets fixed here before merge.Follow-ups (not in this PR)
Toolbar, so every toolbar gets it; to be done with Seiya.DataTable.SelectionActionsfor custom placement, following the "option = default placement, sub-component = custom placement" rule from tailor-inc/platform-planning#1699.Try it
/showcase/data-table-selection: 240 vendors with fake slow requests. Tick rows and click Activate to see the pending state and auto-clear, and Export to seekeepSelection. Page with a selection open, open ⋯ → Delete for the confirm flow, and use the Pinned footer / Page scroll switch./dashboard/products: oneDataTableAction("Delete") serves both the row menu and the bulk bar.Verification
Tests.
selection-bar.test.tsxhas 22 tests, 9 of them for async actions: pending state, clear-on-success,keepSelection, rejection, sync handlers, a singleonSelectionChange([]), Clear disabled while pending, the pending state surviving a remount, andrunfrom a confirm handler. There are also row-action tests (canApply, deprecatedisDisabled, a shared definition), hook tests (cross-page selection,selectedRows,selectionActions) and a header-checkbox test. 2,058 core tests pass after merging the latest main.Checks.
pnpm fmt:check,pnpm exec turbo run lint test type-check check-dts(28/28),pnpm docs:check, and docs examples compile against core.In the browser, on the example app's theme (bloom) in light and dark mode, at 1440px and a narrower ~1000px window:
canApply.Not yet checked on the default and cream themes.
🤖 Generated with Claude Code