diff --git a/.changeset/tidy-groups-select.md b/.changeset/tidy-groups-select.md new file mode 100644 index 00000000000..8696076f333 --- /dev/null +++ b/.changeset/tidy-groups-select.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +DataTable: Add selection checkboxes to collapsible groups, aligned with row checkboxes. Table.Group supports controlled group selection. diff --git a/.playwright/snapshots/components/DataTable.test.ts-snapshots/DataTable-Group-expansion-collapsed-dark-linux.png b/.playwright/snapshots/components/DataTable.test.ts-snapshots/DataTable-Group-expansion-collapsed-dark-linux.png index 12413d0f652..5be995e2f56 100644 Binary files a/.playwright/snapshots/components/DataTable.test.ts-snapshots/DataTable-Group-expansion-collapsed-dark-linux.png and b/.playwright/snapshots/components/DataTable.test.ts-snapshots/DataTable-Group-expansion-collapsed-dark-linux.png differ diff --git a/.playwright/snapshots/components/DataTable.test.ts-snapshots/DataTable-Group-expansion-collapsed-light-linux.png b/.playwright/snapshots/components/DataTable.test.ts-snapshots/DataTable-Group-expansion-collapsed-light-linux.png index b146747bacd..3033e5c8436 100644 Binary files a/.playwright/snapshots/components/DataTable.test.ts-snapshots/DataTable-Group-expansion-collapsed-light-linux.png and b/.playwright/snapshots/components/DataTable.test.ts-snapshots/DataTable-Group-expansion-collapsed-light-linux.png differ diff --git a/.playwright/snapshots/components/DataTable.test.ts-snapshots/DataTable-Group-expansion-expanded-dark-linux.png b/.playwright/snapshots/components/DataTable.test.ts-snapshots/DataTable-Group-expansion-expanded-dark-linux.png index 65454a79b29..7103443303d 100644 Binary files a/.playwright/snapshots/components/DataTable.test.ts-snapshots/DataTable-Group-expansion-expanded-dark-linux.png and b/.playwright/snapshots/components/DataTable.test.ts-snapshots/DataTable-Group-expansion-expanded-dark-linux.png differ diff --git a/.playwright/snapshots/components/DataTable.test.ts-snapshots/DataTable-Group-expansion-expanded-light-linux.png b/.playwright/snapshots/components/DataTable.test.ts-snapshots/DataTable-Group-expansion-expanded-light-linux.png index f2d62f0bb34..a7332e4146f 100644 Binary files a/.playwright/snapshots/components/DataTable.test.ts-snapshots/DataTable-Group-expansion-expanded-light-linux.png and b/.playwright/snapshots/components/DataTable.test.ts-snapshots/DataTable-Group-expansion-expanded-light-linux.png differ diff --git a/e2e/components/DataTable.test.ts b/e2e/components/DataTable.test.ts index 4911c9cbdee..7d50a8d7065 100644 --- a/e2e/components/DataTable.test.ts +++ b/e2e/components/DataTable.test.ts @@ -86,13 +86,15 @@ test.describe('DataTable', () => { await page.keyboard.press('Space') await expect(disclosure).toHaveAttribute('aria-expanded', 'false') await expect(disclosure).toBeFocused() - await expect(page.getByRole('checkbox', {name: 'Select strapi', exact: true})).toHaveCount(0) + await expect(page.getByRole('checkbox', {name: 'Select primer/css', exact: true})).toHaveCount(0) const bodyId = await disclosure.getAttribute('aria-controls') await expect(page.locator(`[id="${bodyId}"]`)).toBeHidden() - await page.getByRole('checkbox', {name: 'Select rows', exact: true}).check() + const groupCheckbox = page.getByRole('checkbox', {name: 'Select Public', exact: true}) + await groupCheckbox.check() + await expect(disclosure).toHaveAttribute('aria-expanded', 'false') await disclosure.focus() await page.keyboard.press('Enter') - const member = page.getByRole('checkbox', {name: 'Select strapi', exact: true}) + const member = page.getByRole('checkbox', {name: 'Select primer/css', exact: true}) await expect(member).toBeChecked() await member.focus() // A programmatic control activation models an external controlled update. @@ -100,7 +102,7 @@ test.describe('DataTable', () => { await expect(disclosure).toBeFocused() await expect(page.locator(`[id="${bodyId}"]`)).toBeHidden() await page.keyboard.press('Shift+Tab') - await expect(page.getByRole('button', {name: 'Internal', exact: true})).toBeFocused() + await expect(groupCheckbox).toBeFocused() await page.keyboard.press('Tab') await expect(disclosure).toBeFocused() await page.keyboard.press('Tab') diff --git a/packages/react/src/DataTable/DataTable.accessibility.md b/packages/react/src/DataTable/DataTable.accessibility.md index 96b5a190124..ddda51ba395 100644 --- a/packages/react/src/DataTable/DataTable.accessibility.md +++ b/packages/react/src/DataTable/DataTable.accessibility.md @@ -239,7 +239,7 @@ The fragments below show these associations; `sr-only` denotes hidden text. Without a row header, `DataTable` uses `Select row {id}` from `getRowId`. Place selection first and include it in group `colSpan`. `Table.Group` prepends -its header ID to selection cells' `headers`; group headings have no checkbox. +its header ID to selection cells' `headers`. Group headers are not selected row IDs. ### State, scope, and focus @@ -272,6 +272,13 @@ 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. +With `rowSelection`, each collapsible group has a checkbox named `Select {group name}`. +It toggles the group's selectable members on the current page, including collapsed +members. Partial selection is mixed; activating it selects the remaining members. +Empty groups and groups without selectable members have disabled checkboxes. +The checkbox precedes the disclosure in Tab order; selection does not toggle expansion. +With `Table.Group`, consumers provide `selection`, `selectionDisabled`, and `onToggleSelect`. + ## Verification - [Grouping tests](./__tests__/DataTableGrouping.test.tsx) cover mixed body @@ -283,6 +290,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. +- [Group selection tests](./__tests__/DataTableGroupSelection.test.tsx) cover mixed + state, collapsed members, disabled groups, and controlled selection. - [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..96865d87aa6 100644 --- a/packages/react/src/DataTable/DataTable.docs.json +++ b/packages/react/src/DataTable/DataTable.docs.json @@ -101,7 +101,7 @@ { "name": "rowSelection", "type": "boolean", - "description": "Render row checkboxes and a select-all checkbox for the current page." + "description": "Render row checkboxes and current-page select-all. Collapsible groups also get a checkbox for their selectable members." }, { "name": "selectedRows", @@ -269,6 +269,21 @@ "type": "({ expanded }: { expanded: boolean }) => void", "description": "Handle a disclosure request. Controlled groups wait for the expanded prop to update." }, + { + "name": "selection", + "type": "'all' | 'some' | 'none'", + "description": "Render a group checkbox with the given selection state." + }, + { + "name": "selectionDisabled", + "type": "boolean", + "description": "Disable the group checkbox without disabling its disclosure." + }, + { + "name": "onToggleSelect", + "type": "() => void", + "description": "Handle group checkbox activation. Consumers own member selection." + }, { "name": "id", "type": "string | number", diff --git a/packages/react/src/DataTable/DataTable.features.stories.tsx b/packages/react/src/DataTable/DataTable.features.stories.tsx index 44de054828f..a517a0fc3a3 100644 --- a/packages/react/src/DataTable/DataTable.features.stories.tsx +++ b/packages/react/src/DataTable/DataTable.features.stories.tsx @@ -1822,30 +1822,38 @@ const sortableGroupedColumns = [ export const WithCollapsibleGroups = () => { const titleId = React.useId() - const [expandedGroups, setExpandedGroups] = React.useState>( - () => new Set(['internal', 'public']), - ) - const [selectedRows, setSelectedRows] = React.useState>(() => new Set()) + const [expandedGroups, setExpandedGroups] = React.useState>(() => new Set(['public'])) return ( - Collapsible repositories by visibility + Repositories - + setExpandedGroups(expandedGroups)} rowSelection - selectedRows={selectedRows} - onSelectionChange={({selectedRows}) => setSelectedRows(selectedRows)} - isRowSelectable={repo => repo.id !== 1} + defaultSelectedRows={new Set(['react'])} /> ) diff --git a/packages/react/src/DataTable/DataTable.tsx b/packages/react/src/DataTable/DataTable.tsx index c31ee59f967..f72e9172f67 100644 --- a/packages/react/src/DataTable/DataTable.tsx +++ b/packages/react/src/DataTable/DataTable.tsx @@ -283,6 +283,11 @@ function DataTableImplementation({ aria-label={body['aria-label']} collapsible={collapsibleGroups} expanded={body.expanded} + selection={rowSelection && collapsibleGroups ? body.selection : undefined} + selectionDisabled={!body.selectable} + onToggleSelect={() => { + actions.toggleGroupSelection(body) + }} onExpandedChange={() => { actions.toggleGroupExpansion(body) }} diff --git a/packages/react/src/DataTable/Table.module.css b/packages/react/src/DataTable/Table.module.css index c03d844fa3e..b11efc3117f 100644 --- a/packages/react/src/DataTable/Table.module.css +++ b/packages/react/src/DataTable/Table.module.css @@ -320,12 +320,31 @@ border-block-end: var(--borderWidth-thin) solid var(--borderColor-default); } +.TableGroupHeaderTitle { + display: flex; + align-items: center; + column-gap: inherit; + + &:where([data-selection]) { + column-gap: var(--base-size-8); + } +} + .TableGroupHeaderContent { display: flex; align-items: baseline; column-gap: var(--base-size-8); } +.TableGroupSelection { + flex-shrink: 0; +} + +:where(.TableBody[data-selection] > .TableRow) > .TableCell:nth-child(2)::before { + content: ''; + flex: 0 0 var(--base-size-24); +} + .TableGroupHeaderLabel { font-weight: var(--base-text-weight-semibold); color: var(--fgColor-default); @@ -345,6 +364,7 @@ height: var(--base-size-24); /* Center the hit target over the same 16px slot as a selection checkbox. */ margin-inline: calc((var(--base-size-16) - var(--base-size-24)) / 2); + margin-block: calc(-1 * var(--base-size-2)); color: var(--fgColor-default); align-items: center; justify-content: center; diff --git a/packages/react/src/DataTable/TableGroup.tsx b/packages/react/src/DataTable/TableGroup.tsx index 5a68cdef8ba..0b9d2b88850 100644 --- a/packages/react/src/DataTable/TableGroup.tsx +++ b/packages/react/src/DataTable/TableGroup.tsx @@ -2,6 +2,7 @@ import {clsx} from 'clsx' import type React from 'react' import {useCallback, useId, useMemo, useRef} from 'react' import {ChevronRightIcon} from '@primer/octicons-react' +import Checkbox from '../Checkbox' import {VisuallyHidden} from '../VisuallyHidden' import {Button} from '../internal/components/ButtonReset' import {useControllableState} from '../hooks/useControllableState' @@ -61,6 +62,9 @@ export type TableGroupProps = { * Handles requests to change the expanded state. */ onExpandedChange?: ({expanded}: {expanded: boolean}) => void + selection?: 'all' | 'some' | 'none' + selectionDisabled?: boolean + onToggleSelect?: () => void } function TableGroup({ @@ -75,6 +79,9 @@ function TableGroup({ expanded: controlledExpanded, defaultExpanded = true, onExpandedChange, + selection, + selectionDisabled, + onToggleSelect, }: TableGroupProps) { const headerId = useId() const bodyId = useId() @@ -114,35 +121,47 @@ function TableGroup({ scope="colgroup" colSpan={colSpan} role="columnheader" - aria-label={collapsible ? (ariaLabel ?? label) : ariaLabel} + aria-label={collapsible || selection !== undefined ? (ariaLabel ?? label) : ariaLabel} data-component="Table.Group.Header" > - {collapsible ? ( - + {selection !== undefined ? ( + onToggleSelect?.()} + /> ) : null} - - - {label} - , - - - {rowCount} - {rowCount === 1 ? ' row' : ' rows'} + + {collapsible ? ( + + ) : null} + + + {label} + , + + + {rowCount} + {rowCount === 1 ? ' row' : ' rows'} + @@ -156,6 +175,7 @@ function TableGroup({ role="rowgroup" data-component="Table.Group.Body" data-group-id={id} + data-selection={collapsible ? selection : undefined} > {children} diff --git a/packages/react/src/DataTable/__tests__/DataTableExpansion.test.tsx b/packages/react/src/DataTable/__tests__/DataTableExpansion.test.tsx index a43235b9e3c..90b993a0720 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, userEvent as browserUserEvent} from 'vitest/browser' import {DataTable, Table} from '../../DataTable' import {implementsClassName} from '../../utils/testing' import classes from '../Table.module.css' @@ -159,9 +160,8 @@ describe('DataTable group expansion', () => { describe.each(['ltr', 'rtl'] as const)('alignment (%s)', dir => { it.each(['condensed', 'normal', 'spacious'] as const)( - 'aligns disclosures, checkboxes and text with %s cell padding', + 'aligns group checkboxes and member text at %s cell padding', async cellPadding => { - const user = userEvent.setup() render(
{ />
, ) - const disclosure = screen.getByRole('button', {name: 'First'}) - const label = screen.getByText('First') + const disclosure = page.getByRole('button', {name: 'First', exact: true}) + const groupHeader = page.getByRole('columnheader', {name: 'First', exact: true}).element() + const label = groupHeader.querySelector('.TableGroupHeaderLabel')! + const columnHeader = page.getByRole('columnheader', {name: 'Name', exact: true}).element() + const selectAll = page.getByRole('checkbox', {name: 'Select rows', exact: true}).element() + const rowHeader = page.getByRole('rowheader', {name: 'Beta', exact: true}).element() + const rowHeight = rowHeader.getBoundingClientRect().height const textRange = document.createRange() - textRange.selectNodeContents(screen.getByRole('rowheader', {name: 'Beta'})) + textRange.selectNodeContents(rowHeader) const rowText = textRange.getBoundingClientRect() - textRange.selectNodeContents(screen.getByRole('columnheader', {name: 'Name'})) + textRange.selectNodeContents(columnHeader) const columnText = textRange.getBoundingClientRect() + textRange.selectNodeContents(page.getByRole('rowheader', {name: 'Standalone', exact: true}).element()) + const standaloneText = textRange.getBoundingClientRect() const inlineStart = (rect: DOMRect) => (dir === 'rtl' ? rect.right : rect.left) for (const expanded of [true, false]) { if (!expanded) { - await user.click(disclosure) + await browserUserEvent.click(disclosure) } - const buttonRect = disclosure.getBoundingClientRect() + const buttonRect = disclosure.element().getBoundingClientRect() + const iconRect = disclosure.element().querySelector('svg')!.getBoundingClientRect() const labelRect = label.getBoundingClientRect() - for (const checkbox of screen.getAllByRole('checkbox')) { + const selectAllRect = selectAll.getBoundingClientRect() + for (const checkbox of page.getByRole('checkbox').elements()) { const checkboxRect = checkbox.getBoundingClientRect() expect( - Math.abs(buttonRect.x + buttonRect.width / 2 - checkboxRect.x - checkboxRect.width / 2), + Math.abs(selectAllRect.x + selectAllRect.width / 2 - checkboxRect.x - checkboxRect.width / 2), ).toBeLessThan(0.5) } expect(Math.abs(inlineStart(labelRect) - inlineStart(rowText))).toBeLessThan(0.5) - expect(Math.abs(inlineStart(labelRect) - inlineStart(columnText))).toBeLessThan(0.5) + expect(Math.abs(inlineStart(iconRect) - inlineStart(columnText))).toBeLessThan(0.5) + expect(Math.abs(inlineStart(standaloneText) - inlineStart(columnText))).toBeLessThan(0.5) + expect((inlineStart(labelRect) - inlineStart(iconRect)) * (dir === 'rtl' ? -1 : 1) - iconRect.width).toBe(8) expect(Math.abs(buttonRect.y + buttonRect.height / 2 - labelRect.y - labelRect.height / 2)).toBeLessThan(0.5) + expect(buttonRect.height).toBeGreaterThanOrEqual(24) + expect(groupHeader.getBoundingClientRect().height).toBe(rowHeight) + expect(groupHeader.getBoundingClientRect().height).toBeLessThanOrEqual( + columnHeader.getBoundingClientRect().height, + ) } }, ) @@ -403,3 +419,100 @@ describe('Table.Group expansion', () => { expect(screen.getByRole('columnheader', {name: 'Administrators'})).toHaveAttribute('colspan', '1') }) }) + +describe('DataTable group selection', () => { + const columns: Array> = [{header: 'Name', field: 'name', rowHeader: true}] + const data: DataTableData = [ + {id: 'standalone', name: 'Standalone'}, + { + groupId: 'public', + label: 'Public', + rows: [ + {id: 1, name: 'Alpha'}, + {id: '1', name: 'Beta'}, + {id: 2, name: 'Disabled'}, + ], + }, + {groupId: 'private', label: 'Private', rows: [{id: 3, name: 'Gamma'}]}, + {groupId: 'empty', label: 'Empty', rows: []}, + ] + + it('selects only selectable members, including collapsed rows, and preserves other selections', async () => { + const onSelectionChange = vi.fn() + render( + (['standalone', 3, 'off-page'])} + isRowSelectable={row => row.id !== 2} + onSelectionChange={onSelectionChange} + />, + ) + const group = page.getByRole('checkbox', {name: 'Select Public', exact: true}) + const disclosure = page.getByRole('button', {name: 'Public', exact: true}) + const selectAll = page.getByRole('checkbox', {name: 'Select rows', exact: true}) + + await expect.element(group).not.toBeChecked() + await browserUserEvent.click(page.getByRole('checkbox', {name: 'Select Alpha', exact: true})) + await expect.element(group).toBePartiallyChecked() + await browserUserEvent.click(disclosure) + await browserUserEvent.tab({shift: true}) + await expect.element(group).toHaveFocus() + await browserUserEvent.keyboard(' ') + await expect.element(group).toBeChecked() + await expect.element(group).toHaveFocus() + await expect.element(selectAll).toBeChecked() + await expect.element(disclosure).toHaveAttribute('aria-expanded', 'false') + expect(onSelectionChange).toHaveBeenLastCalledWith({ + selectedRows: new Set(['standalone', 3, 'off-page', 1, '1']), + }) + + await browserUserEvent.click(disclosure) + await expect.element(page.getByRole('checkbox', {name: 'Select Beta', exact: true})).toBeChecked() + await expect.element(page.getByRole('checkbox', {name: 'Select Disabled', exact: true})).not.toBeChecked() + await browserUserEvent.click(group) + await expect.element(group).not.toBeChecked() + await expect.element(selectAll).toBePartiallyChecked() + expect(onSelectionChange).toHaveBeenLastCalledWith({ + selectedRows: new Set(['standalone', 3, 'off-page']), + }) + }) + + it('requests immutable controlled updates using custom row IDs and group names', async () => { + const selectedRows = new Set(['Alpha', 'off-page']) + const onSelectionChange = vi.fn() + const props = { + data: [{groupId: 'public', label: 'Public', 'aria-label': 'Public repositories', rows: [{id: 1, name: 'Alpha'}]}], + columns, + rowSelection: true, + collapsibleGroups: true, + getRowId: (row: Row) => row.name, + onSelectionChange, + } + const {rerender} = render() + const group = page.getByRole('checkbox', {name: 'Select Public repositories', exact: true}) + await browserUserEvent.click(group) + expect(onSelectionChange).toHaveBeenLastCalledWith({selectedRows: new Set(['off-page'])}) + expect(selectedRows).toEqual(new Set(['Alpha', 'off-page'])) + await expect.element(group).toBeChecked() + rerender() + await expect.element(group).not.toBeChecked() + }) + + it('disables empty and unselectable groups and requires both selection and collapse controls', async () => { + const {rerender} = render( + false} />, + ) + for (const name of ['Public', 'Private', 'Empty']) { + const checkbox = page.getByRole('checkbox', {name: `Select ${name}`, exact: true}) + await expect.element(checkbox).toBeDisabled() + await expect.element(checkbox).not.toBeChecked() + } + rerender() + await expect.element(page.getByRole('checkbox', {name: 'Select Public', exact: true})).not.toBeInTheDocument() + rerender() + await expect.element(page.getByRole('checkbox')).not.toBeInTheDocument() + }) +}) diff --git a/packages/react/src/DataTable/useTable.ts b/packages/react/src/DataTable/useTable.ts index 23c9cf40fc8..6f16dc3485a 100644 --- a/packages/react/src/DataTable/useTable.ts +++ b/packages/react/src/DataTable/useTable.ts @@ -30,6 +30,7 @@ interface Table { sortBy: (header: Header) => void toggleRowSelection: (row: Row) => void toggleAllRows: () => void + toggleGroupSelection: (group: RowGroup) => void toggleGroupExpansion: (group: RowGroup) => void } selection: { @@ -69,6 +70,8 @@ interface RowGroup { label: string rows: Array> expanded: boolean + selection: 'all' | 'some' | 'none' + selectable: boolean 'aria-label'?: string } @@ -337,13 +340,22 @@ export function useTable({ bodies.push(body) } const groupStartIndex = rowIndex + const rows = item.rows.map((row, index) => createRow(row, groupStartIndex + index)) + const selectableRows = rows.filter(row => row.selectable) bodies.push({ type: 'row-group', key: `group:${typeof item.groupId}:${item.groupId}`, id: item.groupId, label: item.label, - rows: item.rows.map((row, index) => createRow(row, groupStartIndex + index)), + rows, expanded: !collapsibleGroups || expandedGroups.has(item.groupId), + selection: + selectableRows.length > 0 && selectableRows.every(row => row.selected) + ? 'all' + : selectableRows.some(row => row.selected) + ? 'some' + : 'none', + selectable: selectableRows.length > 0, 'aria-label': item['aria-label'], }) rowIndex += item.rows.length @@ -379,12 +391,23 @@ export function useTable({ } function toggleAllRows() { - if (selectableRows.length === 0) { + toggleRowsSelection(selectableRows, allSelected) + } + + function toggleGroupSelection(group: RowGroup) { + toggleRowsSelection( + group.rows.filter(row => row.selectable), + group.selection === 'all', + ) + } + + function toggleRowsSelection(rows: Array>, allSelected: boolean) { + if (rows.length === 0) { return } const nextSelectedRows = new Set(selectedRows) - for (const row of selectableRows) { + for (const row of rows) { if (allSelected) { nextSelectedRows.delete(row.selectionId) } else { @@ -413,6 +436,7 @@ export function useTable({ sortBy, toggleRowSelection, toggleAllRows, + toggleGroupSelection, toggleGroupExpansion, }, selection: {