diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx index 79dc050a68..fd62ee7da8 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/__tests__/utils.test.tsx @@ -480,12 +480,13 @@ describe('ConnectedNestedCheckboxes utils', () => { const result = renderCheckbox({ option: mockOption, state, - checkboxId: 'test-id', + name: 'test-id', isRequired: true, isDisabled: false, onBlur: mockOnBlur, onChange: mockOnChange, ref: mockRef, + flatOptions: [mockOption], }); const { container } = render(result); @@ -503,12 +504,13 @@ describe('ConnectedNestedCheckboxes utils', () => { const result = renderCheckbox({ option: mockOption, state, - checkboxId: 'test-id', + name: 'test-id', isRequired: false, isDisabled: false, onBlur: mockOnBlur, onChange: mockOnChange, ref: mockRef, + flatOptions: [mockOption], }); const { container } = render(result); @@ -525,12 +527,13 @@ describe('ConnectedNestedCheckboxes utils', () => { const result = renderCheckbox({ option: mockOption, state, - checkboxId: 'test-id', + name: 'test-id', isRequired: false, isDisabled: false, onBlur: mockOnBlur, onChange: mockOnChange, ref: mockRef, + flatOptions: [mockOption], }); const { container } = render(result); @@ -547,12 +550,13 @@ describe('ConnectedNestedCheckboxes utils', () => { const result = renderCheckbox({ option: { ...mockOption, level: 2 }, state, - checkboxId: 'test-id', + name: 'test-id', isRequired: false, isDisabled: false, onBlur: mockOnBlur, onChange: mockOnChange, ref: mockRef, + flatOptions: [{ ...mockOption, level: 2 }], }); const { container } = render(result); @@ -567,12 +571,13 @@ describe('ConnectedNestedCheckboxes utils', () => { const result = renderCheckbox({ option: { ...mockOption, disabled: true }, state, - checkboxId: 'test-id', + name: 'test-id', isRequired: false, isDisabled: true, onBlur: mockOnBlur, onChange: mockOnChange, ref: mockRef, + flatOptions: [{ ...mockOption, disabled: true }], }); const { container } = render(result); @@ -587,13 +592,14 @@ describe('ConnectedNestedCheckboxes utils', () => { const result = renderCheckbox({ option: mockOption, state, - checkboxId: 'test-id', + name: 'test-id', isRequired: false, isDisabled: false, onBlur: mockOnBlur, onChange: mockOnChange, ref: mockRef, error: true, + flatOptions: [mockOption], }); const { container } = render(result); @@ -612,12 +618,13 @@ describe('ConnectedNestedCheckboxes utils', () => { const result = renderCheckbox({ option: optionWithAriaLabel, state, - checkboxId: 'test-id', + name: 'test-id', isRequired: false, isDisabled: false, onBlur: mockOnBlur, onChange: mockOnChange, ref: mockRef, + flatOptions: [optionWithAriaLabel], }); const { container } = render(result); @@ -632,12 +639,13 @@ describe('ConnectedNestedCheckboxes utils', () => { const result = renderCheckbox({ option: mockOption, state, - checkboxId: 'test-id', + name: 'test-id', isRequired: false, isDisabled: false, onBlur: mockOnBlur, onChange: mockOnChange, ref: mockRef, + flatOptions: [mockOption], }); const { container } = render(result); @@ -656,12 +664,13 @@ describe('ConnectedNestedCheckboxes utils', () => { const result = renderCheckbox({ option: optionWithElementLabel as any, // ts should prevent this from ever happening but we have a default just in case state, - checkboxId: 'test-id', + name: 'test-id', isRequired: false, isDisabled: false, onBlur: mockOnBlur, onChange: mockOnChange, ref: mockRef, + flatOptions: [optionWithElementLabel as any], }); const { container } = render(result); @@ -669,5 +678,94 @@ describe('ConnectedNestedCheckboxes utils', () => { expect(checkbox).toHaveAttribute('aria-label', 'checkbox'); }); + + it('should generate aria-controls with all nested descendants', () => { + const state = { checked: false }; + const flatOptions = [ + { + value: 'parent', + level: 0, + parentValue: undefined, + options: ['child1', 'child2'], + label: 'Parent', + }, + { + value: 'child1', + level: 1, + parentValue: 'parent', + options: ['grandchild1'], + label: 'Child 1', + }, + { + value: 'child2', + level: 1, + parentValue: 'parent', + options: [], + label: 'Child 2', + }, + { + value: 'grandchild1', + level: 2, + parentValue: 'child1', + options: [], + label: 'Grandchild 1', + }, + ]; + + const parentOption = flatOptions[0]; + + const result = renderCheckbox({ + option: parentOption, + state, + name: 'test-parent', + isRequired: false, + isDisabled: false, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + flatOptions, + }); + + const { container } = render(result); + const checkbox = container.querySelector('input[type="checkbox"]'); + + // Should include all descendants (child1, grandchild1, child2), not just immediate children + expect(checkbox).toHaveAttribute( + 'aria-controls', + 'test-parent-child1 test-parent-grandchild1 test-parent-child2' + ); + }); + + it('should not have aria-controls for leaf nodes', () => { + const state = { checked: false }; + const flatOptions = [ + { + value: 'leaf', + level: 0, + parentValue: undefined, + options: [], + label: 'Leaf', + }, + ]; + + const leafOption = flatOptions[0]; + + const result = renderCheckbox({ + option: leafOption, + state, + name: 'test-leaf', + isRequired: false, + isDisabled: false, + onBlur: mockOnBlur, + onChange: mockOnChange, + ref: mockRef, + flatOptions, + }); + + const { container } = render(result); + const checkbox = container.querySelector('input[type="checkbox"]'); + + expect(checkbox).not.toHaveAttribute('aria-controls'); + }); }); }); diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx index afce7c0d6f..c42d3b288b 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/index.tsx @@ -61,7 +61,7 @@ export const ConnectedNestedCheckboxes: React.FC< return renderCheckbox({ option: { ...option, spacing }, state, - checkboxId: `${name}-${option.value}`, + name, isRequired, isDisabled, onBlur, @@ -76,6 +76,7 @@ export const ConnectedNestedCheckboxes: React.FC< }); }, ref, + flatOptions, }); })} diff --git a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx index af6017f531..45d387f160 100644 --- a/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx +++ b/packages/gamut/src/ConnectedForm/ConnectedInputs/ConnectedNestedCheckboxes/utils.tsx @@ -154,25 +154,27 @@ export const handleCheckboxChange = ({ interface RenderCheckboxParams { option: FlatCheckbox; state: FlatCheckboxState; - checkboxId: string; + name: string; isRequired: boolean; isDisabled: boolean; onBlur: () => void; onChange: (event: React.ChangeEvent) => void; ref: React.RefCallback; error?: boolean; + flatOptions: FlatCheckbox[]; } export const renderCheckbox = ({ option, state, - checkboxId, + name, isRequired, isDisabled, onBlur, onChange, ref, error, + flatOptions, }: RenderCheckboxParams) => { let checkedProps = {}; if (state.checked) { @@ -193,6 +195,16 @@ export const renderCheckbox = ({ }; } + const checkboxId = `${name}-${option.value}`; + + // Generate aria-controls for parent checkboxes with all nested descendants + const ariaControls = + option.options.length > 0 + ? getAllDescendants(option.value, flatOptions) + .map((childValue) => `${name}-${childValue}`) + .join(' ') + : undefined; + return (