diff --git a/.changeset/datatable-group-expansion.md b/.changeset/datatable-group-expansion.md new file mode 100644 index 00000000000..de789877fcd --- /dev/null +++ b/.changeset/datatable-group-expansion.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +DataTable, Table.Group: Add controlled and uncontrolled group expansion without changing row selection. diff --git a/.changeset/datatable-selection-header-ref.md b/.changeset/datatable-selection-header-ref.md new file mode 100644 index 00000000000..cfe9e195301 --- /dev/null +++ b/.changeset/datatable-selection-header-ref.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Table.SelectionHeader: Preserve the mixed checkbox state when using a callback ref. 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 new file mode 100644 index 00000000000..12413d0f652 Binary files /dev/null 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 new file mode 100644 index 00000000000..b146747bacd Binary files /dev/null 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 new file mode 100644 index 00000000000..65454a79b29 Binary files /dev/null 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 new file mode 100644 index 00000000000..f2d62f0bb34 Binary files /dev/null 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 87966baf9f9..4911c9cbdee 100644 --- a/e2e/components/DataTable.test.ts +++ b/e2e/components/DataTable.test.ts @@ -79,6 +79,55 @@ const stories: ReadonlyArray<{ ] test.describe('DataTable', () => { + test('group expansion hides rows without changing selection scope @aat', async ({page}) => { + await visit(page, {id: 'experimental-components-datatable-features--with-collapsible-groups'}) + const disclosure = page.getByRole('button', {name: 'Public', exact: true}) + await disclosure.focus() + 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) + const bodyId = await disclosure.getAttribute('aria-controls') + await expect(page.locator(`[id="${bodyId}"]`)).toBeHidden() + await page.getByRole('checkbox', {name: 'Select rows', exact: true}).check() + await disclosure.focus() + await page.keyboard.press('Enter') + const member = page.getByRole('checkbox', {name: 'Select strapi', exact: true}) + await expect(member).toBeChecked() + await member.focus() + // A programmatic control activation models an external controlled update. + await page.getByRole('button', {name: 'Collapse all groups'}).dispatchEvent('click') + 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 page.keyboard.press('Tab') + await expect(disclosure).toBeFocused() + await page.keyboard.press('Tab') + await expect(page.getByRole('table').locator(':focus')).toHaveCount(0) + await expect(page).toHaveNoViolations() + }) + + for (const theme of ['light', 'dark'] as const) { + for (const state of ['expanded', 'collapsed'] as const) { + test(`group expansion ${state} ${theme} @vrt @aat`, async ({page}) => { + await visit(page, { + id: 'experimental-components-datatable-features--with-collapsible-groups', + globals: {colorScheme: theme}, + }) + if (state === 'collapsed') { + await page.getByRole('button', {name: 'Collapse all groups'}).click() + } + await expect(page).toHaveNoViolations() + expect( + await page.screenshot({ + mask: await page.locator('td', {has: page.locator('relative-time')}).all(), + }), + ).toMatchSnapshot(`DataTable.Group expansion.${state}.${theme}.png`) + }) + } + } + for (const story of stories) { test.describe(story.title, () => { for (const theme of themes) { diff --git a/packages/react/src/DataTable/DataTable.accessibility.md b/packages/react/src/DataTable/DataTable.accessibility.md index dfb464ab5ce..96b5a190124 100644 --- a/packages/react/src/DataTable/DataTable.accessibility.md +++ b/packages/react/src/DataTable/DataTable.accessibility.md @@ -80,14 +80,14 @@ The same investigation records a VoiceOver limitation even with native ### Keep group names concise and localizable -The default accessible name is the label followed by the member count, such as -`Public, 2 rows` or `Internal, 1 row`. The visible label and count also supply +For non-collapsible groups, the accessible name is the label followed by the +member count, such as `Public, 2 rows` or `Internal, 1 row`. The visible label and count also supply the accessible name, with visually hidden text adding the comma and `row` or `rows` suffix. This avoids duplicating the label and count. Consumers can override the name through the group's `aria-label`, including for localization. -A group heading is not a button. Selection and expansion actions are not part -of this contract and must not be included in its accessible name. +A group heading retains its native header role, even when it contains a +disclosure button. Action verbs must not be included in its accessible name. ```html @@ -243,8 +243,8 @@ its header ID to selection cells' `headers`; group headings have no checkbox. ### State, scope, and focus -- Select-all includes every selectable supplied row across groups and outside - the scroll viewport. Disabled rows do not affect selection totals. +- Select-all includes every selectable supplied row, including collapsed groups + and rows outside the scroll viewport. Disabled rows do not affect selection totals. - `Checkbox` sets the native `indeterminate` property for mixed state. Activating a mixed header selects the remaining rows; a checked header clears them. - With no selectable rows, keep disabled, unchecked, non-mixed checkboxes and @@ -259,6 +259,19 @@ 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). +## Group expansion + +With `collapsibleGroups` (`Table.Group`'s `collapsible`), the group header contains +a native disclosure button. The header and button use the group label, or its +`aria-label` override, as their name; the button's `aria-description` supplies the +row count. The generated count description is currently English. + +- Enter or Space toggles `aria-expanded`; `aria-controls` references the member body. +- Collapsed members stay mounted but are hidden from table navigation and Tab order. +- If a controlled collapse hides the focused member, focus returns to that group's + disclosure. Focus outside the group is left alone. +- Collapse does not clear selection or change its scope. + ## Verification - [Grouping tests](./__tests__/DataTableGrouping.test.tsx) cover mixed body @@ -268,6 +281,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. +- [Expansion tests](./__tests__/DataTableExpansion.test.tsx) cover disclosure + semantics, focus recovery, hidden members, and selection compatibility. - [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 @@ -279,6 +294,8 @@ boundaries with NVDA and JAWS, and check VoiceOver while accounting for its documented spanning-header limitation. Verify that sorting controls announce their name, state, and next action without polluting cell header names. For selection, also verify mixed-state announcements and focus after select-all. +For expansion, verify the disclosure's name, count, and expanded state, and that +collapsed members are skipped during table navigation. ## Links and resources diff --git a/packages/react/src/DataTable/DataTable.docs.json b/packages/react/src/DataTable/DataTable.docs.json index 8688354566e..0cf2dca2360 100644 --- a/packages/react/src/DataTable/DataTable.docs.json +++ b/packages/react/src/DataTable/DataTable.docs.json @@ -61,6 +61,9 @@ { "id": "experimental-components-datatable-features--with-sortable-groups" }, + { + "id": "experimental-components-datatable-features--with-collapsible-groups" + }, { "id": "experimental-components-table-features--with-groups" }, @@ -120,6 +123,27 @@ "type": "(row: Data) => boolean", "description": "Determine which rows can be selected and counted by select-all." }, + { + "name": "collapsibleGroups", + "type": "boolean", + "defaultValue": "false", + "description": "Enable group disclosures. Collapsing only hides members; selection and select-all still include them." + }, + { + "name": "expandedGroups", + "type": "ReadonlySet", + "description": "Control expanded groups by stable groupId. String and numeric IDs remain distinct." + }, + { + "name": "defaultExpandedGroups", + "type": "ReadonlySet", + "description": "Initially expanded group IDs. Defaults to all groups in the initial data; subsequently introduced IDs are collapsed." + }, + { + "name": "onExpandedGroupsChange", + "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": "cellPadding", "type": "'condensed' | 'normal' | 'spacious'", @@ -223,6 +247,28 @@ { "name": "Table.Group", "props": [ + { + "name": "collapsible", + "type": "boolean", + "defaultValue": "false", + "description": "Render a disclosure controlling the member body. Without this prop members are always visible." + }, + { + "name": "expanded", + "type": "boolean", + "description": "Control whether the member body is visible." + }, + { + "name": "defaultExpanded", + "type": "boolean", + "defaultValue": "true", + "description": "Initial expanded state for an uncontrolled group." + }, + { + "name": "onExpandedChange", + "type": "({ expanded }: { expanded: boolean }) => void", + "description": "Handle a disclosure request. Controlled groups wait for the expanded prop to update." + }, { "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 b66db41a2d0..44de054828f 100644 --- a/packages/react/src/DataTable/DataTable.features.stories.tsx +++ b/packages/react/src/DataTable/DataTable.features.stories.tsx @@ -8,6 +8,7 @@ import { TrashIcon, } from '@primer/octicons-react' import {action} from 'storybook/actions' +import {expect, userEvent, within} from 'storybook/test' import type {Meta} from '@storybook/react-vite' import React from 'react' import {ActionList} from '../ActionList' @@ -1819,6 +1820,46 @@ const sortableGroupedColumns = [ groupedColumns[1], ] +export const WithCollapsibleGroups = () => { + const titleId = React.useId() + const [expandedGroups, setExpandedGroups] = React.useState>( + () => new Set(['internal', 'public']), + ) + const [selectedRows, setSelectedRows] = React.useState>(() => new Set()) + return ( + + + Collapsible repositories by visibility + + + + + + setExpandedGroups(expandedGroups)} + rowSelection + selectedRows={selectedRows} + onSelectionChange={({selectedRows}) => setSelectedRows(selectedRows)} + isRowSelectable={repo => repo.id !== 1} + /> + + ) +} + +WithCollapsibleGroups.play = async ({canvasElement}: {canvasElement: HTMLElement}) => { + const canvas = within(canvasElement) + const publicGroup = canvas.getByRole('button', {name: 'Public'}) + await userEvent.click(publicGroup) + await expect(publicGroup).toHaveAttribute('aria-expanded', 'false') + await userEvent.keyboard('{Enter}') + await expect(publicGroup).toHaveAttribute('aria-expanded', 'true') +} + export const WithStandaloneAndGroupedRowSelection = () => { const [selectedRows, setSelectedRows] = React.useState>(() => new Set()) diff --git a/packages/react/src/DataTable/DataTable.tsx b/packages/react/src/DataTable/DataTable.tsx index 36aa8daf354..c31ee59f967 100644 --- a/packages/react/src/DataTable/DataTable.tsx +++ b/packages/react/src/DataTable/DataTable.tsx @@ -105,6 +105,26 @@ type DataTableBaseProps = { * Determines whether a row can be selected. */ isRowSelectable?: (row: Data) => boolean + + /** + * Whether groups have disclosure controls. Groups are expanded by default. + */ + collapsibleGroups?: boolean + + /** + * Controls the expanded group IDs. + */ + expandedGroups?: ReadonlySet + + /** + * Initially expanded group IDs. Defaults to all groups in the initial data. + */ + defaultExpandedGroups?: ReadonlySet + + /** + * Handles requests to change the expanded group IDs. + */ + onExpandedGroupsChange?: ({expandedGroups}: {expandedGroups: Set}) => void } export type DataTableProps = DataTableBaseProps & { @@ -148,6 +168,10 @@ function DataTableImplementation({ defaultSelectedRows, onSelectionChange, isRowSelectable, + collapsibleGroups = false, + expandedGroups, + defaultExpandedGroups, + onExpandedGroupsChange, }: DataTableProps) { const table = useTable({ data, @@ -161,6 +185,10 @@ function DataTableImplementation({ defaultSelectedRows, onSelectionChange, isRowSelectable, + collapsibleGroups, + expandedGroups, + defaultExpandedGroups, + onExpandedGroupsChange, }) const {headers, bodies, actions, selection, gridTemplateColumns} = table @@ -253,6 +281,11 @@ function DataTableImplementation({ rowCount={body.rows.length} colSpan={headers.length + (rowSelection ? 1 : 0)} aria-label={body['aria-label']} + collapsible={collapsibleGroups} + expanded={body.expanded} + onExpandedChange={() => { + actions.toggleGroupExpansion(body) + }} > {body.rows.map(renderRow)} diff --git a/packages/react/src/DataTable/DataTable.types.test.tsx b/packages/react/src/DataTable/DataTable.types.test.tsx index 099e5c20f65..e57d7741c87 100644 --- a/packages/react/src/DataTable/DataTable.types.test.tsx +++ b/packages/react/src/DataTable/DataTable.types.test.tsx @@ -82,6 +82,41 @@ export function shouldAcceptExplicitRowType() { return data={groups} columns={columns} /> } +export function shouldAcceptGroupExpansion() { + const expandedGroups: ReadonlySet = new Set([1, 'public']) + const config = ( + { + const ids: Set = expandedGroups + return ids + }} + /> + ) + const presentational = ( + { + const nextExpanded: boolean = expanded + return nextExpanded + }} + /> + ) + // @ts-expect-error Expansion IDs must be strings or numbers. + const invalid = + return {config, presentational, invalid} +} + export function shouldInferRowTypeFromInlineGroups() { return ( { ) } +export const WithCollapsibleGroups = () => { + const id = useId() + return ( + + + Collapsible repository groups + + + + + Name + Visibility + + + {groups.map(group => ( + + {group.rows.map(repo => ( + + + {repo.name} + + {repo.visibility} + + ))} + + ))} +
+
+ ) +} + export const WithRowSelection = () => { const id = useId() const titleId = `${id}-title` diff --git a/packages/react/src/DataTable/Table.module.css b/packages/react/src/DataTable/Table.module.css index a7caf99744e..c03d844fa3e 100644 --- a/packages/react/src/DataTable/Table.module.css +++ b/packages/react/src/DataTable/Table.module.css @@ -164,6 +164,17 @@ border-bottom-right-radius: var(--table-border-radius); } +/* stylelint-disable selector-pseudo-class-disallowed-list, selector-max-compound-selectors -- scoped to a group header and its adjacent member body */ +.TableOverflowWrapper:last-child + .TableGroupHeaderBody:where(:has(+ .TableBody[hidden]:last-child)) + .TableGroupHeaderCell { + /* stylelint-disable-next-line primer/borders */ + border-bottom-left-radius: var(--table-border-radius); + /* stylelint-disable-next-line primer/borders */ + border-bottom-right-radius: var(--table-border-radius); +} +/* stylelint-enable selector-pseudo-class-disallowed-list, selector-max-compound-selectors */ + /** * Offset padding to make sure type aligns regardless of cell padding * selection @@ -296,8 +307,9 @@ .TableGroupHeaderCell { display: flex; grid-column: 1 / -1; - align-items: baseline; - column-gap: var(--base-size-8); + align-items: center; + /* Match the padding on either side of the selection/data column boundary. */ + column-gap: calc(2 * var(--cell-padding-inline)); /* stylelint-disable-next-line primer/spacing */ padding: var(--table-cell-padding); text-align: start; @@ -323,6 +335,32 @@ color: var(--fgColor-muted); } +.TableBody:where([hidden]) { + display: none; +} + +.TableGroupToggle { + display: inline-flex; + width: var(--base-size-24); + 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); + color: var(--fgColor-default); + align-items: center; + justify-content: center; + align-self: center; + flex-shrink: 0; + border-radius: var(--borderRadius-small); + + &:hover { + background-color: var(--bgColor-neutral-muted); + } + + &:where([aria-expanded='true']) svg { + transform: rotate(90deg); + } +} + .PlaceholderText { color: var(--fgColor-muted); } diff --git a/packages/react/src/DataTable/Table.tsx b/packages/react/src/DataTable/Table.tsx index 02710588f8b..6f881e7f6d3 100644 --- a/packages/react/src/DataTable/Table.tsx +++ b/packages/react/src/DataTable/Table.tsx @@ -233,6 +233,9 @@ function TableSelectionHeader({ 'aria-description': ariaDescription, ...rest }: TableSelectionHeaderProps) { + const inputRef = React.useRef(null) + React.useImperativeHandle(checkboxRef, () => inputRef.current, []) + return ( void } -function TableGroup({className, id, label, rowCount, colSpan, children, 'aria-label': ariaLabel}: TableGroupProps) { +function TableGroup({ + className, + id, + label, + rowCount, + colSpan, + children, + 'aria-label': ariaLabel, + collapsible = false, + expanded: controlledExpanded, + defaultExpanded = true, + onExpandedChange, +}: TableGroupProps) { const headerId = useId() + const bodyId = useId() + const headerRef = useRef(null) + const [expanded, setExpanded] = useControllableState({ + name: 'Table.Group expanded', + value: controlledExpanded, + defaultValue: defaultExpanded, + onChange: value => onExpandedChange?.({expanded: value}), + }) + const isExpanded = !collapsible || expanded const contextValue = useMemo(() => ({headerId}), [headerId]) + // Restore focus during the collapse commit, including externally controlled changes. + const bodyRef = useCallback( + (body: HTMLTableSectionElement | null) => { + if (!isExpanded && body?.contains(body.ownerDocument.activeElement)) { + headerRef.current?.querySelector('button')?.focus() + } + }, + [isExpanded], + ) + return ( <> - + {collapsible ? ( + + ) : null} + {label} , @@ -76,6 +149,9 @@ function TableGroup({className, id, label, rowCount, colSpan, children, 'aria-la > = [{header: 'Name', field: 'name', rowHeader: true, sortBy: true}] +const data: DataTableData = [ + {id: 'standalone', name: 'Standalone'}, + { + groupId: 1, + label: 'First', + rows: [ + {id: 1, name: 'Beta'}, + {id: 2, name: 'Alpha'}, + ], + }, + {groupId: '1', label: 'Second', rows: [{id: '1', name: 'Gamma'}]}, + {groupId: 'empty group', label: 'Empty', rows: []}, +] + +describe('DataTable group expansion', () => { + it('rounds only the final group header when its members are collapsed', async () => { + const user = userEvent.setup() + render( + , + ) + const first = screen.getByRole('columnheader', {name: 'First'}) + const last = screen.getByRole('columnheader', {name: 'Last'}) + expect(getComputedStyle(last).borderBottomLeftRadius).toBe('0px') + await user.click(screen.getByRole('button', {name: 'First'})) + expect(getComputedStyle(first).borderBottomLeftRadius).toBe('0px') + await user.click(screen.getByRole('button', {name: 'Last'})) + expect(getComputedStyle(last).borderBottomLeftRadius).toBe('6px') + expect(getComputedStyle(last).borderBottomRightRadius).toBe('6px') + await user.click(screen.getByRole('button', {name: 'Last'})) + expect(getComputedStyle(last).borderBottomLeftRadius).toBe('0px') + expect(getComputedStyle(screen.getByRole('rowheader', {name: 'Beta'})).borderBottomLeftRadius).toBe('6px') + }) + + it('leaves groups expanded and without controls unless enabled', () => { + render() + expect(screen.queryByRole('button', {name: 'First'})).not.toBeInTheDocument() + expect(screen.getByRole('rowheader', {name: 'Beta'})).toBeVisible() + }) + + it('defaults to expanded groups and toggles distinct string and numeric IDs', async () => { + const user = userEvent.setup() + const onExpandedGroupsChange = vi.fn() + const {container} = render( + , + ) + const first = screen.getByRole('button', {name: 'First'}) + const second = screen.getByRole('button', {name: 'Second'}) + expect(first).toHaveAttribute('aria-expanded', 'true') + expect(first).toHaveAttribute('aria-description', '2 rows') + const body = container.querySelector(`[id="${first.getAttribute('aria-controls')}"]`) + expect(body).toHaveAttribute('data-component', 'Table.Group.Body') + await user.click(first) + expect(first).toHaveFocus() + expect(first).toHaveAttribute('aria-expanded', 'false') + expect(body).toHaveAttribute('hidden') + expect(screen.queryByRole('rowheader', {name: 'Beta'})).not.toBeInTheDocument() + expect(second).toHaveAttribute('aria-expanded', 'true') + expect(onExpandedGroupsChange).toHaveBeenLastCalledWith({expandedGroups: new Set(['1', 'empty group'])}) + expect(screen.getByRole('rowheader', {name: 'Standalone'})).toBeVisible() + await user.click(screen.getByRole('button', {name: 'Empty'})) + expect(screen.getByRole('button', {name: 'Empty'})).toHaveAttribute('aria-expanded', 'false') + }) + + it('requests immutable controlled updates without changing the displayed state', async () => { + const user = userEvent.setup() + const expandedGroups = new Set([1, 'missing']) + const onExpandedGroupsChange = vi.fn() + const {rerender} = render( + , + ) + await user.click(screen.getByRole('button', {name: 'First'})) + expect(expandedGroups).toEqual(new Set([1, 'missing'])) + expect(onExpandedGroupsChange).toHaveBeenLastCalledWith({expandedGroups: new Set(['missing'])}) + expect(screen.getByRole('rowheader', {name: 'Beta'})).toBeVisible() + rerender() + expect(screen.queryByRole('rowheader', {name: 'Beta'})).not.toBeInTheDocument() + }) + + it('preserves selection and includes collapsed members in select-all', async () => { + const user = userEvent.setup() + const onSelectionChange = vi.fn() + render( + row.id !== 2} + onSelectionChange={onSelectionChange} + />, + ) + await user.click(screen.getByRole('button', {name: 'First'})) + expect(onSelectionChange).not.toHaveBeenCalled() + const selectAll = screen.getByRole('checkbox', {name: 'Select rows'}) + expect(selectAll).toBePartiallyChecked() + expect(selectAll).toHaveAttribute('aria-description', 'Select all 3 rows') + await user.click(selectAll) + expect(onSelectionChange).toHaveBeenLastCalledWith({selectedRows: new Set(['standalone', 1, '1'])}) + await user.click(screen.getByRole('button', {name: 'First'})) + expect(screen.getByRole('checkbox', {name: 'Select Beta'})).toBeChecked() + expect(screen.getByRole('checkbox', {name: 'Select Alpha'})).not.toBeChecked() + expect(screen.getByRole('checkbox', {name: 'Select Alpha'})).toBeDisabled() + await user.click(screen.getByRole('button', {name: 'First'})) + await user.click(selectAll) + expect(onSelectionChange).toHaveBeenLastCalledWith({selectedRows: new Set()}) + }) + + it('keeps every member and effective selection in the model while collapsed', () => { + const {result} = renderHook(() => + useTable({ + data, + columns, + getRowId: row => row.id, + collapsibleGroups: true, + defaultExpandedGroups: new Set(), + rowSelection: true, + defaultSelectedRows: new Set([1, 2, 'missing']), + isRowSelectable: row => row.id !== 2, + }), + ) + expect(result.current.bodies.flatMap(body => body.rows)).toHaveLength(4) + expect(result.current.selection.selectedRows).toEqual(new Set([1])) + expect(result.current.selection.selectedCount).toBe(1) + expect(result.current.selection.selectableCount).toBe(3) + expect(result.current.bodies).toContainEqual(expect.objectContaining({type: 'row-group', id: 1, expanded: false})) + }) + + 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', + async cellPadding => { + const user = userEvent.setup() + render( +
+ +
, + ) + const disclosure = screen.getByRole('button', {name: 'First'}) + const label = screen.getByText('First') + const textRange = document.createRange() + textRange.selectNodeContents(screen.getByRole('rowheader', {name: 'Beta'})) + const rowText = textRange.getBoundingClientRect() + textRange.selectNodeContents(screen.getByRole('columnheader', {name: 'Name'})) + const columnText = textRange.getBoundingClientRect() + const inlineStart = (rect: DOMRect) => (dir === 'rtl' ? rect.right : rect.left) + + for (const expanded of [true, false]) { + if (!expanded) { + await user.click(disclosure) + } + const buttonRect = disclosure.getBoundingClientRect() + const labelRect = label.getBoundingClientRect() + for (const checkbox of screen.getAllByRole('checkbox')) { + const checkboxRect = checkbox.getBoundingClientRect() + expect( + Math.abs(buttonRect.x + buttonRect.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(buttonRect.y + buttonRect.height / 2 - labelRect.y - labelRect.height / 2)).toBeLessThan(0.5) + } + }, + ) + }) + + it('preserves expansion across sorting, reordering, removal and reintroduction', async () => { + const user = userEvent.setup() + const onExpandedGroupsChange = vi.fn() + const {rerender} = render( + , + ) + await user.click(screen.getByRole('button', {name: 'Name'})) + expect(screen.getByRole('button', {name: 'First'})).toHaveAttribute('aria-expanded', 'false') + rerender( + , + ) + rerender( + , + ) + expect(screen.getByRole('button', {name: 'Second'})).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByRole('button', {name: 'First'})).toHaveAttribute('aria-expanded', 'false') + expect(onExpandedGroupsChange).not.toHaveBeenCalled() + }) + + it('restores focus to the disclosure only when controlled collapse hides the focused member', () => { + const {rerender} = render( + , + ) + screen.getByRole('checkbox', {name: 'Select Beta'}).focus() + rerender() + expect(screen.getByRole('button', {name: 'First'})).toHaveFocus() + const selectAll = screen.getByRole('checkbox', {name: 'Select rows'}) + selectAll.focus() + rerender() + rerender() + expect(selectAll).toHaveFocus() + }) + + it('uses defaults only initially and collapses newly introduced group IDs', () => { + const {rerender} = render() + const nextData: DataTableData = [ + ...data, + {groupId: 'new', label: 'New', rows: [{id: 'new-row', name: 'New row'}]}, + ] + rerender() + expect(screen.getByRole('button', {name: 'New'})).toHaveAttribute('aria-expanded', 'false') + expect(screen.getByRole('button', {name: 'First'})).toHaveAttribute('aria-expanded', 'true') + }) + + it('hydrates with stable disclosure references and hidden state across table instances', async () => { + const ui = ( + <> + + + + ) + const container = document.createElement('div') + container.innerHTML = renderToString(ui) + document.body.append(container) + const buttons = [...container.querySelectorAll('[aria-controls]')] + const controls = buttons.map(button => button.getAttribute('aria-controls')) + expect(new Set(controls).size).toBe(buttons.length) + for (const id of controls) { + expect(container.querySelector(`[id="${id}"]`)).not.toBeNull() + } + expect(container.querySelectorAll('tbody[hidden]')).toHaveLength(3) + let root: ReturnType | undefined + const onRecoverableError = vi.fn() + try { + await act(() => { + root = hydrateRoot(container, ui, {onRecoverableError}) + }) + expect(onRecoverableError).not.toHaveBeenCalled() + expect( + [...container.querySelectorAll('[aria-controls]')].map(button => button.getAttribute('aria-controls')), + ).toEqual(controls) + expect(container.querySelectorAll('tbody[hidden]')).toHaveLength(3) + } finally { + await act(() => root?.unmount()) + container.remove() + } + }) +}) + +describe('Table.Group expansion', () => { + it('keeps collapsed group corners square when pagination follows the table', () => { + render( + + + +
+ +
, + ) + const header = screen.getByRole('columnheader', {name: 'Last'}) + expect(getComputedStyle(header).borderBottomLeftRadius).toBe('0px') + expect(getComputedStyle(header).borderBottomRightRadius).toBe('0px') + }) + + implementsClassName( + props => ( + + +
+ ), + classes.TableGroupHeaderBody, + ) + + it('preserves the disclosure target size when the group label wraps', () => { + const label = 'Repositories with access inherited from organizational policies and permissions' + render( +
+ + +
+
, + ) + const button = screen.getByRole('button', {name: label}) + expect(button.getBoundingClientRect().width).toBeGreaterThanOrEqual(24) + expect(button.getBoundingClientRect().height).toBeGreaterThanOrEqual(24) + }) + + it('requests controlled changes and always shows non-collapsible members', async () => { + const user = userEvent.setup() + const onExpandedChange = vi.fn() + function Example({collapsible = true, expanded = false}: {collapsible?: boolean; expanded?: boolean}) { + return ( + + + + Member + + +
+ ) + } + const {rerender} = render() + await user.click(screen.getByRole('button', {name: 'Controlled'})) + expect(onExpandedChange).toHaveBeenLastCalledWith({expanded: true}) + expect(screen.queryByRole('cell', {name: 'Member'})).not.toBeInTheDocument() + rerender() + expect(screen.getByRole('cell', {name: 'Member'})).toBeVisible() + rerender() + expect(screen.queryByRole('button', {name: 'Controlled'})).not.toBeInTheDocument() + expect(screen.getByRole('cell', {name: 'Member'})).toBeVisible() + }) + + it('supports uncontrolled expansion, localized names and preserves mounted children', async () => { + const user = userEvent.setup() + const onExpandedChange = vi.fn() + render( + + + + + + + + +
, + ) + const button = screen.getByRole('button', {name: 'Administrators'}) + await user.click(button) + const input = screen.getByRole('textbox', {name: 'Draft'}) + await user.type(input, ' text') + await user.click(button) + expect(input).not.toBeVisible() + expect(onExpandedChange).toHaveBeenLastCalledWith({expanded: false}) + await user.click(button) + expect(input).toHaveValue('Saved draft text') + expect(screen.getByRole('columnheader', {name: 'Administrators'})).toHaveAttribute('colspan', '1') + }) +}) diff --git a/packages/react/src/DataTable/__tests__/DataTableGrouping.test.tsx b/packages/react/src/DataTable/__tests__/DataTableGrouping.test.tsx index d72f55f6134..51baccf5f1b 100644 --- a/packages/react/src/DataTable/__tests__/DataTableGrouping.test.tsx +++ b/packages/react/src/DataTable/__tests__/DataTableGrouping.test.tsx @@ -217,11 +217,11 @@ describe('DataTable grouping', () => { ]) expect(result.current.bodies.map(body => body.key)).toEqual([ 'rows:start', - 'group:5', - 'group:public', - 'rows:after:public', - 'group:empty', - 'rows:after:empty', + 'group:number:5', + 'group:string:public', + 'rows:after:string:public', + 'group:string:empty', + 'rows:after:string:empty', ]) expect(result.current.bodies.map(body => body.rows.length)).toEqual([2, 2, 2, 2, 0, 2]) const rowHeaderIds: string[] = [] diff --git a/packages/react/src/DataTable/__tests__/DataTableSelection.test.tsx b/packages/react/src/DataTable/__tests__/DataTableSelection.test.tsx index 034d3e4edae..8c42386c718 100644 --- a/packages/react/src/DataTable/__tests__/DataTableSelection.test.tsx +++ b/packages/react/src/DataTable/__tests__/DataTableSelection.test.tsx @@ -9,6 +9,7 @@ import type {Column} from '../column' import type {DataTableData, DataTableRowGroup} from '../row' import classes from '../Table.module.css' import {implementsClassName} from '../../utils/testing' +import {FeatureFlags} from '../../FeatureFlags' interface Row { id: string | number @@ -31,6 +32,27 @@ const data: Array = [ ] describe('DataTable row selection', () => { + it.each([false, true])('preserves mixed state with a callback ref (merged refs: %s)', enabled => { + const checkboxRef = vi.fn() + const {unmount} = render( + + + + + + + +
+
, + ) + const checkbox = screen.getByRole('checkbox', {name: 'Select rows'}) + expect(checkboxRef).toHaveBeenLastCalledWith(checkbox) + expect(checkbox.indeterminate).toBe(true) + expect(checkbox).toHaveAttribute('aria-checked', 'mixed') + unmount() + expect(checkboxRef).toHaveBeenLastCalledWith(null) + }) + implementsClassName( props => ( diff --git a/packages/react/src/DataTable/useTable.ts b/packages/react/src/DataTable/useTable.ts index 7d6e50e4da3..23c9cf40fc8 100644 --- a/packages/react/src/DataTable/useTable.ts +++ b/packages/react/src/DataTable/useTable.ts @@ -17,6 +17,10 @@ interface TableConfig { defaultSelectedRows?: ReadonlySet onSelectionChange?: ({selectedRows}: {selectedRows: Set}) => void isRowSelectable?: (row: Data) => boolean + collapsibleGroups?: boolean + expandedGroups?: ReadonlySet + defaultExpandedGroups?: ReadonlySet + onExpandedGroupsChange?: ({expandedGroups}: {expandedGroups: Set}) => void } interface Table { @@ -26,6 +30,7 @@ interface Table { sortBy: (header: Header) => void toggleRowSelection: (row: Row) => void toggleAllRows: () => void + toggleGroupExpansion: (group: RowGroup) => void } selection: { headerId: string @@ -63,6 +68,7 @@ interface RowGroup { id: string | number label: string rows: Array> + expanded: boolean 'aria-label'?: string } @@ -95,6 +101,10 @@ export function useTable({ defaultSelectedRows, onSelectionChange, isRowSelectable, + collapsibleGroups = false, + expandedGroups: controlledExpandedGroups, + defaultExpandedGroups, + onExpandedGroupsChange, }: TableConfig): Table { const tableId = useId() const [rowOrder, setRowOrder] = useState>(data) @@ -112,6 +122,14 @@ export function useTable({ }, }) const {gridTemplateColumns} = useTableLayout(columns, rowSelection) + const [expandedGroups, setExpandedGroups] = useControllableState>({ + name: 'DataTable expandedGroups', + value: controlledExpandedGroups, + defaultValue: () => defaultExpandedGroups ?? new Set(data.filter(isDataTableRowGroup).map(group => group.groupId)), + onChange: nextExpandedGroups => { + onExpandedGroupsChange?.({expandedGroups: new Set(nextExpandedGroups)}) + }, + }) // Reset the `sortByColumn` state if the columns change and that column is no // longer provided @@ -321,14 +339,15 @@ export function useTable({ const groupStartIndex = rowIndex bodies.push({ type: 'row-group', - key: `group:${item.groupId}`, + key: `group:${typeof item.groupId}:${item.groupId}`, id: item.groupId, label: item.label, rows: item.rows.map((row, index) => createRow(row, groupStartIndex + index)), + expanded: !collapsibleGroups || expandedGroups.has(item.groupId), 'aria-label': item['aria-label'], }) rowIndex += item.rows.length - body = {type: 'row-body', key: `rows:after:${item.groupId}`, rows: []} + body = {type: 'row-body', key: `rows:after:${typeof item.groupId}:${item.groupId}`, rows: []} } else { body.rows.push(createRow(item, rowIndex)) rowIndex += 1 @@ -375,12 +394,26 @@ export function useTable({ setSelectedRows(nextSelectedRows) } + function toggleGroupExpansion(group: RowGroup) { + if (!collapsibleGroups) { + return + } + const nextExpandedGroups = new Set(expandedGroups) + if (nextExpandedGroups.has(group.id)) { + nextExpandedGroups.delete(group.id) + } else { + nextExpandedGroups.add(group.id) + } + setExpandedGroups(nextExpandedGroups) + } + return { headers, actions: { sortBy, toggleRowSelection, toggleAllRows, + toggleGroupExpansion, }, selection: { headerId: `${tableId}-selection-column`,