Skip to content

Commit 35e3216

Browse files
authored
[checkbox][switch][radio] Add automatic aria-labelledby support (#4142)
1 parent c6b35f2 commit 35e3216

File tree

13 files changed

+414
-24
lines changed

13 files changed

+414
-24
lines changed

packages/react/src/checkbox/root/CheckboxRoot.test.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as React from 'react';
22
import { expect } from 'chai';
33
import { spy } from 'sinon';
4-
import { act, fireEvent, screen } from '@mui/internal-test-utils';
4+
import { act, fireEvent, screen, waitFor } from '@mui/internal-test-utils';
55
import { Checkbox } from '@base-ui/react/checkbox';
66
import { CheckboxGroup } from '@base-ui/react/checkbox-group';
77
import { Field } from '@base-ui/react/field';
@@ -1000,6 +1000,54 @@ describe('<Checkbox.Root />', () => {
10001000
expect(checkbox).to.have.attribute('aria-checked', 'false');
10011001
});
10021002

1003+
it('sets `aria-labelledby` from a sibling label associated with the hidden input', async () => {
1004+
await render(
1005+
<div>
1006+
<label htmlFor="checkbox-input">Label</label>
1007+
<Checkbox.Root id="checkbox-input" />
1008+
</div>,
1009+
);
1010+
1011+
const label = screen.getByText('Label');
1012+
expect(label.id).not.to.equal('');
1013+
expect(screen.getByRole('checkbox')).to.have.attribute('aria-labelledby', label.id);
1014+
});
1015+
1016+
it('updates fallback `aria-labelledby` when the hidden input id changes', async () => {
1017+
function TestCase() {
1018+
const [id, setId] = React.useState('checkbox-input-a');
1019+
1020+
return (
1021+
<React.Fragment>
1022+
<label htmlFor="checkbox-input-a">Label A</label>
1023+
<label htmlFor="checkbox-input-b">Label B</label>
1024+
<Checkbox.Root id={id} />
1025+
<button type="button" onClick={() => setId('checkbox-input-b')}>
1026+
Toggle
1027+
</button>
1028+
</React.Fragment>
1029+
);
1030+
}
1031+
1032+
await render(<TestCase />);
1033+
1034+
const checkbox = screen.getByRole('checkbox');
1035+
const labelA = screen.getByText('Label A');
1036+
1037+
expect(labelA.id).to.not.equal('');
1038+
expect(checkbox).to.have.attribute('aria-labelledby', labelA.id);
1039+
1040+
fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
1041+
1042+
await waitFor(() => {
1043+
const labelB = screen.getByText('Label B');
1044+
1045+
expect(labelB.id).to.not.equal('');
1046+
expect(labelA.id).to.not.equal(labelB.id);
1047+
expect(checkbox).to.have.attribute('aria-labelledby', labelB.id);
1048+
});
1049+
});
1050+
10031051
it('can render a native button', async () => {
10041052
const { container, user } = await render(<Checkbox.Root render={<button />} nativeButton />);
10051053

packages/react/src/checkbox/root/CheckboxRoot.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { useFieldItemContext } from '../../field/item/FieldItemContext';
2020
import { useField } from '../../field/useField';
2121
import { useFormContext } from '../../form/FormContext';
2222
import { useLabelableContext } from '../../labelable-provider/LabelableContext';
23+
import { useAriaLabelledBy } from '../../labelable-provider/useAriaLabelledBy';
2324
import { useCheckboxGroupContext } from '../../checkbox-group/CheckboxGroupContext';
2425
import { CheckboxRootContext } from './CheckboxRootContext';
2526
import {
@@ -45,6 +46,7 @@ export const CheckboxRoot = React.forwardRef(function CheckboxRoot(
4546
checked: checkedProp,
4647
className,
4748
defaultChecked = false,
49+
'aria-labelledby': ariaLabelledByProp,
4850
disabled: disabledProp = false,
4951
id: idProp,
5052
indeterminate = false,
@@ -175,6 +177,13 @@ export const CheckboxRoot = React.forwardRef(function CheckboxRoot(
175177

176178
const inputRef = React.useRef<HTMLInputElement>(null);
177179
const mergedInputRef = useMergedRefs(inputRefProp, inputRef, validation.inputRef);
180+
const ariaLabelledBy = useAriaLabelledBy(
181+
ariaLabelledByProp,
182+
labelId,
183+
inputRef,
184+
!nativeButton,
185+
inputId ?? undefined,
186+
);
178187

179188
useIsoLayoutEffect(() => {
180189
if (inputRef.current) {
@@ -297,7 +306,7 @@ export const CheckboxRoot = React.forwardRef(function CheckboxRoot(
297306
'aria-checked': groupIndeterminate ? 'mixed' : checked,
298307
'aria-readonly': readOnly || undefined,
299308
'aria-required': required || undefined,
300-
'aria-labelledby': labelId,
309+
'aria-labelledby': ariaLabelledBy,
301310
[PARENT_CHECKBOX as string]: parent ? '' : undefined,
302311
onFocus() {
303312
setFocused(true);

packages/react/src/field/item/FieldItem.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const FieldItem = React.forwardRef(function FieldItem(
3131
// this a more reliable check
3232
const hasParentCheckbox = checkboxGroupContext?.allValues !== undefined;
3333

34-
const initialControlId = hasParentCheckbox ? parentId : undefined;
34+
const controlId = hasParentCheckbox ? parentId : undefined;
3535

3636
const fieldItemContext: FieldItemContext = React.useMemo(() => ({ disabled }), [disabled]);
3737

@@ -43,7 +43,7 @@ export const FieldItem = React.forwardRef(function FieldItem(
4343
});
4444

4545
return (
46-
<LabelableProvider initialControlId={initialControlId}>
46+
<LabelableProvider controlId={controlId}>
4747
<FieldItemContext.Provider value={fieldItemContext}>{element}</FieldItemContext.Provider>
4848
</LabelableProvider>
4949
);

packages/react/src/field/label/FieldLabel.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ export const FieldLabel = React.forwardRef(function FieldLabel(
2929

3030
const fieldRootContext = useFieldRootContext(false);
3131

32-
const { controlId, setLabelId, labelId } = useLabelableContext();
32+
const { controlId, setLabelId, labelId: contextLabelId } = useLabelableContext();
3333

34-
const id = useBaseUiId(idProp);
34+
const generatedLabelId = useBaseUiId(idProp);
35+
const labelId = idProp ?? contextLabelId ?? generatedLabelId;
3536

3637
const labelRef = React.useRef<HTMLElement | null>(null);
3738

@@ -92,14 +93,12 @@ export const FieldLabel = React.forwardRef(function FieldLabel(
9293
}
9394

9495
useIsoLayoutEffect(() => {
95-
if (id) {
96-
setLabelId(id);
97-
}
96+
setLabelId(labelId);
9897

9998
return () => {
10099
setLabelId(undefined);
101100
};
102-
}, [id, setLabelId]);
101+
}, [labelId, setLabelId]);
103102

104103
const element = useRenderElement('label', componentProps, {
105104
ref: [forwardedRef, labelRef],

packages/react/src/field/root/FieldRoot.test.tsx

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ import {
2020
import { vi } from 'vitest';
2121
import { expect } from 'chai';
2222
import { spy } from 'sinon';
23-
import { createRenderer, describeConformance } from '#test-utils';
23+
import { createRenderer, describeConformance, isJSDOM } from '#test-utils';
2424
import { LabelableProvider } from '../../labelable-provider';
2525

2626
describe('<Field.Root />', () => {
27-
const { render } = createRenderer();
27+
const { render, renderToString } = createRenderer();
2828
const { render: renderStrict } = createRenderer({ strict: true });
2929

3030
describeConformance(<Field.Root />, () => ({
@@ -68,7 +68,7 @@ describe('<Field.Root />', () => {
6868
it('preserves null initial control ids', async () => {
6969
await render(
7070
<Field.Root>
71-
<LabelableProvider initialControlId={null}>
71+
<LabelableProvider controlId={null}>
7272
<Field.Label>Label</Field.Label>
7373
<Field.Control data-testid="control" />
7474
</LabelableProvider>
@@ -149,6 +149,116 @@ describe('<Field.Root />', () => {
149149
});
150150
});
151151

152+
it.skipIf(isJSDOM)('does not set `aria-labelledby` during SSR when Field.Label is absent', () => {
153+
renderToString(
154+
<Field.Root>
155+
<Select.Root>
156+
<Select.Trigger data-testid="trigger">
157+
<Select.Value placeholder="Pick one" />
158+
</Select.Trigger>
159+
</Select.Root>
160+
</Field.Root>,
161+
);
162+
163+
expect(screen.getByTestId('trigger')).to.not.have.attribute('aria-labelledby');
164+
});
165+
166+
it.skipIf(isJSDOM)(
167+
'keeps `aria-labelledby` valid when toggling from Checkbox.Root to Select.Root after hydration',
168+
async () => {
169+
function TestCase() {
170+
const [showSelect, setShowSelect] = React.useState(false);
171+
172+
return (
173+
<React.Fragment>
174+
<Field.Root>
175+
<Field.Label nativeLabel={false} render={<div />} data-testid="label">
176+
Label
177+
</Field.Label>
178+
{showSelect ? (
179+
<Select.Root>
180+
<Select.Trigger data-testid="trigger">
181+
<Select.Value placeholder="Pick one" />
182+
</Select.Trigger>
183+
</Select.Root>
184+
) : (
185+
<Checkbox.Root data-testid="checkbox" />
186+
)}
187+
</Field.Root>
188+
<button type="button" onClick={() => setShowSelect((prev) => !prev)}>
189+
Toggle
190+
</button>
191+
</React.Fragment>
192+
);
193+
}
194+
195+
const { hydrate } = renderToString(<TestCase />);
196+
const label = screen.getByTestId('label');
197+
const checkbox = screen.getByTestId('checkbox');
198+
199+
expect(label.id).to.not.equal('');
200+
expect(checkbox).to.not.have.attribute('aria-labelledby');
201+
202+
hydrate();
203+
await waitFor(() => {
204+
expect(screen.getByTestId('checkbox')).to.have.attribute('aria-labelledby', label.id);
205+
});
206+
fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
207+
208+
const trigger = screen.getByTestId('trigger');
209+
expect(trigger).to.have.attribute('aria-labelledby', label.id);
210+
211+
fireEvent.click(screen.getByRole('button', { name: 'Toggle' }));
212+
213+
const checkboxAfterToggle = screen.getByTestId('checkbox');
214+
expect(checkboxAfterToggle).to.have.attribute('aria-labelledby', label.id);
215+
},
216+
);
217+
218+
it.skipIf(isJSDOM)(
219+
'removes `aria-labelledby` when Field.Label is removed after hydration',
220+
async () => {
221+
function TestCase() {
222+
const [showLabel, setShowLabel] = React.useState(true);
223+
224+
return (
225+
<React.Fragment>
226+
<Field.Root>
227+
{showLabel ? (
228+
<Field.Label nativeLabel={false} render={<div />} data-testid="label">
229+
Label
230+
</Field.Label>
231+
) : null}
232+
<Select.Root>
233+
<Select.Trigger data-testid="trigger">
234+
<Select.Value placeholder="Pick one" />
235+
</Select.Trigger>
236+
</Select.Root>
237+
</Field.Root>
238+
<button type="button" onClick={() => setShowLabel(false)}>
239+
Remove Label
240+
</button>
241+
</React.Fragment>
242+
);
243+
}
244+
245+
const { hydrate } = renderToString(<TestCase />);
246+
const label = screen.getByTestId('label');
247+
const trigger = screen.getByTestId('trigger');
248+
249+
expect(trigger).to.not.have.attribute('aria-labelledby');
250+
251+
hydrate();
252+
await waitFor(() => {
253+
expect(screen.getByTestId('trigger')).to.have.attribute('aria-labelledby', label.id);
254+
});
255+
fireEvent.click(screen.getByRole('button', { name: 'Remove Label' }));
256+
257+
expect(screen.queryByTestId('label')).to.equal(null);
258+
expect(screen.getByTestId('trigger')).to.not.have.attribute('aria-labelledby');
259+
},
260+
);
261+
152262
it.skipIf(reactMajor < 19)(
153263
'does not loop when a control is unmounted and remounted',
154264
async () => {

packages/react/src/fieldset/legend/FieldsetLegend.test.tsx

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { createRenderer, screen } from '@mui/internal-test-utils';
1+
import { createRenderer, screen, waitFor } from '@mui/internal-test-utils';
22
import { Fieldset } from '@base-ui/react/fieldset';
33
import { expect } from 'chai';
4-
import { describeConformance } from '../../../test/describeConformance';
4+
import { describeConformance, isJSDOM } from '#test-utils';
55

66
describe('<Fieldset.Legend />', () => {
7-
const { render } = createRenderer();
7+
const { render, renderToString } = createRenderer();
88

99
describeConformance(<Fieldset.Legend />, () => ({
1010
refInstanceof: window.HTMLDivElement,
@@ -35,4 +35,33 @@ describe('<Fieldset.Legend />', () => {
3535

3636
expect(screen.getByRole('group')).to.have.attribute('aria-labelledby', 'legend-id');
3737
});
38+
39+
it.skipIf(isJSDOM)('does not set `aria-labelledby` during SSR when legend is absent', () => {
40+
renderToString(<Fieldset.Root data-testid="fieldset" />);
41+
42+
expect(screen.getByTestId('fieldset')).to.not.have.attribute('aria-labelledby');
43+
});
44+
45+
it.skipIf(isJSDOM)(
46+
'sets `aria-labelledby` after hydration without a custom legend id',
47+
async () => {
48+
const { hydrate } = renderToString(
49+
<Fieldset.Root data-testid="fieldset">
50+
<Fieldset.Legend data-testid="legend">Legend</Fieldset.Legend>
51+
</Fieldset.Root>,
52+
);
53+
54+
const fieldset = screen.getByTestId('fieldset');
55+
const legend = screen.getByTestId('legend');
56+
57+
expect(legend.id).to.not.equal('');
58+
expect(fieldset).to.not.have.attribute('aria-labelledby');
59+
60+
hydrate();
61+
62+
await waitFor(() => {
63+
expect(screen.getByTestId('fieldset')).to.have.attribute('aria-labelledby', legend.id);
64+
});
65+
},
66+
);
3867
});

packages/react/src/fieldset/root/FieldsetRoot.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ export const FieldsetRoot = React.forwardRef(function FieldsetRoot(
1515
forwardedRef: React.ForwardedRef<HTMLElement>,
1616
) {
1717
const { render, className, disabled = false, ...elementProps } = componentProps;
18-
1918
const [legendId, setLegendId] = React.useState<string | undefined>(undefined);
2019

2120
const state: FieldsetRoot.State = {

packages/react/src/labelable-provider/LabelableProvider.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ export const LabelableProvider: React.FC<LabelableProvider.Props> = function Lab
1414
props,
1515
) {
1616
const defaultId = useBaseUiId();
17+
const initialControlId = props.controlId === undefined ? defaultId : props.controlId;
1718

1819
const [controlId, setControlIdState] = React.useState<string | null | undefined>(
19-
props.initialControlId === undefined ? defaultId : props.initialControlId,
20+
initialControlId,
2021
);
21-
const [labelId, setLabelId] = React.useState<string | undefined>(undefined);
22+
const [labelId, setLabelId] = React.useState<string | undefined>(props.labelId);
2223
const [messageIds, setMessageIds] = React.useState<string[]>([]);
2324

2425
const registrationsRef = useRefWithInit(() => new Map<symbol, string | null>());
@@ -98,7 +99,8 @@ export const LabelableProvider: React.FC<LabelableProvider.Props> = function Lab
9899
};
99100

100101
export interface LabelableProviderProps {
101-
initialControlId?: string | null | undefined;
102+
controlId?: string | null | undefined;
103+
labelId?: string | undefined;
102104
children?: React.ReactNode;
103105
}
104106

0 commit comments

Comments
 (0)