Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/datatable-group-expansion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

DataTable, Table.Group: Add controlled and uncontrolled group expansion without changing row selection.
5 changes: 5 additions & 0 deletions .changeset/datatable-selection-header-ref.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Table.SelectionHeader: Preserve the mixed checkbox state when using a callback ref.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 49 additions & 0 deletions e2e/components/DataTable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
29 changes: 23 additions & 6 deletions packages/react/src/DataTable/DataTable.accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<tbody>
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
46 changes: 46 additions & 0 deletions packages/react/src/DataTable/DataTable.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -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<DataTableRowId>",
"description": "Control expanded groups by stable groupId. String and numeric IDs remain distinct."
},
{
"name": "defaultExpandedGroups",
"type": "ReadonlySet<DataTableRowId>",
"description": "Initially expanded group IDs. Defaults to all groups in the initial data; subsequently introduced IDs are collapsed."
},
{
"name": "onExpandedGroupsChange",
"type": "({ expandedGroups }: { expandedGroups: Set<DataTableRowId> }) => 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'",
Expand Down Expand Up @@ -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",
Expand Down
41 changes: 41 additions & 0 deletions packages/react/src/DataTable/DataTable.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -1819,6 +1820,46 @@ const sortableGroupedColumns = [
groupedColumns[1],
]

export const WithCollapsibleGroups = () => {
const titleId = React.useId()
const [expandedGroups, setExpandedGroups] = React.useState<ReadonlySet<DataTableRowId>>(
() => new Set(['internal', 'public']),
)
const [selectedRows, setSelectedRows] = React.useState<ReadonlySet<DataTableRowId>>(() => new Set())
return (
<Table.Container>
<Table.Title as="h2" id={titleId}>
Collapsible repositories by visibility
</Table.Title>
<Table.Actions>
<Button onClick={() => setExpandedGroups(new Set())}>Collapse all groups</Button>
<Button onClick={() => setExpandedGroups(new Set(['internal', 'public']))}>Expand all groups</Button>
</Table.Actions>
<DataTable
aria-labelledby={titleId}
data={repoGroupsWithDisabledRow}
columns={sortableGroupedColumns}
collapsibleGroups
expandedGroups={expandedGroups}
onExpandedGroupsChange={({expandedGroups}) => setExpandedGroups(expandedGroups)}
rowSelection
selectedRows={selectedRows}
onSelectionChange={({selectedRows}) => setSelectedRows(selectedRows)}
isRowSelectable={repo => repo.id !== 1}
/>
</Table.Container>
)
}

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<ReadonlySet<DataTableRowId>>(() => new Set())

Expand Down
33 changes: 33 additions & 0 deletions packages/react/src/DataTable/DataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,26 @@ type DataTableBaseProps<Data extends UniqueRow> = {
* 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<DataTableRowId>

/**
* Initially expanded group IDs. Defaults to all groups in the initial data.
*/
defaultExpandedGroups?: ReadonlySet<DataTableRowId>

/**
* Handles requests to change the expanded group IDs.
*/
onExpandedGroupsChange?: ({expandedGroups}: {expandedGroups: Set<DataTableRowId>}) => void
}

export type DataTableProps<Data extends UniqueRow> = DataTableBaseProps<Data> & {
Expand Down Expand Up @@ -148,6 +168,10 @@ function DataTableImplementation<Data extends UniqueRow>({
defaultSelectedRows,
onSelectionChange,
isRowSelectable,
collapsibleGroups = false,
expandedGroups,
defaultExpandedGroups,
onExpandedGroupsChange,
}: DataTableProps<Data>) {
const table = useTable({
data,
Expand All @@ -161,6 +185,10 @@ function DataTableImplementation<Data extends UniqueRow>({
defaultSelectedRows,
onSelectionChange,
isRowSelectable,
collapsibleGroups,
expandedGroups,
defaultExpandedGroups,
onExpandedGroupsChange,
})
const {headers, bodies, actions, selection, gridTemplateColumns} = table

Expand Down Expand Up @@ -253,6 +281,11 @@ function DataTableImplementation<Data extends UniqueRow>({
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)}
</TableGroup>
Expand Down
35 changes: 35 additions & 0 deletions packages/react/src/DataTable/DataTable.types.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,41 @@ export function shouldAcceptExplicitRowType() {
return <DataTable<Repository> data={groups} columns={columns} />
}

export function shouldAcceptGroupExpansion() {
const expandedGroups: ReadonlySet<DataTableRowId> = new Set([1, 'public'])
const config = (
<DataTable
data={groups}
columns={columns}
collapsibleGroups
expandedGroups={expandedGroups}
defaultExpandedGroups={expandedGroups}
onExpandedGroupsChange={({expandedGroups}) => {
const ids: Set<DataTableRowId> = expandedGroups
return ids
}}
/>
)
const presentational = (
<Table.Group
id="public"
label="Public"
colSpan={1}
rowCount={0}
collapsible
expanded
defaultExpanded={false}
onExpandedChange={({expanded}) => {
const nextExpanded: boolean = expanded
return nextExpanded
}}
/>
)
// @ts-expect-error Expansion IDs must be strings or numbers.
const invalid = <DataTable data={groups} columns={columns} expandedGroups={new Set([true])} />
return {config, presentational, invalid}
}

export function shouldInferRowTypeFromInlineGroups() {
return (
<DataTable
Expand Down
Loading
Loading