Skip to content
Open
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/tidy-groups-select.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

DataTable: Add selection checkboxes to collapsible groups, aligned with row checkboxes. Table.Group supports controlled group selection.
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.
10 changes: 6 additions & 4 deletions e2e/components/DataTable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,21 +86,23 @@ 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.
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 expect(groupCheckbox).toBeFocused()
await page.keyboard.press('Tab')
await expect(disclosure).toBeFocused()
await page.keyboard.press('Tab')
Expand Down
11 changes: 10 additions & 1 deletion packages/react/src/DataTable/DataTable.accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
17 changes: 16 additions & 1 deletion packages/react/src/DataTable/DataTable.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
30 changes: 19 additions & 11 deletions packages/react/src/DataTable/DataTable.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1822,30 +1822,38 @@ const sortableGroupedColumns = [

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())
const [expandedGroups, setExpandedGroups] = React.useState<ReadonlySet<DataTableRowId>>(() => new Set(['public']))
return (
<Table.Container>
<Table.Title as="h2" id={titleId}>
Collapsible repositories by visibility
Repositories
</Table.Title>
<Table.Actions>
<Button onClick={() => setExpandedGroups(new Set())}>Collapse all groups</Button>
<Button onClick={() => setExpandedGroups(new Set(['internal', 'public']))}>Expand all groups</Button>
<Button onClick={() => setExpandedGroups(new Set(['public']))}>Expand all groups</Button>
</Table.Actions>
<DataTable
aria-labelledby={titleId}
data={repoGroupsWithDisabledRow}
columns={sortableGroupedColumns}
data={[
{id: 'github', name: 'github/github', visibility: 'Internal'},
{
groupId: 'public',
label: 'Public',
rows: [
{id: 'react', name: 'primer/react', visibility: 'Public'},
{id: 'css', name: 'primer/css', visibility: 'Public'},
],
},
]}
columns={[
{header: 'Repository', field: 'name', rowHeader: true},
{header: 'Visibility', field: 'visibility'},
]}
collapsibleGroups
expandedGroups={expandedGroups}
onExpandedGroupsChange={({expandedGroups}) => setExpandedGroups(expandedGroups)}
rowSelection
selectedRows={selectedRows}
onSelectionChange={({selectedRows}) => setSelectedRows(selectedRows)}
isRowSelectable={repo => repo.id !== 1}
defaultSelectedRows={new Set(['react'])}
/>
</Table.Container>
)
Expand Down
5 changes: 5 additions & 0 deletions packages/react/src/DataTable/DataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,11 @@ function DataTableImplementation<Data extends UniqueRow>({
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)
}}
Expand Down
20 changes: 20 additions & 0 deletions packages/react/src/DataTable/Table.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down
72 changes: 46 additions & 26 deletions packages/react/src/DataTable/TableGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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({
Expand All @@ -75,6 +79,9 @@ function TableGroup({
expanded: controlledExpanded,
defaultExpanded = true,
onExpandedChange,
selection,
selectionDisabled,
onToggleSelect,
}: TableGroupProps) {
const headerId = useId()
const bodyId = useId()
Expand Down Expand Up @@ -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 ? (
<Button
className={classes.TableGroupToggle}
data-component="Table.Group.Toggle"
aria-label={ariaLabel ?? label}
aria-description={`${rowCount} ${rowCount === 1 ? 'row' : 'rows'}`}
aria-expanded={isExpanded}
aria-controls={bodyId}
onClick={() => {
setExpanded(!expanded)
}}
>
<ChevronRightIcon aria-hidden="true" />
</Button>
{selection !== undefined ? (
<Checkbox
className={classes.TableGroupSelection}
checked={selection === 'all'}
indeterminate={selection === 'some'}
disabled={selectionDisabled}
aria-label={`Select ${ariaLabel ?? label}`}
onChange={() => onToggleSelect?.()}
/>
) : null}
<span
className={clsx('TableGroupHeaderContent', classes.TableGroupHeaderContent)}
aria-hidden={collapsible || undefined}
>
<span className={clsx('TableGroupHeaderLabel', classes.TableGroupHeaderLabel)}>
{label}
<VisuallyHidden>,</VisuallyHidden>
</span>
<span className={clsx('TableGroupHeaderCount', classes.TableGroupHeaderCount)}>
{rowCount}
<VisuallyHidden>{rowCount === 1 ? ' row' : ' rows'}</VisuallyHidden>
<span className={classes.TableGroupHeaderTitle} data-selection={selection}>
{collapsible ? (
<Button
className={classes.TableGroupToggle}
data-component="Table.Group.Toggle"
aria-label={ariaLabel ?? label}
aria-description={`${rowCount} ${rowCount === 1 ? 'row' : 'rows'}`}
aria-expanded={isExpanded}
aria-controls={bodyId}
onClick={() => {
setExpanded(!expanded)
}}
>
<ChevronRightIcon aria-hidden="true" />
</Button>
) : null}
<span
className={clsx('TableGroupHeaderContent', classes.TableGroupHeaderContent)}
aria-hidden={collapsible || undefined}
>
<span className={clsx('TableGroupHeaderLabel', classes.TableGroupHeaderLabel)}>
{label}
<VisuallyHidden>,</VisuallyHidden>
</span>
<span className={clsx('TableGroupHeaderCount', classes.TableGroupHeaderCount)}>
{rowCount}
<VisuallyHidden>{rowCount === 1 ? ' row' : ' rows'}</VisuallyHidden>
</span>
</span>
</span>
</th>
Expand All @@ -156,6 +175,7 @@ function TableGroup({
role="rowgroup"
data-component="Table.Group.Body"
data-group-id={id}
data-selection={collapsible ? selection : undefined}
>
<TableGroupContext.Provider value={contextValue}>{children}</TableGroupContext.Provider>
</tbody>
Expand Down
Loading
Loading