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
+
+