From e17d5b3ec5b25cc55771c71faba8ea237bb24aad Mon Sep 17 00:00:00 2001 From: Edmo Lima Date: Thu, 2 Oct 2025 23:54:23 +0200 Subject: [PATCH 1/2] [checkbox group] Fix parent checkbox ignoring custom `id` prop (#2691) --- .../useCheckboxGroupParent.test.tsx | 51 +++++++++++++++++++ .../checkbox-group/useCheckboxGroupParent.ts | 8 ++- .../react/src/checkbox/root/CheckboxRoot.tsx | 6 +-- 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/packages/react/src/checkbox-group/useCheckboxGroupParent.test.tsx b/packages/react/src/checkbox-group/useCheckboxGroupParent.test.tsx index 9d9703be17..fbb735b0dd 100644 --- a/packages/react/src/checkbox-group/useCheckboxGroupParent.test.tsx +++ b/packages/react/src/checkbox-group/useCheckboxGroupParent.test.tsx @@ -159,6 +159,57 @@ describe('useCheckboxGroupParent', () => { expect(parent).to.have.attribute('aria-controls', allValues.map((v) => `${id}-${v}`).join(' ')); }); + it('should honor custom id on parent checkbox', () => { + function App() { + const [value, setValue] = React.useState([]); + return ( + + + + + + + ); + } + + render(); + + const parent = screen.getByTestId('parent'); + + expect(parent).to.have.attribute('id', 'custom-parent-id'); + expect(parent).to.have.attribute( + 'aria-controls', + allValues.map((v) => `custom-parent-id-${v}`).join(' '), + ); + }); + + it('should allow label association via htmlFor with custom id', () => { + function App() { + const [value, setValue] = React.useState([]); + return ( + + + + + + + + ); + } + + render(); + + const parent = screen.getByTestId('parent'); + const label = screen.getByText('Select All'); + + expect(parent).to.have.attribute('id', 'parent-checkbox-id'); + expect(label).to.have.attribute('for', 'parent-checkbox-id'); + + // Click the label should toggle the parent checkbox + fireEvent.click(label); + expect(parent).to.have.attribute('aria-checked', 'true'); + }); + it('preserves initial state if mixed when parent is clicked', () => { function App() { const [value, setValue] = React.useState([]); diff --git a/packages/react/src/checkbox-group/useCheckboxGroupParent.ts b/packages/react/src/checkbox-group/useCheckboxGroupParent.ts index 73118e0b18..ee696a8650 100644 --- a/packages/react/src/checkbox-group/useCheckboxGroupParent.ts +++ b/packages/react/src/checkbox-group/useCheckboxGroupParent.ts @@ -23,11 +23,10 @@ export function useCheckboxGroupParent( const onValueChange = useEventCallback(onValueChangeProp); const getParentProps: useCheckboxGroupParent.ReturnValue['getParentProps'] = React.useCallback( - () => ({ - id, + (parentId?: string) => ({ indeterminate, checked, - 'aria-controls': allValues.map((v) => `${id}-${v}`).join(' '), + 'aria-controls': allValues.map((v) => `${parentId ?? id}-${v}`).join(' '), onCheckedChange(_, eventDetails) { const uncontrolledState = uncontrolledStateRef.current; @@ -114,8 +113,7 @@ export namespace useCheckboxGroupParent { id: string | undefined; indeterminate: boolean; disabledStatesRef: React.RefObject>; - getParentProps: () => { - id: string | undefined; + getParentProps: (parentId?: string) => { indeterminate: boolean; checked: boolean; 'aria-controls': string; diff --git a/packages/react/src/checkbox/root/CheckboxRoot.tsx b/packages/react/src/checkbox/root/CheckboxRoot.tsx index 84dffbb635..3eac4be3c9 100644 --- a/packages/react/src/checkbox/root/CheckboxRoot.tsx +++ b/packages/react/src/checkbox/root/CheckboxRoot.tsx @@ -78,10 +78,12 @@ export const CheckboxRoot = React.forwardRef(function CheckboxRoot( const name = fieldName ?? nameProp; const value = valueProp ?? name; + const id = useBaseUiId(idProp); + let groupProps: Partial> = {}; if (isGrouped) { if (parent) { - groupProps = groupContext.parent.getParentProps(); + groupProps = groupContext.parent.getParentProps(id); } else if (value) { groupProps = groupContext.parent.getChildProps(value); } @@ -119,8 +121,6 @@ export const CheckboxRoot = React.forwardRef(function CheckboxRoot( state: 'checked', }); - const id = useBaseUiId(idProp); - useIsoLayoutEffect(() => { const element = controlRef?.current; if (!element) { From e75beb285f5c570214a6412e804c1938a58acbba Mon Sep 17 00:00:00 2001 From: Edmo Lima Date: Fri, 3 Oct 2025 08:11:30 +0200 Subject: [PATCH 2/2] [checkbox group] Fix child checkbox ignoring custom uid=501(edmo) gid=20(staff) groups=20(staff),12(everyone),61(localaccounts),79(_appserverusr),80(admin),81(_appserveradm),33(_appstore),98(_lpadmin),100(_lpoperator),204(_developer),250(_analyticsusers),395(com.apple.access_ftp),398(com.apple.access_screensharing),399(com.apple.access_ssh),400(com.apple.access_remote_ae),701(com.apple.sharepoint.group.1) prop --- .../useCheckboxGroupParent.test.tsx | 27 +++++++++++++++++++ .../checkbox-group/useCheckboxGroupParent.ts | 6 ++--- .../react/src/checkbox/root/CheckboxRoot.tsx | 2 +- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/react/src/checkbox-group/useCheckboxGroupParent.test.tsx b/packages/react/src/checkbox-group/useCheckboxGroupParent.test.tsx index fbb735b0dd..b138d61ae2 100644 --- a/packages/react/src/checkbox-group/useCheckboxGroupParent.test.tsx +++ b/packages/react/src/checkbox-group/useCheckboxGroupParent.test.tsx @@ -210,6 +210,33 @@ describe('useCheckboxGroupParent', () => { expect(parent).to.have.attribute('aria-checked', 'true'); }); + it('should allow label association via htmlFor with custom id on child checkbox', () => { + function App() { + const [value, setValue] = React.useState([]); + return ( + + + + + + + + ); + } + + render(); + + const childA = screen.getByTestId('child-a'); + const label = screen.getByText('Option A'); + + expect(childA).to.have.attribute('id', 'child-checkbox-a'); + expect(label).to.have.attribute('for', 'child-checkbox-a'); + + // Click the label should toggle the child checkbox + fireEvent.click(label); + expect(childA).to.have.attribute('aria-checked', 'true'); + }); + it('preserves initial state if mixed when parent is clicked', () => { function App() { const [value, setValue] = React.useState([]); diff --git a/packages/react/src/checkbox-group/useCheckboxGroupParent.ts b/packages/react/src/checkbox-group/useCheckboxGroupParent.ts index ee696a8650..c6b8feb563 100644 --- a/packages/react/src/checkbox-group/useCheckboxGroupParent.ts +++ b/packages/react/src/checkbox-group/useCheckboxGroupParent.ts @@ -71,9 +71,9 @@ export function useCheckboxGroupParent( ); const getChildProps: useCheckboxGroupParent.ReturnValue['getChildProps'] = React.useCallback( - (name: string) => ({ + (name: string, childId?: string) => ({ name, - id: `${id}-${name}`, + id: childId ?? `${id}-${name}`, checked: value.includes(name), onCheckedChange(nextChecked, eventDetails) { const newValue = value.slice(); @@ -119,7 +119,7 @@ export namespace useCheckboxGroupParent { 'aria-controls': string; onCheckedChange: (checked: boolean, eventDetails: BaseUIChangeEventDetails<'none'>) => void; }; - getChildProps: (name: string) => { + getChildProps: (name: string, childId?: string) => { name: string; id: string; checked: boolean; diff --git a/packages/react/src/checkbox/root/CheckboxRoot.tsx b/packages/react/src/checkbox/root/CheckboxRoot.tsx index 3eac4be3c9..9bef58b9f1 100644 --- a/packages/react/src/checkbox/root/CheckboxRoot.tsx +++ b/packages/react/src/checkbox/root/CheckboxRoot.tsx @@ -85,7 +85,7 @@ export const CheckboxRoot = React.forwardRef(function CheckboxRoot( if (parent) { groupProps = groupContext.parent.getParentProps(id); } else if (value) { - groupProps = groupContext.parent.getChildProps(value); + groupProps = groupContext.parent.getChildProps(value, id); } }