Skip to content
Draft
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.
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.
145 changes: 145 additions & 0 deletions e2e/components/DataTableBulkActions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
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} @avt`, async ({page}) => {
await visit(page, {
id: storyId,
globals: {colorScheme: theme},
})
await expect(page).toHaveNoViolations()
})
}

test('bulk actions replace column labels beside the header checkbox @avt', 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 @avt', 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 @avt', 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}) @avt`, 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'})
await expect(actions.getByRole('button')).toHaveCount(2)
const lastAction = await actions.getByRole('button', {name: 'Delete selected'}).boundingBox()
// 8px padding plus the 1px header border.
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 @avt', 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})
})
})
30 changes: 30 additions & 0 deletions packages/react/src/DataTable/DataTable.accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,34 @@ its header ID to selection cells' `headers`; group headings have no checkbox.
Examples: `WithRowSelection` in [Table features](./Table.features.stories.tsx)
and `WithPaginatedRowSelection` in [DataTable features](./DataTable.features.stories.tsx).

## 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 labels remain available to assistive
technology, but 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. 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.
See `WithBulkActions` in [DataTable features](./DataTable.features.stories.tsx).

## Verification

- [Grouping tests](./__tests__/DataTableGrouping.test.tsx) cover mixed body
Expand All @@ -268,6 +296,8 @@ and `WithPaginatedRowSelection` in [DataTable features](./DataTable.features.sto
spanning headers, accessible names, and nested-table isolation.
- [Selection tests](./__tests__/DataTableSelection.test.tsx) cover controlled
updates, disabled rows, focus, and hydration.
- [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 @@ -120,6 +123,21 @@
"type": "(row: Data) => boolean",
"description": "Determine which rows can be selected and counted by select-all."
},
{
"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>",
"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 @@ -169,6 +187,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
69 changes: 69 additions & 0 deletions packages/react/src/DataTable/DataTable.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1772,6 +1772,75 @@ 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}
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