Skip to content

Commit 6308f98

Browse files
rhamiltoclaude
andcommitted
feat(Table): add indeterminate checkbox state support for select-all header
Add support for indeterminate state to the Table component's select-all checkbox, following PatternFly's bulk selection design guidelines. The select-all checkbox now supports three states: - Unchecked: no items selected - Indeterminate: some items selected (shows dash/minus icon) - Checked: all items selected ## Changes - Add `isIndeterminate?: boolean` property to `ThSelectType` interface - Add `isIndeterminate?: boolean` to `IColumn` extraParams type - Update `Th` component to pass `isIndeterminate` to the selectable decorator - Update `selectable` decorator to pass indeterminate state to `SelectColumn` - Update `SelectColumn` to use PatternFly Checkbox's native indeterminate support (isChecked: null) - Add new `TableSelectableIndeterminate` example showcasing the feature ## Implementation The indeterminate state is implemented using the PatternFly Checkbox component's native support by setting `isChecked: null` when `isIndeterminate` is true. ## Usage ```typescript const areSomeReposSelected = selectedRepoNames.length > 0 && selectedRepoNames.length < selectableRepos.length; <Th select={{ onSelect: (_event, isSelecting) => selectAllRepos(isSelecting), isSelected: areAllReposSelected, isIndeterminate: areSomeReposSelected }} aria-label="Row select" /> ``` Fixes #12404 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent ed21bd6 commit 6308f98

7 files changed

Lines changed: 156 additions & 3 deletions

File tree

packages/react-table/src/components/Table/SelectColumn.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export interface SelectColumnProps {
2121
id?: string;
2222
/** name for the input element - required by Radio component */
2323
name?: string;
24+
/** Whether the checkbox should be in an indeterminate state */
25+
isIndeterminate?: boolean;
2426
}
2527

2628
export const SelectColumn: React.FunctionComponent<SelectColumnProps> = ({
@@ -33,6 +35,7 @@ export const SelectColumn: React.FunctionComponent<SelectColumnProps> = ({
3335
tooltipProps,
3436
id,
3537
name,
38+
isIndeterminate,
3639
...props
3740
}: SelectColumnProps) => {
3841
const inputRef = createRef<any>();
@@ -41,6 +44,15 @@ export const SelectColumn: React.FunctionComponent<SelectColumnProps> = ({
4144
onSelect && onSelect(event);
4245
};
4346

47+
// PatternFly Checkbox supports indeterminate via isChecked: null
48+
const checkboxProps = {
49+
...props,
50+
id,
51+
ref: inputRef,
52+
onChange: handleChange,
53+
...(isIndeterminate && { isChecked: null })
54+
};
55+
4456
const commonProps = {
4557
...props,
4658
id,
@@ -51,7 +63,7 @@ export const SelectColumn: React.FunctionComponent<SelectColumnProps> = ({
5163
const content = (
5264
<Fragment>
5365
{selectVariant === RowSelectVariant.checkbox ? (
54-
<Checkbox {...commonProps} />
66+
<Checkbox {...checkboxProps} />
5567
) : (
5668
<Radio {...commonProps} name={name} />
5769
)}

packages/react-table/src/components/Table/TableTypes.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export interface IColumn {
107107
allRowsSelected?: boolean;
108108
allRowsExpanded?: boolean;
109109
isHeaderSelectDisabled?: boolean;
110+
isIndeterminate?: boolean;
110111
onFavorite?: OnFavorite;
111112
favoriteButtonProps?: ButtonProps;
112113
variant?: 'compact';

packages/react-table/src/components/Table/Th.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,8 @@ const ThBase: React.FunctionComponent<ThProps> = ({
158158
onSelect: select?.onSelect,
159159
selectVariant: 'checkbox',
160160
allRowsSelected: select.isSelected,
161-
isHeaderSelectDisabled: !!select.isHeaderSelectDisabled
161+
isHeaderSelectDisabled: !!select.isHeaderSelectDisabled,
162+
isIndeterminate: select?.isIndeterminate
162163
}
163164
},
164165
tooltip: tooltip as string,

packages/react-table/src/components/Table/base/types.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ export interface ThSelectType {
180180
onSelect?: OnSelect;
181181
/** Whether the cell is selected */
182182
isSelected: boolean;
183+
/** Whether the select checkbox should be in an indeterminate state (some items selected) */
184+
isIndeterminate?: boolean;
183185
/** Flag indicating the select checkbox in the th is disabled */
184186
isHeaderSelectDisabled?: boolean;
185187
/** Whether to disable the selection */

packages/react-table/src/components/Table/examples/Table.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,14 @@ checking checkboxes will check intermediate rows' checkboxes.
158158

159159
```
160160

161+
### Selectable with indeterminate state
162+
163+
This example demonstrates the indeterminate state support for the select-all checkbox. When some (but not all) rows are selected, the header checkbox displays a dash/minus icon to indicate partial selection.
164+
165+
```ts file="TableSelectableIndeterminate.tsx"
166+
167+
```
168+
161169
### Selectable radio input
162170

163171
Similarly to the selectable example above, the radio buttons use the first column. The first header cell is empty, and each body row's first cell has radio button props.
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { useEffect, useState } from 'react';
2+
import { Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table';
3+
4+
interface Repository {
5+
name: string;
6+
branches: string;
7+
prs: string;
8+
workspaces: string;
9+
lastCommit: string;
10+
}
11+
12+
export const TableSelectableIndeterminate: React.FunctionComponent = () => {
13+
// In real usage, this data would come from some external source like an API via props.
14+
const repositories: Repository[] = [
15+
{ name: 'one', branches: 'two', prs: 'a', workspaces: 'four', lastCommit: 'five' },
16+
{ name: 'a', branches: 'two', prs: 'k', workspaces: 'four', lastCommit: 'five' },
17+
{ name: 'b', branches: 'two', prs: 'k', workspaces: 'four', lastCommit: 'five' },
18+
{ name: 'c', branches: 'two', prs: 'k', workspaces: 'four', lastCommit: 'five' },
19+
{ name: 'd', branches: 'two', prs: 'k', workspaces: 'four', lastCommit: 'five' },
20+
{ name: 'e', branches: 'two', prs: 'b', workspaces: 'four', lastCommit: 'five' }
21+
];
22+
23+
const columnNames = {
24+
name: 'Repositories',
25+
branches: 'Branches',
26+
prs: 'Pull requests',
27+
workspaces: 'Workspaces',
28+
lastCommit: 'Last commit'
29+
};
30+
31+
const isRepoSelectable = (repo: Repository) => repo.name !== 'a'; // Arbitrary logic for this example
32+
const selectableRepos = repositories.filter(isRepoSelectable);
33+
34+
// In this example, selected rows are tracked by the repo names from each row. This could be any unique identifier.
35+
// This is to prevent state from being based on row order index in case we later add sorting.
36+
const [selectedRepoNames, setSelectedRepoNames] = useState<string[]>([]);
37+
const setRepoSelected = (repo: Repository, isSelecting = true) =>
38+
setSelectedRepoNames((prevSelected) => {
39+
const otherSelectedRepoNames = prevSelected.filter((r) => r !== repo.name);
40+
return isSelecting && isRepoSelectable(repo) ? [...otherSelectedRepoNames, repo.name] : otherSelectedRepoNames;
41+
});
42+
const selectAllRepos = (isSelecting = true) =>
43+
setSelectedRepoNames(isSelecting ? selectableRepos.map((r) => r.name) : []);
44+
const areAllReposSelected = selectedRepoNames.length === selectableRepos.length;
45+
const areSomeReposSelected = selectedRepoNames.length > 0 && selectedRepoNames.length < selectableRepos.length;
46+
const isRepoSelected = (repo: Repository) => selectedRepoNames.includes(repo.name);
47+
48+
// To allow shift+click to select/deselect multiple rows
49+
const [recentSelectedRowIndex, setRecentSelectedRowIndex] = useState<number | null>(null);
50+
const [shifting, setShifting] = useState(false);
51+
52+
const onSelectRepo = (repo: Repository, rowIndex: number, isSelecting: boolean) => {
53+
// If the user is shift + selecting the checkboxes, then all intermediate checkboxes should be selected
54+
if (shifting && recentSelectedRowIndex !== null) {
55+
const numberSelected = rowIndex - recentSelectedRowIndex;
56+
const intermediateIndexes =
57+
numberSelected > 0
58+
? Array.from(new Array(numberSelected + 1), (_x, i) => i + recentSelectedRowIndex)
59+
: Array.from(new Array(Math.abs(numberSelected) + 1), (_x, i) => i + rowIndex);
60+
intermediateIndexes.forEach((index) => setRepoSelected(repositories[index], isSelecting));
61+
} else {
62+
setRepoSelected(repo, isSelecting);
63+
}
64+
setRecentSelectedRowIndex(rowIndex);
65+
};
66+
67+
useEffect(() => {
68+
const onKeyDown = (e: KeyboardEvent) => {
69+
if (e.key === 'Shift') {
70+
setShifting(true);
71+
}
72+
};
73+
const onKeyUp = (e: KeyboardEvent) => {
74+
if (e.key === 'Shift') {
75+
setShifting(false);
76+
}
77+
};
78+
79+
document.addEventListener('keydown', onKeyDown);
80+
document.addEventListener('keyup', onKeyUp);
81+
82+
return () => {
83+
document.removeEventListener('keydown', onKeyDown);
84+
document.removeEventListener('keyup', onKeyUp);
85+
};
86+
}, []);
87+
88+
return (
89+
<Table aria-label="Selectable table with indeterminate state">
90+
<Thead>
91+
<Tr>
92+
<Th
93+
select={{
94+
onSelect: (_event, isSelecting) => selectAllRepos(isSelecting),
95+
isSelected: areAllReposSelected,
96+
isIndeterminate: areSomeReposSelected
97+
}}
98+
aria-label="Row select"
99+
/>
100+
<Th>{columnNames.name}</Th>
101+
<Th>{columnNames.branches}</Th>
102+
<Th>{columnNames.prs}</Th>
103+
<Th>{columnNames.workspaces}</Th>
104+
<Th>{columnNames.lastCommit}</Th>
105+
</Tr>
106+
</Thead>
107+
<Tbody>
108+
{repositories.map((repo, rowIndex) => (
109+
<Tr key={repo.name}>
110+
<Td
111+
select={{
112+
rowIndex,
113+
onSelect: (_event, isSelecting) => onSelectRepo(repo, rowIndex, isSelecting),
114+
isSelected: isRepoSelected(repo),
115+
isDisabled: !isRepoSelectable(repo)
116+
}}
117+
/>
118+
<Td dataLabel={columnNames.name}>{repo.name}</Td>
119+
<Td dataLabel={columnNames.branches}>{repo.branches}</Td>
120+
<Td dataLabel={columnNames.prs}>{repo.prs}</Td>
121+
<Td dataLabel={columnNames.workspaces}>{repo.workspaces}</Td>
122+
<Td dataLabel={columnNames.lastCommit}>{repo.lastCommit}</Td>
123+
</Tr>
124+
))}
125+
</Tbody>
126+
</Table>
127+
);
128+
};

packages/react-table/src/components/Table/utils/decorators/selectable.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const selectable: ITransform = (
99
{ rowIndex, columnIndex, rowData, column, property, tooltip }: IExtra
1010
) => {
1111
const {
12-
extraParams: { onSelect, selectVariant, allRowsSelected, isHeaderSelectDisabled }
12+
extraParams: { onSelect, selectVariant, allRowsSelected, isHeaderSelectDisabled, isIndeterminate }
1313
} = column;
1414
const extraData = {
1515
rowIndex,
@@ -70,6 +70,7 @@ export const selectable: ITransform = (
7070
onSelect={selectClick}
7171
name={selectName}
7272
tooltip={tooltip}
73+
isIndeterminate={rowId === -1 ? isIndeterminate : undefined}
7374
>
7475
{label as React.ReactNode}
7576
</SelectColumn>

0 commit comments

Comments
 (0)