diff --git a/packages/mui-base/src/Checkbox/Root/CheckboxRoot.tsx b/packages/mui-base/src/Checkbox/Root/CheckboxRoot.tsx index 4e3dcf87b..5f9bed5f2 100644 --- a/packages/mui-base/src/Checkbox/Root/CheckboxRoot.tsx +++ b/packages/mui-base/src/Checkbox/Root/CheckboxRoot.tsx @@ -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> = {}; if (isGrouped) { @@ -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, diff --git a/packages/mui-base/src/CheckboxGroup/Parent/useCheckboxGroupParent.test.tsx b/packages/mui-base/src/CheckboxGroup/Parent/useCheckboxGroupParent.test.tsx index a3ae0e9fc..1b46ef396 100644 --- a/packages/mui-base/src/CheckboxGroup/Parent/useCheckboxGroupParent.test.tsx +++ b/packages/mui-base/src/CheckboxGroup/Parent/useCheckboxGroupParent.test.tsx @@ -206,4 +206,64 @@ describe('useCheckboxGroupParent', () => { } }); }); + + it('handles unchecked disabled checkboxes', () => { + function App() { + const [value, setValue] = React.useState([]); + return ( + + + + + + + ); + } + + render(); + + 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(['a']); + return ( + + + + + + + ); + } + + render(); + + 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'); + }); }); diff --git a/packages/mui-base/src/CheckboxGroup/Parent/useCheckboxGroupParent.ts b/packages/mui-base/src/CheckboxGroup/Parent/useCheckboxGroupParent.ts index c9fc7be4f..e9e4d0fae 100644 --- a/packages/mui-base/src/CheckboxGroup/Parent/useCheckboxGroupParent.ts +++ b/packages/mui-base/src/CheckboxGroup/Parent/useCheckboxGroupParent.ts @@ -20,6 +20,8 @@ export function useCheckboxGroupParent( } = params; const uncontrolledStateRef = React.useRef(value); + const disabledStatesRef = React.useRef(new Map()); + const [status, setStatus] = React.useState<'on' | 'off' | 'mixed'>('mixed'); const id = useId(); @@ -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'); } }, @@ -106,6 +122,7 @@ export function useCheckboxGroupParent( indeterminate, getParentProps, getChildProps, + disabledStatesRef, }), [id, indeterminate, getParentProps, getChildProps], ); @@ -122,6 +139,7 @@ export namespace UseCheckboxGroupParent { export interface ReturnValue { id: string | undefined; indeterminate: boolean; + disabledStatesRef: React.MutableRefObject>; getParentProps: () => { id: string | undefined; indeterminate: boolean;