Skip to content

Commit

Permalink
[CheckboxGroup] Handle disabled children (#733)
Browse files Browse the repository at this point in the history
  • Loading branch information
atomiks authored Nov 15, 2024
1 parent 7a203cc commit c3d06db
Show file tree
Hide file tree
Showing 3 changed files with 94 additions and 9 deletions.
9 changes: 8 additions & 1 deletion packages/mui-base/src/Checkbox/Root/CheckboxRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ const CheckboxRoot = React.forwardRef(function CheckboxRoot(
} = props;

const groupContext = useCheckboxGroupRootContext();
const isGrouped = groupContext?.parent && groupContext.allValues;
const parentContext = groupContext?.parent;
const isGrouped = parentContext && groupContext.allValues;

let groupProps: Partial<Omit<CheckboxRoot.Props, 'className'>> = {};
if (isGrouped) {
Expand Down Expand Up @@ -74,6 +75,12 @@ const CheckboxRoot = React.forwardRef(function CheckboxRoot(
const { ownerState: fieldOwnerState, disabled: fieldDisabled } = useFieldRootContext();
const disabled = fieldDisabled || disabledProp;

React.useEffect(() => {
if (parentContext && name) {
parentContext.disabledStatesRef.current.set(name, disabled);
}
}, [parentContext, disabled, name]);

const ownerState: CheckboxRoot.OwnerState = React.useMemo(
() => ({
...fieldOwnerState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,4 +206,64 @@ describe('useCheckboxGroupParent', () => {
}
});
});

it('handles unchecked disabled checkboxes', () => {
function App() {
const [value, setValue] = React.useState<string[]>([]);
return (
<CheckboxGroup.Root value={value} onValueChange={setValue} allValues={allValues}>
<Checkbox.Root parent data-testid="parent" />
<Checkbox.Root name="a" disabled />
<Checkbox.Root name="b" />
<Checkbox.Root name="c" />
</CheckboxGroup.Root>
);
}

render(<App />);

const checkboxes = screen
.getAllByRole('checkbox')
.filter((v) => v.getAttribute('name') && v.tagName === 'BUTTON');
const checkboxA = checkboxes.find((v) => v.getAttribute('name') === 'a')!;
const parent = screen.getByTestId('parent');

fireEvent.click(parent);

expect(parent).to.have.attribute('aria-checked', 'mixed');
expect(checkboxA).to.have.attribute('aria-checked', 'false');
});

it('handles checked disabled checkboxes', () => {
function App() {
const [value, setValue] = React.useState<string[]>(['a']);
return (
<CheckboxGroup.Root value={value} onValueChange={setValue} allValues={allValues}>
<Checkbox.Root parent data-testid="parent" />
<Checkbox.Root name="a" disabled />
<Checkbox.Root name="b" />
<Checkbox.Root name="c" />
</CheckboxGroup.Root>
);
}

render(<App />);

const checkboxes = screen
.getAllByRole('checkbox')
.filter((v) => v.getAttribute('name') && v.tagName === 'BUTTON');
const checkboxA = checkboxes.find((v) => v.getAttribute('name') === 'a')!;
const checkboxB = checkboxes.find((v) => v.getAttribute('name') === 'b')!;
const parent = screen.getByTestId('parent');

fireEvent.click(parent);

expect(checkboxA).to.have.attribute('aria-checked', 'true');
expect(checkboxB).to.have.attribute('aria-checked', 'true');

fireEvent.click(parent);

expect(checkboxA).to.have.attribute('aria-checked', 'true');
expect(checkboxB).to.have.attribute('aria-checked', 'false');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export function useCheckboxGroupParent(
} = params;

const uncontrolledStateRef = React.useRef(value);
const disabledStatesRef = React.useRef(new Map<string, boolean>());

const [status, setStatus] = React.useState<'on' | 'off' | 'mixed'>('mixed');

const id = useId();
Expand All @@ -36,34 +38,48 @@ export function useCheckboxGroupParent(
'aria-controls': allValues.map((v) => `${id}-${v}`).join(' '),
onCheckedChange(_, event) {
const uncontrolledState = uncontrolledStateRef.current;

// None except the disabled ones that are checked, which can't be changed.
const none = allValues.filter(
(v) => disabledStatesRef.current.get(v) && uncontrolledState.includes(v),
);
// "All" that are valid:
// - any that aren't disabled
// - disabled ones that are checked
const all = allValues.filter(
(v) =>
!disabledStatesRef.current.get(v) ||
(disabledStatesRef.current.get(v) && uncontrolledState.includes(v)),
);

const allOnOrOff =
uncontrolledState.length === allValues.length || uncontrolledState.length === 0;
uncontrolledState.length === all.length || uncontrolledState.length === 0;

if (allOnOrOff) {
if (value.length === allValues.length) {
onValueChange([], event);
if (value.length === all.length) {
onValueChange(none, event);
} else {
onValueChange(allValues, event);
onValueChange(all, event);
}
return;
}

if (preserveChildStates) {
if (status === 'mixed') {
onValueChange(allValues, event);
onValueChange(all, event);
setStatus('on');
} else if (status === 'on') {
onValueChange([], event);
onValueChange(none, event);
setStatus('off');
} else if (status === 'off') {
onValueChange(uncontrolledState, event);
setStatus('mixed');
}
} else if (checked) {
onValueChange([], event);
onValueChange(none, event);
setStatus('off');
} else {
onValueChange(allValues, event);
onValueChange(all, event);
setStatus('on');
}
},
Expand Down Expand Up @@ -106,6 +122,7 @@ export function useCheckboxGroupParent(
indeterminate,
getParentProps,
getChildProps,
disabledStatesRef,
}),
[id, indeterminate, getParentProps, getChildProps],
);
Expand All @@ -122,6 +139,7 @@ export namespace UseCheckboxGroupParent {
export interface ReturnValue {
id: string | undefined;
indeterminate: boolean;
disabledStatesRef: React.MutableRefObject<Map<string, boolean>>;
getParentProps: () => {
id: string | undefined;
indeterminate: boolean;
Expand Down

0 comments on commit c3d06db

Please sign in to comment.