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/.changeset/table-empty-group-corners.md b/.changeset/table-empty-group-corners.md new file mode 100644 index 00000000000..9f50d1f49c5 --- /dev/null +++ b/.changeset/table-empty-group-corners.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Table: Preserve bottom corners when the final row group is empty. 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..1a0e74dbd9c 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..abb4e016baa 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..4b0e2d59d47 --- /dev/null +++ b/e2e/components/DataTableBulkActions.test.ts @@ -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}) + }) +}) diff --git a/packages/react/src/DataTable/DataTable.accessibility.md b/packages/react/src/DataTable/DataTable.accessibility.md index 96b5a190124..27322c26ac8 100644 --- a/packages/react/src/DataTable/DataTable.accessibility.md +++ b/packages/react/src/DataTable/DataTable.accessibility.md @@ -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 @@ -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 diff --git a/packages/react/src/DataTable/DataTable.docs.json b/packages/react/src/DataTable/DataTable.docs.json index 0cf2dca2360..70a045c522b 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" }, @@ -144,6 +147,21 @@ "type": "({ expandedGroups }: { expandedGroups: Set }) => 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", + "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'", @@ -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": [ diff --git a/packages/react/src/DataTable/DataTable.features.stories.tsx b/packages/react/src/DataTable/DataTable.features.stories.tsx index 44de054828f..d5fc5eae81a 100644 --- a/packages/react/src/DataTable/DataTable.features.stories.tsx +++ b/packages/react/src/DataTable/DataTable.features.stories.tsx @@ -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>(() => 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} + collapsibleGroups + 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 c31ee59f967..ee38a9a40a6 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 {useId, 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 @@ -125,6 +134,10 @@ type DataTableBaseProps = { * Handles requests to change the expanded group IDs. */ onExpandedGroupsChange?: ({expandedGroups}: {expandedGroups: Set}) => void + + renderBulkActions?: (context: DataTableBulkActionsContext) => ReactNode + bulkActionsProps?: Pick + bulkActionsReturnFocusRef?: RefObject } export type DataTableProps = DataTableBaseProps & { @@ -172,7 +185,13 @@ function DataTableImplementation({ expandedGroups, defaultExpandedGroups, onExpandedGroupsChange, + renderBulkActions, + bulkActionsProps, + bulkActionsReturnFocusRef, }: DataTableProps) { + const selectionCheckboxRef = useRef(null) + const tableRef = useRef(null) + const bulkActionsId = useId() const table = useTable({ data, columns, @@ -191,6 +210,8 @@ function DataTableImplementation({ onExpandedGroupsChange, }) 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]) => ( @@ -222,15 +243,18 @@ function DataTableImplementation({ return ( - + {rowSelection ? ( ({ }} /> ) : null} - {headers.map(header => { + {headers.map((header, index) => { + if (bulkActionsActive) { + const labelId = typeof header.column.header === 'string' ? undefined : `${bulkActionsId}-column-${index}` + return ( + + + {typeof header.column.header === 'string' ? ( + {header.column.header} + ) : ( + + )} + + + ) + } if (header.isSortable()) { return ( ({ ) })} + {hasBulkActions ? ( + + + { + 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 e57d7741c87..9587aaca493 100644 --- a/packages/react/src/DataTable/DataTable.types.test.tsx +++ b/packages/react/src/DataTable/DataTable.types.test.tsx @@ -1,4 +1,4 @@ -import {lazy} from 'react' +import {lazy, type RefObject} from 'react' import type {Column} from './column' import {DataTable, type DataTableProps} from './DataTable' import type {DataTableRowGroup} from './row' @@ -248,6 +248,31 @@ export function shouldAcceptRowSelectionProps() { ) } +export function shouldAcceptBulkActions() { + const returnFocusRef: RefObject = {current: null} + 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..cc5c0620408 --- /dev/null +++ b/packages/react/src/DataTable/TableBulkActions.module.css @@ -0,0 +1,57 @@ +.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 { + min-block-size: 1lh; +} + +.Actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--base-size-8); + margin-inline-start: auto; + + :focus { + scroll-margin: var(--base-size-4); + } +} + +.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); +} + +.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..476efba6bb4 --- /dev/null +++ b/packages/react/src/DataTable/TableBulkActions.tsx @@ -0,0 +1,107 @@ +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'> & { + selectedCount: number + onFocusReturn?: () => void + 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 focusObserver = useRef(null) + const active = selectedCount > 0 + + function recoverFocus() { + const element = focusedAction.current + if (element && !element.isConnected) { + focusedAction.current = null + focusObserver.current?.disconnect() + const document = element.ownerDocument + if (document.activeElement === document.body) { + onFocusReturn?.() + } + } + } + + function observeFocusedAction() { + focusObserver.current?.disconnect() + const element = focusedAction.current + if (element) { + const observer = new MutationObserver(recoverFocus) + observer.observe(element.ownerDocument.body, {childList: true, subtree: true}) + focusObserver.current = observer + } + } + + useLayoutEffect(() => { + recoverFocus() + observeFocusedAction() + return () => { + focusObserver.current?.disconnect() + } + }) + + const count = ( + + {getSelectedCountLabel(selectedCount)} + + ) + + return ( +
{ + focusedAction.current = event.target + observeFocusedAction() + if (event.currentTarget.contains(event.target)) { + event.target.scrollIntoView({block: 'nearest', inline: 'nearest'}) + } + if (!event.defaultPrevented) { + onFocusCapture?.(event) + } + }} + onBlurCapture={event => { + if (event.target.isConnected) { + focusedAction.current = null + focusObserver.current?.disconnect() + } + 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..d098f0ec480 --- /dev/null +++ b/packages/react/src/DataTable/__tests__/DataTableBulkActions.test.tsx @@ -0,0 +1,490 @@ +import React from 'react' +import {act, render, screen, waitFor, within} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import {page, userEvent as browserUserEvent} from 'vitest/browser' +import {createPortal} from 'react-dom' +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('names custom headers without leaving hidden controls in the tab order', async () => { + render( + Name, field: 'name', rowHeader: true}, + { + header: () => ( + + ), + field: 'id', + }, + ]} + rowSelection + defaultSelectedRows={new Set([1])} + renderBulkActions={renderActions} + />, + ) + for (const name of ['Name', 'Details']) { + const header = page.getByRole('columnheader', {name, exact: true}) + await expect.element(header).toBeInTheDocument() + const labelId = header.element().getAttribute('aria-labelledby') + expect(document.getElementById(labelId!)).toHaveAttribute('hidden') + } + await expect.element(page.getByRole('link', {name: 'Name', exact: true})).not.toBeInTheDocument() + await expect.element(page.getByRole('button', {name: 'Details', exact: true})).not.toBeInTheDocument() + const selectAll = page.getByRole('checkbox', {name: 'Select rows', exact: true}) + act(() => selectAll.element().focus()) + await browserUserEvent.tab() + const apply = page.getByRole('button', {name: 'Apply to 1', exact: true}) + await expect.element(apply).toHaveFocus() + await browserUserEvent.tab({shift: true}) + await expect.element(selectAll).toHaveFocus() + await browserUserEvent.click(apply) + await browserUserEvent.tab() + await expect.element(page.getByRole('link', {name: 'Name', exact: true})).toHaveFocus() + await browserUserEvent.tab() + await expect.element(page.getByRole('button', {name: 'Details', exact: true})).toHaveFocus() + }) + + it.each([false, true])('recovers focus after a child-only action removal (portal: %s)', async portal => { + function Action() { + const [visible, setVisible] = React.useState(true) + const button = visible ? : null + return portal ? createPortal(button, document.body) : button + } + render( + } + />, + ) + await browserUserEvent.click(page.getByRole('button', {name: 'Remove action', exact: true})) + await expect.element(page.getByRole('checkbox', {name: 'Select rows', exact: true})).toHaveFocus() + await expect + .element(page.getByRole('group', {name: 'Bulk actions', exact: true}).getByText('1 selected', {exact: true})) + .toBeVisible() + }) + + it.each([false, true])('preserves a consumer focus destination (child-only removal: %s)', async childOnly => { + const destination = React.createRef() + function Action({clearSelection}: DataTableBulkActionsContext) { + const [visible, setVisible] = React.useState(true) + return visible ? ( + + ) : null + } + render( + <> + + } + /> + , + ) + await browserUserEvent.click(page.getByRole('button', {name: 'Complete', exact: true})) + await expect.element(page.getByRole('button', {name: 'Confirmation', exact: true})).toHaveFocus() + }) + + it('acts on selected collapsed rows without changing expansion', async () => { + const onAction = vi.fn() + render( + ( + + )} + />, + ) + await expect + .element(page.getByRole('group', {name: 'Bulk actions', exact: true}).getByText('1 selected', {exact: true})) + .toBeVisible() + await expect + .element(page.getByRole('button', {name: 'Repositories', exact: true})) + .toHaveAttribute('aria-expanded', 'false') + await browserUserEvent.click(page.getByRole('button', {name: 'Apply', exact: true})) + expect(onAction).toHaveBeenCalledWith(new Set([1])) + await expect.element(page.getByRole('group', {name: 'Bulk actions', exact: true})).not.toBeInTheDocument() + await expect.element(page.getByRole('checkbox', {name: 'Select rows', exact: true})).toHaveFocus() + await expect + .element(page.getByRole('button', {name: 'Repositories', exact: true})) + .toHaveAttribute('aria-expanded', 'false') + }) + + 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 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('does not recover focus when the entire component unmounts', async () => { + const onFocusReturn = vi.fn() + const {unmount} = render( + + + , + ) + await browserUserEvent.click(page.getByRole('button', {name: 'Apply', exact: true})) + await act(async () => unmount()) + expect(onFocusReturn).not.toHaveBeenCalled() + }) + + 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/__tests__/DataTableExpansion.test.tsx b/packages/react/src/DataTable/__tests__/DataTableExpansion.test.tsx index a43235b9e3c..6cd8bf7aa09 100644 --- a/packages/react/src/DataTable/__tests__/DataTableExpansion.test.tsx +++ b/packages/react/src/DataTable/__tests__/DataTableExpansion.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event' import {hydrateRoot} from 'react-dom/client' import {renderToString} from 'react-dom/server' import {describe, expect, it, vi} from 'vitest' +import {page} from 'vitest/browser' import {DataTable, Table} from '../../DataTable' import {implementsClassName} from '../../utils/testing' import classes from '../Table.module.css' @@ -298,6 +299,44 @@ describe('DataTable group expansion', () => { }) describe('Table.Group expansion', () => { + describe.each([false, true])('empty group corners (pagination: %s)', pagination => { + it.each([false, true])('rounds only the final visible boundary (collapsible: %s)', collapsible => { + function Example({empty}: {empty: boolean}) { + return ( + +
+ + + {empty ? null : ( + + Member + + )} + +
+ {pagination ? : null} + + ) + } + const {rerender} = render() + const header = page.getByRole('columnheader', {name: /^Last/}).element() + expect(getComputedStyle(header).borderBottomLeftRadius).toBe('0px') + expect(getComputedStyle(header).borderBottomRightRadius).toBe('0px') + + rerender() + const radius = pagination ? '0px' : '6px' + expect(getComputedStyle(header).borderBottomLeftRadius).toBe(radius) + expect(getComputedStyle(header).borderBottomRightRadius).toBe(radius) + const first = page.getByRole('columnheader', {name: /^First/}).element() + expect(getComputedStyle(first).borderBottomLeftRadius).toBe('0px') + expect(getComputedStyle(first).borderBottomRightRadius).toBe('0px') + + rerender() + expect(getComputedStyle(header).borderBottomLeftRadius).toBe('0px') + expect(getComputedStyle(header).borderBottomRightRadius).toBe('0px') + }) + }) + it('keeps collapsed group corners square when pagination follows the table', () => { render( 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 23c9cf40fc8..a4656d8ca75 100644 --- a/packages/react/src/DataTable/useTable.ts +++ b/packages/react/src/DataTable/useTable.ts @@ -31,6 +31,7 @@ interface Table { toggleRowSelection: (row: Row) => void toggleAllRows: () => void toggleGroupExpansion: (group: RowGroup) => void + clearSelection: () => void } selection: { headerId: string @@ -414,6 +415,9 @@ export function useTable({ toggleRowSelection, toggleAllRows, toggleGroupExpansion, + 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,