diff --git a/.changeset/data-table-bulk-actions.md b/.changeset/data-table-bulk-actions.md new file mode 100644 index 00000000000..c37050478b3 --- /dev/null +++ b/.changeset/data-table-bulk-actions.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +DataTable: Add bulk actions in the table header with selection counts and focus recovery. diff --git a/.playwright/snapshots/components/DataTableBulkActions.test.ts-snapshots/DataTable-BulkActions-Mixed-dark-linux.png b/.playwright/snapshots/components/DataTableBulkActions.test.ts-snapshots/DataTable-BulkActions-Mixed-dark-linux.png new file mode 100644 index 00000000000..24cce1f7f8c Binary files /dev/null and b/.playwright/snapshots/components/DataTableBulkActions.test.ts-snapshots/DataTable-BulkActions-Mixed-dark-linux.png differ diff --git a/.playwright/snapshots/components/DataTableBulkActions.test.ts-snapshots/DataTable-BulkActions-Mixed-light-linux.png b/.playwright/snapshots/components/DataTableBulkActions.test.ts-snapshots/DataTable-BulkActions-Mixed-light-linux.png new file mode 100644 index 00000000000..5e162e365ec Binary files /dev/null and b/.playwright/snapshots/components/DataTableBulkActions.test.ts-snapshots/DataTable-BulkActions-Mixed-light-linux.png differ diff --git a/e2e/components/DataTableBulkActions.test.ts b/e2e/components/DataTableBulkActions.test.ts new file mode 100644 index 00000000000..576873dfcc5 --- /dev/null +++ b/e2e/components/DataTableBulkActions.test.ts @@ -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}) + }) +}) diff --git a/packages/react/src/DataTable/DataTable.accessibility.md b/packages/react/src/DataTable/DataTable.accessibility.md index dfb464ab5ce..da758a375fe 100644 --- a/packages/react/src/DataTable/DataTable.accessibility.md +++ b/packages/react/src/DataTable/DataTable.accessibility.md @@ -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 @@ -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 diff --git a/packages/react/src/DataTable/DataTable.docs.json b/packages/react/src/DataTable/DataTable.docs.json index 8688354566e..c0a07520795 100644 --- a/packages/react/src/DataTable/DataTable.docs.json +++ b/packages/react/src/DataTable/DataTable.docs.json @@ -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" }, @@ -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", + "description": "Localize the bulk-action group name and selected-count announcement (including zero)." + }, + { + "name": "bulkActionsReturnFocusRef", + "type": "React.RefObject", + "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'", @@ -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": [ diff --git a/packages/react/src/DataTable/DataTable.features.stories.tsx b/packages/react/src/DataTable/DataTable.features.stories.tsx index b66db41a2d0..97c71e99c7e 100644 --- a/packages/react/src/DataTable/DataTable.features.stories.tsx +++ b/packages/react/src/DataTable/DataTable.features.stories.tsx @@ -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>(() => new Set([1])) + const resetButtonRef = React.useRef(null) + + return ( + + Repositories + + + + 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}) => ( + <> + + + + )} + /> + + ) +} + const repoGroupsWithDisabledRow = repoGroups.map(group => ({ ...group, rows: group.rows.map(repo => (repo.id === 1 ? {...repo, name: `${repo.name} (not selectable)`} : repo)), diff --git a/packages/react/src/DataTable/DataTable.tsx b/packages/react/src/DataTable/DataTable.tsx index 36aa8daf354..0741170863d 100644 --- a/packages/react/src/DataTable/DataTable.tsx +++ b/packages/react/src/DataTable/DataTable.tsx @@ -1,4 +1,4 @@ -import type {ReactElement, ReactNode} from 'react' +import {useRef, type ReactElement, type ReactNode, type RefObject} from 'react' import type {Column} from './column' import {useTable} from './useTable' import type {SortDirection} from './sorting' @@ -16,6 +16,15 @@ import { TableRowSelection, } from './Table' import {TableGroup} from './TableGroup' +import {TableBulkActions, type TableBulkActionsProps} from './TableBulkActions' +import classes from './TableBulkActions.module.css' +import VisuallyHidden from '../_VisuallyHidden' + +export interface DataTableBulkActionsContext { + selectedRows: ReadonlySet + selectedCount: number + clearSelection: () => void +} // ---------------------------------------------------------------------------- // DataTable @@ -105,6 +114,23 @@ type DataTableBaseProps = { * Determines whether a row can be selected. */ isRowSelectable?: (row: Data) => boolean + + /** + * Renders actions for effectively selected rows, excluding missing and + * non-selectable IDs. Requires rowSelection. Consumers own business operations. + */ + renderBulkActions?: (context: DataTableBulkActionsContext) => ReactNode + + /** + * Localizes bulk-action labels and the selected-count announcement. + */ + bulkActionsProps?: Pick + + /** + * Overrides the focus recovery target when a focused bulk action disappears. + * Defaults to select-all, or the table when no selectable rows remain. + */ + bulkActionsReturnFocusRef?: RefObject } export type DataTableProps = DataTableBaseProps & { @@ -148,7 +174,12 @@ function DataTableImplementation({ defaultSelectedRows, onSelectionChange, isRowSelectable, + renderBulkActions, + bulkActionsProps, + bulkActionsReturnFocusRef, }: DataTableProps) { + const selectionCheckboxRef = useRef(null) + const tableRef = useRef(null) const table = useTable({ data, columns, @@ -163,6 +194,8 @@ function DataTableImplementation({ isRowSelectable, }) const {headers, bodies, actions, selection, gridTemplateColumns} = table + const hasBulkActions = Boolean(rowSelection && renderBulkActions) + const bulkActionsActive = hasBulkActions && selection.selectedCount > 0 const renderRow = (row: (typeof bodies)[number]['rows'][number]) => ( @@ -194,15 +227,18 @@ function DataTableImplementation({ return ( - + {rowSelection ? ( ({ /> ) : null} {headers.map(header => { + if (bulkActionsActive) { + return ( + + + + {typeof header.column.header === 'string' ? header.column.header : header.column.header()} + + + + ) + } if (header.isSortable()) { return ( ({ ) })} + {hasBulkActions ? ( + // Share the visual header row without putting action names in column + // headers or removing their associations with the table body. + + + { + const target = bulkActionsReturnFocusRef?.current + if (target?.isConnected) { + target.focus() + } else if (selectionCheckboxRef.current && !selectionCheckboxRef.current.disabled) { + selectionCheckboxRef.current.focus() + } else { + tableRef.current?.focus() + } + }} + > + {bulkActionsActive + ? renderBulkActions?.({ + selectedRows: new Set(selection.selectedRows), + selectedCount: selection.selectedCount, + clearSelection: actions.clearSelection, + }) + : null} + + + + ) : null} {bodies.map(body => body.type === 'row-group' ? ( diff --git a/packages/react/src/DataTable/DataTable.types.test.tsx b/packages/react/src/DataTable/DataTable.types.test.tsx index 099e5c20f65..919ef3026b8 100644 --- a/packages/react/src/DataTable/DataTable.types.test.tsx +++ b/packages/react/src/DataTable/DataTable.types.test.tsx @@ -213,6 +213,29 @@ export function shouldAcceptRowSelectionProps() { ) } +export function shouldAcceptBulkActions() { + const config = ( + { + const ids: ReadonlySet = selectedRows + // @ts-expect-error Bulk action IDs are read-only. + ids.add(3) + return ( + + ) + }} + bulkActionsProps={{getSelectedCountLabel: count => `${count} selected`}} + /> + ) + const composed = Actions + return {config, composed} +} + export function shouldInferMixedSelectionCallbacks() { return ( -function TableRow({children, ...rest}: TableRowProps) { +function TableRow({children, className, ...rest}: TableRowProps) { return ( - + {children} ) diff --git a/packages/react/src/DataTable/TableBulkActions.module.css b/packages/react/src/DataTable/TableBulkActions.module.css new file mode 100644 index 00000000000..f2027ed2431 --- /dev/null +++ b/packages/react/src/DataTable/TableBulkActions.module.css @@ -0,0 +1,55 @@ +.TableBulkActions { + &:where([data-active]) { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--base-size-8); + } +} + +.Count { + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-muted); +} + +.ColumnLabel { + /* Preserve the header's text line when its label is visually hidden. */ + min-block-size: 1lh; +} + +.Actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--base-size-8); + margin-inline-start: auto; +} + +.HeaderRow, +.HeaderRow > * { + grid-row: 1; +} + +.BulkActionsCell { + grid-row: 1; + grid-column: 2 / -1; + z-index: 1; + min-width: 0; + align-content: center; + /* stylelint-disable-next-line primer/spacing */ + padding: var(--table-cell-padding); + padding-block: 0; + background-color: var(--bgColor-muted); + border-block-start: var(--borderWidth-thin) solid var(--borderColor-default); + border-block-end: var(--borderWidth-thin) solid var(--borderColor-default); + border-inline-end: var(--borderWidth-thin) solid var(--borderColor-default); + /* stylelint-disable-next-line primer/borders */ + border-start-end-radius: var(--table-border-radius); +} + +/* This spans data columns, despite being the first cell in its DOM row. */ +.HeaderRow > .BulkActionsCell:first-child:last-child { + /* stylelint-disable-next-line primer/spacing */ + padding-inline-start: var(--cell-padding-inline, var(--base-size-12)); + padding-inline-end: var(--base-size-8); +} diff --git a/packages/react/src/DataTable/TableBulkActions.tsx b/packages/react/src/DataTable/TableBulkActions.tsx new file mode 100644 index 00000000000..1657d538da2 --- /dev/null +++ b/packages/react/src/DataTable/TableBulkActions.tsx @@ -0,0 +1,99 @@ +import React, {useRef} from 'react' +import {clsx} from 'clsx' +import {AriaStatus} from '../live-region' +import VisuallyHidden from '../_VisuallyHidden' +import useLayoutEffect from '../utils/useIsomorphicLayoutEffect' +import classes from './TableBulkActions.module.css' + +export type TableBulkActionsProps = Omit, 'role'> & { + /** + * The number of effectively selected rows. Keep this component mounted at zero + * so the count announcement and focus recovery can complete. + */ + selectedCount: number + /** + * Restores focus when a focused action disappears. Not called if focus has + * already moved elsewhere, for example into a consumer-owned dialog. + */ + onFocusReturn?: () => void + /** + * Localizes the visible and announced selection count, including zero. + */ + getSelectedCountLabel?: (selectedCount: number) => string +} + +function defaultSelectedCountLabel(selectedCount: number) { + return `${selectedCount} selected` +} + +const TableBulkActions = React.forwardRef(function TableBulkActions( + { + selectedCount, + onFocusReturn, + getSelectedCountLabel = defaultSelectedCountLabel, + 'aria-label': ariaLabel = 'Bulk actions', + className, + children, + onFocusCapture, + onBlurCapture, + ...rest + }, + ref, +) { + const focusedAction = useRef(null) + const active = selectedCount > 0 + + // Controlled updates can remove the focused action after its event has finished. + useLayoutEffect(() => { + const element = focusedAction.current + if (element && !element.isConnected) { + focusedAction.current = null + const document = element.ownerDocument + if (document.activeElement === document.body) { + onFocusReturn?.() + } + } + }) + + const count = ( + + {getSelectedCountLabel(selectedCount)} + + ) + + return ( +
{ + focusedAction.current = event.target + // Native focus scrolling can miss controls in the grid-overlaid header. + if (event.currentTarget.contains(event.target)) { + event.target.scrollIntoView({block: 'nearest', inline: 'nearest'}) + } + if (!event.defaultPrevented) { + onFocusCapture?.(event) + } + }} + onBlurCapture={event => { + // Clicking non-focusable content blurs with a null relatedTarget. + if (event.target.isConnected) { + focusedAction.current = null + } + if (!event.defaultPrevented) { + onBlurCapture?.(event) + } + }} + ref={ref} + role={active ? 'group' : undefined} + aria-label={active ? ariaLabel : undefined} + data-component="Table.BulkActions" + data-active={active || undefined} + className={clsx(classes.TableBulkActions, className)} + > + {count} + {active ?
{children}
: null} +
+ ) +}) + +export {TableBulkActions} diff --git a/packages/react/src/DataTable/__tests__/DataTableBulkActions.test.tsx b/packages/react/src/DataTable/__tests__/DataTableBulkActions.test.tsx new file mode 100644 index 00000000000..7ab819d6ec0 --- /dev/null +++ b/packages/react/src/DataTable/__tests__/DataTableBulkActions.test.tsx @@ -0,0 +1,371 @@ +import React, {act} from 'react' +import {render, screen, waitFor, within} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import {renderToString} from 'react-dom/server' +import {hydrateRoot, type Root} from 'react-dom/client' +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {DataTable, Table, type DataTableBulkActionsContext, type DataTableData, type DataTableRowId} from '..' +import {Button} from '../../Button' +import {ActionMenu} from '../../ActionMenu' +import {ActionList} from '../../ActionList' +import {getLiveRegion} from '../../live-region/__tests__/test-helpers' +import {implementsClassName} from '../../utils/testing' +import type {Column} from '../column' +import classes from '../TableBulkActions.module.css' + +interface Row { + id: DataTableRowId + name: string +} + +const rows: Array = [ + {id: 1, name: 'Beta'}, + {id: '1', name: 'Alpha'}, + {id: 3, name: 'Gamma'}, +] +const columns: Array> = [{header: 'Name', field: 'name', rowHeader: true, sortBy: true}] + +function renderActions({selectedCount, clearSelection}: DataTableBulkActionsContext) { + return +} + +describe('DataTable bulk actions', () => { + beforeEach(() => { + document.body.appendChild(document.createElement('live-region')) + }) + afterEach(() => { + getLiveRegion().remove() + }) + + it.each(['flat', 'grouped', 'mixed'] as const)('uses effective IDs and counts (%s)', async mode => { + const user = userEvent.setup() + const onAction = vi.fn() + const data: DataTableData = + mode === 'flat' + ? rows + : mode === 'grouped' + ? [{groupId: 'all', label: 'All', rows}] + : [rows[0], {groupId: 'other', label: 'Other', rows: rows.slice(1)}] + render( + ([1, '1', 3, 'missing'])} + isRowSelectable={row => row.id !== 3} + renderBulkActions={({selectedRows, selectedCount}) => ( + + )} + />, + ) + + expect(screen.getByText('2 selected')).toBeVisible() + await user.click(screen.getByRole('button', {name: 'Archive'})) + expect(onAction).toHaveBeenLastCalledWith({selectedRows: new Set([1, '1']), selectedCount: 2}) + }) + + it('preserves column names and sort state while replacing sorting controls', async () => { + const user = userEvent.setup() + render( + , + ) + expect(screen.queryByRole('button', {name: 'Name'})).not.toBeInTheDocument() + expect(screen.getByRole('columnheader', {name: 'Name'})).toBeInTheDocument() + await user.click(screen.getByRole('button', {name: 'Apply to 1'})) + await user.click(screen.getByRole('button', {name: 'Name'})) + expect(screen.getByRole('columnheader', {name: 'Name'})).toHaveAttribute('aria-sort', 'ascending') + await user.click(screen.getByRole('checkbox', {name: 'Select rows'})) + expect(screen.getByRole('columnheader', {name: 'Name'})).toHaveAttribute('aria-sort', 'ascending') + expect(screen.queryByRole('button', {name: 'Name'})).not.toBeInTheDocument() + }) + + it('does not render actions without enabled selection or effectively selected rows', () => { + const renderer = vi.fn(renderActions) + const {rerender} = render( + , + ) + expect(renderer).not.toHaveBeenCalled() + rerender( + , + ) + expect(renderer).not.toHaveBeenCalled() + expect(screen.queryByRole('group', {name: 'Bulk actions'})).not.toBeInTheDocument() + }) + + it('preserves checkbox focus when actions appear and announces counts including zero', async () => { + const user = userEvent.setup() + render() + expect(getLiveRegion().getMessage('polite')).toBe('') + const checkbox = screen.getByRole('checkbox', {name: 'Select Beta'}) + checkbox.focus() + await user.keyboard(' ') + expect(checkbox).toHaveFocus() + await waitFor(() => expect(getLiveRegion().getMessage('polite')).toBe('1 selected')) + await user.click(screen.getByRole('checkbox', {name: 'Select Alpha'})) + await waitFor(() => expect(getLiveRegion().getMessage('polite')).toBe('2 selected')) + await user.click(screen.getByRole('button', {name: 'Apply to 2'})) + await waitFor(() => expect(getLiveRegion().getMessage('polite')).toBe('0 selected')) + expect(screen.getByRole('checkbox', {name: 'Select rows'})).toHaveFocus() + expect(screen.queryByRole('group', {name: 'Bulk actions'})).not.toBeInTheDocument() + }) + + it('clears stored IDs through the controlled selection callback without mutating its input', async () => { + const user = userEvent.setup() + const selectedRows = new Set([1, 'missing']) + const onSelectionChange = vi.fn() + const {rerender} = render( + , + ) + await user.click(screen.getByRole('button', {name: 'Apply to 1'})) + expect(onSelectionChange).toHaveBeenLastCalledWith({selectedRows: new Set()}) + expect(selectedRows).toEqual(new Set([1, 'missing'])) + expect(screen.getByRole('button', {name: 'Apply to 1'})).toHaveFocus() + rerender( + , + ) + expect(screen.getByRole('checkbox', {name: 'Select rows'})).toHaveFocus() + }) + + it('recovers focus when deleting the last selected record, including an empty table', async () => { + const user = userEvent.setup() + function Example() { + const [data, setData] = React.useState([rows[0]]) + return ( + ( + + )} + /> + ) + } + render() + await user.click(screen.getByRole('button', {name: 'Delete'})) + expect(screen.getByRole('table')).toHaveFocus() + expect(screen.getByRole('checkbox', {name: 'Select rows'})).toBeDisabled() + expect(screen.queryByRole('button', {name: 'Delete'})).not.toBeInTheDocument() + }) + + it('allows a consumer-selected recovery target', async () => { + const user = userEvent.setup() + function Example() { + const ref = React.useRef(null) + return ( + <> + + + + ) + } + render() + await user.click(screen.getByRole('button', {name: 'Apply to 1'})) + expect(screen.getByRole('button', {name: 'Add repository'})).toHaveFocus() + }) + + it('does not steal focus from a consumer-owned action destination', async () => { + const user = userEvent.setup() + function Example() { + const destination = React.useRef(null) + return ( + <> + + ( + + )} + /> + + ) + } + render() + await user.click(screen.getByRole('button', {name: 'Complete'})) + expect(screen.getByRole('button', {name: 'Confirmation'})).toHaveFocus() + }) + + it('does not recover focus after the user leaves the actions for non-interactive content', async () => { + const user = userEvent.setup() + const renderExample = (data: Array) => ( + <> +

Outside the table

+ + + ) + const {rerender} = render(renderExample(rows)) + screen.getByRole('button', {name: 'Apply to 1'}).focus() + await user.click(screen.getByText('Outside the table')) + expect(document.body).toHaveFocus() + + rerender(renderExample(rows.slice(1))) + + expect(document.body).toHaveFocus() + expect(screen.queryByRole('group', {name: 'Bulk actions'})).not.toBeInTheDocument() + }) + + it('recovers focus when a Primer menu action clears selection', async () => { + const user = userEvent.setup() + render( + ( + + More actions + + + Archive selected + + + + )} + />, + ) + screen.getByRole('button', {name: 'More actions'}).focus() + await user.keyboard(' ') + expect(screen.getByRole('menuitem', {name: 'Archive selected'})).toHaveFocus() + await user.keyboard('{Enter}') + expect(screen.getByRole('checkbox', {name: 'Select rows'})).toHaveFocus() + }) + + it('supports localized labels', () => { + render( + `${count} repositories chosen`, + }} + />, + ) + const group = screen.getByRole('group', {name: 'Selected repositories'}) + expect(within(group).getByText('1 repositories chosen')).toBeVisible() + }) + + it('preserves structure through server rendering and hydration', async () => { + const element = ( + + ) + const container = document.createElement('div') + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + container.innerHTML = renderToString(element) + document.body.appendChild(container) + const serverTable = within(container).getByRole('table') + expect(within(serverTable).getByRole('group', {name: 'Bulk actions'})).toBeInTheDocument() + const onRecoverableError = vi.fn() + let root: Root | undefined + try { + await act(async () => { + root = hydrateRoot(container, element, {onRecoverableError}) + }) + expect(onRecoverableError).not.toHaveBeenCalled() + expect( + consoleErrorSpy.mock.calls.every(([message]) => + String(message).includes('useLayoutEffect does nothing on the server'), + ), + ).toBe(true) + expect(within(container).getByRole('table')).toBe(serverTable) + expect(within(container).getByText('1 selected')).toBeVisible() + } finally { + consoleErrorSpy.mockRestore() + await act(async () => root?.unmount()) + container.remove() + } + }) +}) + +describe('Table.BulkActions', () => { + implementsClassName(props => , classes.TableBulkActions) + + it('forwards root attributes, ref and focus handlers', async () => { + const user = userEvent.setup() + const ref = React.createRef() + const onFocusCapture = vi.fn() + const onBlurCapture = vi.fn() + render( + + + + , + ) + const group = screen.getByRole('group', {name: 'Bulk actions'}) + expect(ref.current).toBe(group) + expect(group).toHaveAttribute('data-testid', 'actions') + expect(group).toHaveAttribute('data-component', 'Table.BulkActions') + await user.tab() + expect(screen.getByRole('button', {name: 'First'})).toHaveFocus() + expect(onFocusCapture).toHaveBeenCalledOnce() + await user.tab() + expect(screen.getByRole('button', {name: 'Second'})).toHaveFocus() + expect(onBlurCapture).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/react/src/DataTable/index.ts b/packages/react/src/DataTable/index.ts index 73707fd4d47..1da6ee1f9ae 100644 --- a/packages/react/src/DataTable/index.ts +++ b/packages/react/src/DataTable/index.ts @@ -18,6 +18,7 @@ import { TableSkeleton, } from './Table' import {TableGroup} from './TableGroup' +import {TableBulkActions} from './TableBulkActions' import {Pagination} from './Pagination' import type {SlotMarker} from '../utils/types/Slots' @@ -27,6 +28,7 @@ const Table: typeof TableImpl & Title: typeof TableTitle Subtitle: typeof TableSubtitle Actions: typeof TableActions + BulkActions: typeof TableBulkActions Divider: typeof TableDivider Skeleton: typeof TableSkeleton Head: typeof TableHead @@ -45,6 +47,7 @@ const Table: typeof TableImpl & Title: TableTitle, Subtitle: TableSubtitle, Actions: TableActions, + BulkActions: TableBulkActions, Divider: TableDivider, Skeleton: TableSkeleton, Head: TableHead, @@ -61,7 +64,8 @@ const Table: typeof TableImpl & }) export {DataTable, Table} -export type {DataTableProps} from './DataTable' +export type {DataTableProps, DataTableBulkActionsContext} from './DataTable' +export type {TableBulkActionsProps} from './TableBulkActions' export type { TableProps, TableHeadProps, diff --git a/packages/react/src/DataTable/useTable.ts b/packages/react/src/DataTable/useTable.ts index 7d6e50e4da3..0aeb6f534db 100644 --- a/packages/react/src/DataTable/useTable.ts +++ b/packages/react/src/DataTable/useTable.ts @@ -26,6 +26,7 @@ interface Table { sortBy: (header: Header) => void toggleRowSelection: (row: Row) => void toggleAllRows: () => void + clearSelection: () => void } selection: { headerId: string @@ -381,6 +382,9 @@ export function useTable({ sortBy, toggleRowSelection, toggleAllRows, + clearSelection: () => { + setSelectedRows(new Set()) + }, }, selection: { headerId: `${tableId}-selection-column`, diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index d83c4636a30..8d714899f42 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -325,6 +325,7 @@ exports[`@primer/react/experimental > should not update exports without a semver "type ColumnWidth", "createColumnHelper", "DataTable", + "type DataTableBulkActionsContext", "type DataTableData", "type DataTableProps", "type DataTableRowGroup", @@ -385,6 +386,7 @@ exports[`@primer/react/experimental > should not update exports without a semver "Table", "type TableActionsProps", "type TableBodyProps", + "type TableBulkActionsProps", "type TableCellProps", "type TableContainerProps", "type TableGroupProps", diff --git a/packages/react/src/experimental/index.ts b/packages/react/src/experimental/index.ts index 3d7dc23cf6a..309eb888b96 100644 --- a/packages/react/src/experimental/index.ts +++ b/packages/react/src/experimental/index.ts @@ -28,6 +28,8 @@ export type { export {DataTable, Table, createColumnHelper} from '../DataTable' export type { DataTableProps, + DataTableBulkActionsContext, + TableBulkActionsProps, DataTableData, DataTableRowGroup, DataTableRowId,