Skip to content

Commit af1970b

Browse files
authored
feat(toggle): handle indeterminate state in a select-all checkbox (#161)
Add support for displaying a checkbox with a small dash instead of a tick to indicate that the option is not exactly checked or unchecked
1 parent e369f2f commit af1970b

File tree

5 files changed

+147
-2
lines changed

5 files changed

+147
-2
lines changed

packages/toggle/docs/Toggle.mdx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,77 @@ function Example() {
124124
}
125125
```
126126
127+
## Checkbox with indeterminate state
128+
129+
Sometimes we need to indicate that the state of a single checkbox is
130+
indeterminate, or "partially checked". The checkbox will appear with a small
131+
dash instead of a tick to indicate that the option is not exactly checked or
132+
unchecked.
133+
134+
```jsx example
135+
function Example() {
136+
const [selectAllChecked, setSelectAllChecked] = React.useState(false);
137+
const [selectedOptions, setSelectedOptions] = React.useState([
138+
{ label: 'Apple pie', value: 'apple pie' },
139+
{ label: 'Pavlova', value: 'pavlova' },
140+
]);
141+
142+
const options = [
143+
{ label: 'Apple pie', value: 'apple pie' },
144+
{ label: 'Carrot cake', value: 'carrot cake' },
145+
{ label: 'Pavlova', value: 'pavlova' },
146+
];
147+
148+
const handleSelectAll = (checked) => {
149+
if (checked === false) {
150+
setSelectedOptions([]);
151+
} else {
152+
setSelectedOptions(options);
153+
}
154+
setSelectAllChecked(checked);
155+
};
156+
157+
const handleSelect = (selected) => {
158+
let updatedSelected = selectedOptions;
159+
160+
if (selectedOptions.some((option) => option.value === selected.value)) {
161+
updatedSelected = selectedOptions.filter(
162+
(option) => option.value !== selected.value,
163+
);
164+
} else {
165+
updatedSelected = [...selectedOptions, selected];
166+
}
167+
168+
if (selectedOptions.length === options.length) setSelectAllChecked(false);
169+
if (updatedSelected.length === options.length) setSelectAllChecked(true);
170+
171+
setSelectedOptions(updatedSelected);
172+
};
173+
174+
return (
175+
<>
176+
<Toggle
177+
onChange={handleSelectAll}
178+
checked={selectAllChecked}
179+
type="checkbox"
180+
label="Select all desserts"
181+
indeterminate={
182+
selectedOptions.length > 0 &&
183+
selectedOptions.length !== options.length
184+
}
185+
/>
186+
<Toggle
187+
title="Favourite desserts"
188+
type="checkbox"
189+
selected={selectedOptions}
190+
options={options}
191+
onChange={handleSelect}
192+
/>
193+
</>
194+
);
195+
}
196+
```
197+
127198
## Toggle with multiple options
128199
129200
We've already had a look at checkboxes, but Toggle can also render radio options

packages/toggle/src/component.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ export function Toggle(props: ToggleProps) {
4040
const isInvalid = props.invalid;
4141
const isRadioGroup = props.type === 'radio' || props.type === 'radio-button';
4242

43-
const isControlled = !!props.selected || !!props.checked;
43+
const isControlled =
44+
props.selected !== undefined || props.checked !== undefined;
4445

4546
return (
4647
<fieldset
@@ -72,6 +73,7 @@ export function Toggle(props: ToggleProps) {
7273
label={props.label}
7374
checked={props.checked}
7475
defaultChecked={props.defaultChecked}
76+
indeterminate={props.indeterminate}
7577
// @ts-ignore TODO: typecheck
7678
onChange={(e: boolean) => props.onChange(e)}
7779
name={`${id}:toggle`}

packages/toggle/src/item.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ interface ItemProps extends Pick<HTMLInputElement, 'type' | 'name'> {
66
controlled: boolean;
77
option?: ToggleEntry;
88
children?: React.ReactNode;
9+
indeterminate?: boolean;
910
checked?: boolean;
1011
value?: string; // value for dead toggle
1112
defaultChecked?: boolean;
@@ -26,19 +27,30 @@ export function Item({
2627
invalid,
2728
value,
2829
helpId,
30+
indeterminate = false,
2931
checked,
3032
defaultChecked,
3133
noVisibleLabel,
3234
labelClassName,
3335
...props
3436
}: ItemProps) {
3537
const id = useId();
38+
const checkboxRef = React.useRef<HTMLInputElement | null>(null);
3639

3740
const labelContent = !children ? label || option?.label : children;
3841

42+
React.useEffect(() => {
43+
if (!checkboxRef.current) {
44+
return;
45+
}
46+
// 'indeterminate' state of checkbox cannot be assigned via HTML: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#indeterminate_state_checkboxes
47+
checkboxRef.current.indeterminate = indeterminate;
48+
}, [indeterminate, checkboxRef]);
49+
3950
return (
4051
<>
4152
<input
53+
ref={checkboxRef}
4254
id={id}
4355
checked={controlled ? checked : undefined}
4456
defaultChecked={defaultChecked}

packages/toggle/src/props.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,9 @@ export interface ToggleProps {
8484
* Whether label should be invisible
8585
*/
8686
noVisibleLabel?: boolean;
87+
/**
88+
* Whether a single option is indeterminate, or "partially checked."
89+
* The checkbox will appear with a small dash instead of a tick to indicate that the option is not exactly checked or unchecked.
90+
*/
91+
indeterminate?: boolean;
8792
}

packages/toggle/stories/Checkbox.stories.tsx

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import React from 'react';
12
import { useState } from 'react';
23
import { Toggle } from '../src';
34

45
const metadata = { title: 'Forms/Toggle/Checkbox' };
56
export default metadata;
67

7-
const options = [
8+
type Option = { label: string; value: string };
9+
const options: Option[] = [
810
{ label: 'Apple', value: 'apple' },
911
{ label: 'Microsoft', value: 'microsoft' },
1012
{ label: 'Amazon', value: 'amazon' },
@@ -56,6 +58,59 @@ export const SingleOptionCheckedUncontrolledDefault = () => {
5658
);
5759
};
5860

61+
export const IndeterminateState = () => {
62+
const [selectAllChecked, setSelectAllChecked] = useState(false);
63+
const [selectedOptions, setSelectedOptions] = useState<Option[]>([]);
64+
65+
const handleSelectAll = (checked: boolean) => {
66+
if (checked === false) {
67+
setSelectedOptions([]);
68+
} else {
69+
setSelectedOptions(options);
70+
}
71+
setSelectAllChecked(checked);
72+
};
73+
74+
const handleSelect = (selected: Option) => {
75+
let updatedSelected = selectedOptions;
76+
77+
if (selectedOptions.some((option) => option.value === selected.value)) {
78+
updatedSelected = selectedOptions.filter(
79+
(option) => option.value !== selected.value,
80+
);
81+
} else {
82+
updatedSelected = [...selectedOptions, selected];
83+
}
84+
85+
if (selectedOptions.length === options.length) setSelectAllChecked(false);
86+
if (updatedSelected.length === options.length) setSelectAllChecked(true);
87+
88+
setSelectedOptions(updatedSelected);
89+
};
90+
91+
return (
92+
<>
93+
<Toggle
94+
onChange={handleSelectAll}
95+
checked={selectAllChecked}
96+
type="checkbox"
97+
label="Select all companies"
98+
indeterminate={
99+
selectedOptions.length > 0 &&
100+
selectedOptions.length !== options.length
101+
}
102+
/>
103+
<Toggle
104+
type="checkbox"
105+
title="Companies"
106+
options={options}
107+
selected={selectedOptions}
108+
onChange={handleSelect}
109+
/>
110+
</>
111+
);
112+
};
113+
59114
export const SingleOptionHelpText = () => {
60115
return (
61116
<Toggle

0 commit comments

Comments
 (0)