Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/data-table-bulk-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

DataTable: Add bulk actions in the table header with selection counts and focus recovery.
5 changes: 5 additions & 0 deletions .changeset/table-empty-group-corners.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Table: Preserve bottom corners when the final row group is empty.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
143 changes: 143 additions & 0 deletions e2e/components/DataTableBulkActions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import {test, expect} from '@playwright/test'
import {visit} from '../test-helpers/storybook'
import {themes} from '../test-helpers/themes'

const storyId = 'experimental-components-datatable-features--with-bulk-actions'

test.describe('DataTable bulk actions', () => {
for (const theme of themes) {
test(`${theme} @aat`, async ({page}) => {
await visit(page, {
id: storyId,
globals: {colorScheme: theme},
})
await expect(page).toHaveNoViolations()
})
}

test('bulk actions replace column labels beside the header checkbox @aat', async ({page}) => {
await visit(page, {id: storyId})
const selectAll = page.getByRole('checkbox', {name: 'Select rows', exact: true})
await selectAll.focus()
await page.keyboard.press('Space')
await expect(selectAll).toBeFocused()
const actions = page.getByRole('table').getByRole('group', {name: 'Bulk actions'})
await expect(page.getByRole('button', {name: 'Repository', exact: true})).toHaveCount(0)
await expect(page.getByRole('columnheader', {name: 'Repository', exact: true})).toHaveCount(1)
await expect(actions.getByText('3 selected')).toBeVisible()
await expect(actions.getByRole('button', {name: 'Clear selection'})).toHaveCount(0)
const actionsBox = await actions.boundingBox()
const headerBox = await selectAll.boundingBox()
expect(headerBox!.y).toBeGreaterThanOrEqual(actionsBox!.y)
expect(headerBox!.y + headerBox!.height).toBeLessThanOrEqual(actionsBox!.y + actionsBox!.height)
expect(headerBox!.x + headerBox!.width).toBeLessThan(actionsBox!.x)

await page.keyboard.press('Tab')
await expect(actions.getByRole('button', {name: 'Make internal'})).toBeFocused()
await page.keyboard.press('Tab')
await expect(actions.getByRole('button', {name: 'Delete selected'})).toBeFocused()
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Space')
await expect(selectAll).toBeFocused()
await expect(actions).toHaveCount(0)
await expect(page.getByRole('button', {name: 'Repository', exact: true})).toBeVisible()
await expect(page).toHaveNoViolations()
})

test('deletion and final-record focus @aat', async ({page}) => {
await visit(page, {id: storyId})
await page.getByRole('button', {name: 'Delete selected'}).click()
await expect(page.getByRole('rowheader', {name: 'primer/react', exact: true})).toHaveCount(0)
const selectAll = page.getByRole('checkbox', {name: 'Select rows', exact: true})
await expect(selectAll).toBeFocused()

await selectAll.click()
await page.getByRole('button', {name: 'Delete selected'}).click()
await expect(page.getByRole('rowheader')).toHaveCount(0)
await expect(page.getByRole('button', {name: 'Reset repositories'})).toBeFocused()
await expect(selectAll).toBeDisabled()
await expect(page).toHaveNoViolations()
})

test('makes only selected repositories internal @aat', async ({page}) => {
await visit(page, {id: storyId})
await page.getByRole('button', {name: 'Make internal'}).click()
await expect(page.getByRole('row', {name: /primer\/react/}).getByRole('cell', {name: 'Internal'})).toBeVisible()
await expect(page.getByRole('row', {name: /primer\/css/}).getByRole('cell', {name: 'Public'})).toBeVisible()
await expect(page.getByRole('checkbox', {name: 'Select rows', exact: true})).toBeFocused()
await expect(page.getByRole('group', {name: 'Bulk actions'})).toHaveCount(0)
})

for (const density of ['condensed', 'normal', 'spacious'] as const) {
test(`preserves header height, typography and alignment (${density}) @aat`, async ({page}) => {
await visit(page, {id: storyId})
const table = page.getByRole('table')
await table.evaluate((element, value) => {
element.setAttribute('data-cell-padding', value)
}, density)
await page.getByRole('button', {name: 'Reset repositories'}).click()
const head = table.locator('thead')
const before = await head.boundingBox()
const labelBefore = await page.getByRole('button', {name: 'Repository', exact: true}).boundingBox()
const columnStyle = await page.getByRole('columnheader', {name: 'Repository', exact: true}).evaluate(element => {
const style = getComputedStyle(element)
return [style.fontFamily, style.fontSize, style.fontWeight, style.lineHeight, style.color]
})

await page.getByRole('checkbox', {name: 'Select rows', exact: true}).click()
const count = table.getByText('3 selected', {exact: true})
await expect(count).toBeVisible()
const selected = await head.boundingBox()
expect(selected!.height).toBe(before!.height)
expect((await count.boundingBox())!.x).toBe(labelBefore!.x)
const actions = table.getByRole('group', {name: 'Bulk actions'})
const lastAction = await actions.getByRole('button', {name: 'Delete selected'}).boundingBox()
expect(selected!.x + selected!.width - lastAction!.x - lastAction!.width).toBe(9)
expect(
await count.evaluate(element => {
const style = getComputedStyle(element)
return [style.fontFamily, style.fontSize, style.fontWeight, style.lineHeight, style.color]
}),
).toEqual(columnStyle)

await page.getByRole('checkbox', {name: 'Select rows', exact: true}).click()
expect((await head.boundingBox())!.height).toBe(before!.height)
})
}

for (const theme of ['light', 'dark'] as const) {
test(`mixed ${theme} @vrt`, async ({page}) => {
await visit(page, {
id: storyId,
globals: {colorScheme: theme},
})
await expect(page).toHaveScreenshot(`DataTable.BulkActions.Mixed.${theme}.png`)
})
}

test('header actions remain reachable in a narrow scrolling table with text-spacing overrides @aat', async ({
page,
}) => {
await page.setViewportSize({width: 320, height: 256})
await visit(page, {id: storyId})
await page.addStyleTag({
content:
'* {line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important;} p {margin-bottom: 2em !important;}',
})
const actions = page.getByRole('group', {name: 'Bulk actions'})
await expect(actions.getByText('1 selected')).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320)
const selectAll = page.getByRole('checkbox', {name: 'Select rows', exact: true})
await selectAll.focus()
await page.keyboard.press('Tab')
await expect(actions.getByRole('button', {name: 'Make internal'})).toBeFocused()
await page.keyboard.press('Tab')
const deleteButton = actions.getByRole('button', {name: 'Delete selected'})
await expect(deleteButton).toBeFocused()
await expect(deleteButton).toBeInViewport({ratio: 1})
await page.keyboard.press('Enter')
await expect(selectAll).toBeFocused()
await expect(selectAll).toBeInViewport({ratio: 1})
})
})
33 changes: 33 additions & 0 deletions packages/react/src/DataTable/DataTable.accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,37 @@ row count. The generated count description is currently English.
disclosure. Focus outside the group is left alone.
- Collapse does not clear selection or change its scope.

## Bulk actions

With `rowSelection`, `renderBulkActions` receives effective `selectedRows`,
`selectedCount`, and `clearSelection`. Use these IDs for operations, not the
unfiltered controlled selection. `clearSelection` requests an empty selection,
including retained IDs; controlled consumers apply `onSelectionChange`.

Actions occupy the table header beside select-all, replacing the visible column
labels while rows are selected. Column names remain available to assistive
technology, but custom header controls are hidden and sorting controls are removed
until selection is cleared. The
named action group uses native Tab/Shift+Tab navigation. Count changes are
announced, including zero. Use `bulkActionsProps`
to localize the group name and count label. The header checkbox toggles selection;
there is no separate clear button. Use small action buttons to fit the header.

Showing actions preserves checkbox focus. When clearing selection or updating
rows removes the focused action, `DataTable` restores focus to select-all, or to
the table when no selectable rows remain. `bulkActionsReturnFocusRef` overrides
that destination. This also applies when an action removes itself without updating
the table. Focus is not restored after the user leaves the actions or
moves into a consumer-owned dialog. When composing `Table.BulkActions` directly,
keep it mounted at zero and provide `onFocusReturn` for recovery.

Consumers own pending states, result announcements, and focus when closing
dialogs or replacing the table. For asynchronous operations, capture the effective
IDs at activation and reconcile only those IDs on success; preserve selection on
failure and do not clear selections added while waiting.
Collapsing a group does not exclude its selected rows from bulk actions.
See `WithBulkActions` in [DataTable features](./DataTable.features.stories.tsx).

## Verification

- [Grouping tests](./__tests__/DataTableGrouping.test.tsx) cover mixed body
Expand All @@ -283,6 +314,8 @@ row count. The generated count description is currently English.
updates, disabled rows, focus, and hydration.
- [Expansion tests](./__tests__/DataTableExpansion.test.tsx) cover disclosure
semantics, focus recovery, hidden members, and selection compatibility.
- [Bulk-action tests](./__tests__/DataTableBulkActions.test.tsx) cover effective
IDs/counts, announcements, controlled clearing, focus recovery and hydration.
- [DataTable browser tests](../../../../e2e/components/DataTable.test.ts) cover
themed axe checks, visual snapshots, and a group split across pages.
- [Table browser tests](../../../../e2e/components/Table.test.ts) cover explicit
Expand Down
50 changes: 50 additions & 0 deletions packages/react/src/DataTable/DataTable.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@
{
"id": "experimental-components-datatable-features--with-row-selection"
},
{
"id": "experimental-components-datatable-features--with-bulk-actions"
},
{
"id": "experimental-components-datatable-features--with-grouped-row-selection"
},
Expand Down Expand Up @@ -144,6 +147,21 @@
"type": "({ expandedGroups }: { expandedGroups: Set<DataTableRowId> }) => void",
"description": "Receive a new expansion set on a disclosure request. Sorting and data replacement do not emit this callback or clear stored IDs."
},
{
"name": "renderBulkActions",
"type": "(context: DataTableBulkActionsContext) => React.ReactNode",
"description": "Render actions in place of visible column labels when rowSelection is enabled and selectable rows are selected. Receives selectedRows IDs, selectedCount, and clearSelection, excluding missing and non-selectable rows."
},
{
"name": "bulkActionsProps",
"type": "Pick<TableBulkActionsProps, 'aria-label' | 'getSelectedCountLabel'>",
"description": "Localize the bulk-action group name and selected-count announcement (including zero)."
},
{
"name": "bulkActionsReturnFocusRef",
"type": "React.RefObject<HTMLElement | null>",
"description": "Override focus recovery when a focused action disappears. Defaults to select-all, or the table when no selectable rows remain. Does not override focus already moved by the consumer."
},
{
"name": "cellPadding",
"type": "'condensed' | 'normal' | 'spacious'",
Expand Down Expand Up @@ -193,6 +211,38 @@
}
],
"subcomponents": [
{
"name": "Table.BulkActions",
"props": [
{
"name": "selectedCount",
"type": "number",
"required": true,
"description": "The effectively selected row count. Keep the component mounted at zero so count announcements and focus recovery can complete."
},
{
"name": "onFocusReturn",
"type": "() => void",
"description": "Choose the recovery destination when a focused action disappears and focus has not moved elsewhere."
},
{
"name": "getSelectedCountLabel",
"type": "(selectedCount: number) => string",
"description": "Localize the visible and announced selected count, including zero."
},
{
"name": "aria-label",
"type": "string",
"defaultValue": "'Bulk actions'",
"description": "Name the action group."
},
{
"name": "children",
"type": "React.ReactNode",
"description": "Consumer-owned action elements in native Tab order, rendered only when selectedCount is positive."
}
]
},
{
"name": "Table",
"props": [
Expand Down
70 changes: 70 additions & 0 deletions packages/react/src/DataTable/DataTable.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1773,6 +1773,76 @@ export const WithRowSelection = () => {
)
}

export const WithBulkActions = () => {
const repositories = [
{id: 1, name: 'primer/react', visibility: 'Public'},
{id: 2, name: 'primer/css', visibility: 'Public'},
{id: 3, name: 'github/github', visibility: 'Internal'},
]
const titleId = React.useId()
const [rows, setRows] = React.useState(repositories)
const [selectedRows, setSelectedRows] = React.useState<ReadonlySet<DataTableRowId>>(() => new Set([1]))
const resetButtonRef = React.useRef<HTMLButtonElement>(null)

return (
<Table.Container>
<Table.Title id={titleId}>Repositories</Table.Title>
<Table.Actions>
<Button
ref={resetButtonRef}
onClick={() => {
setRows(repositories)
setSelectedRows(new Set())
}}
>
Reset repositories
</Button>
</Table.Actions>
<DataTable
aria-labelledby={titleId}
data={[
...rows.filter(row => row.visibility === 'Internal'),
{groupId: 'public', label: 'Public', rows: rows.filter(row => row.visibility === 'Public')},
]}
columns={[
{header: 'Repository', field: 'name', rowHeader: true, sortBy: 'alphanumeric'},
{header: 'Visibility', field: 'visibility'},
]}
rowSelection
selectedRows={selectedRows}
onSelectionChange={({selectedRows: next}) => setSelectedRows(next)}
bulkActionsReturnFocusRef={rows.length === 0 ? resetButtonRef : undefined}
collapsibleGroups
renderBulkActions={({selectedRows: effectiveIds, clearSelection}) => (
<>
<Button
size="small"
onClick={() => {
setRows(current =>
current.map(row => (effectiveIds.has(row.id) ? {...row, visibility: 'Internal'} : row)),
)
clearSelection()
}}
>
Make internal
</Button>
<Button
size="small"
variant="danger"
onClick={() => {
setRows(current => current.filter(row => !effectiveIds.has(row.id)))
clearSelection()
}}
>
Delete selected
</Button>
</>
)}
/>
</Table.Container>
)
}

const repoGroupsWithDisabledRow = repoGroups.map(group => ({
...group,
rows: group.rows.map(repo => (repo.id === 1 ? {...repo, name: `${repo.name} (not selectable)`} : repo)),
Expand Down
Loading
Loading