Skip to content

Commit bfb7a1b

Browse files
committed
feat: require an accessible name on a standalone checkbox
A standalone checkbox renders no visible label, and `aria-label` reached it only by inheritance, so it appeared in no prop table and nothing flagged one that shipped unnamed. `Checkbox.Item` names the row and is exempt.
1 parent 9c1fb3e commit bfb7a1b

2 files changed

Lines changed: 61 additions & 0 deletions

File tree

src/components/Checkbox/Checkbox.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ export type Props = $RemoveChildren<typeof TouchableRipple> & {
7878
* the underlying `TouchableRipple`.
7979
*/
8080
style?: StyleProp<ViewStyle>;
81+
/**
82+
* Accessibility label for the checkbox, read by a screen reader in place of
83+
* a visible label. A standalone `Checkbox` has no label of its own, so it
84+
* needs one here. `Checkbox.Item` names the whole row instead and does not
85+
* require it.
86+
*/
87+
'aria-label'?: string;
8188
};
8289

8390
// Spec dimensions (https://m3.material.io/components/checkbox/specs).
@@ -124,6 +131,12 @@ const RIPPLE_START_SCALE = 0.6;
124131
*
125132
* export default MyComponent;
126133
* ```
134+
*
135+
* ## Accessibility
136+
* A standalone `Checkbox` renders no visible label, so give it an `aria-label`
137+
* to name it for assistive tech. Use `Checkbox.Item` when you want a labelled
138+
* row: it owns the accessible name and keeps the inner checkbox out of the
139+
* accessibility tree so the state is announced once.
127140
*/
128141
const Checkbox = ({
129142
status,
@@ -391,6 +404,23 @@ const Checkbox = ({
391404
rippleScale.value = RIPPLE_START_SCALE;
392405
}, [isInteractive, pressedSV, rippleHoldSV, rippleAlpha, rippleScale]);
393406

407+
// `Checkbox.Item` names the row and passes `accessible={false}` here.
408+
const isInAccessibilityTree = rest.accessible !== false;
409+
const hasAccessibleName = Boolean(
410+
rest['aria-label'] ??
411+
rest.accessibilityLabel ??
412+
rest['aria-labelledby'] ??
413+
rest.accessibilityLabelledBy
414+
);
415+
416+
React.useEffect(() => {
417+
if (!isInAccessibilityTree || hasAccessibleName) return;
418+
419+
console.warn(
420+
'Checkbox: pass `aria-label` to name the checkbox for assistive tech, or use `Checkbox.Item` for a labelled row.'
421+
);
422+
}, [isInAccessibilityTree, hasAccessibleName]);
423+
394424
const checked: boolean | 'mixed' =
395425
status === 'indeterminate' ? 'mixed' : status === 'checked';
396426

src/components/__tests__/Checkbox/Checkbox.test.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,37 @@ describe('Checkbox focus ring', () => {
506506
});
507507
});
508508

509+
describe('Checkbox accessible name', () => {
510+
it('warns when a standalone checkbox has no accessible name', async () => {
511+
jest.spyOn(console, 'warn').mockImplementation(() => {});
512+
513+
await render(<Checkbox status="checked" onPress={() => {}} />);
514+
515+
expect(console.warn).toHaveBeenCalledWith(
516+
expect.stringContaining('aria-label')
517+
);
518+
});
519+
520+
it('is named by aria-label', async () => {
521+
jest.spyOn(console, 'warn').mockImplementation(() => {});
522+
523+
await render(
524+
<Checkbox status="checked" onPress={() => {}} aria-label="Notify me" />
525+
);
526+
527+
expect(screen.getByLabelText('Notify me')).toBeOnTheScreen();
528+
expect(console.warn).not.toHaveBeenCalled();
529+
});
530+
531+
it('does not warn for the checkbox inside a labelled Checkbox.Item', async () => {
532+
jest.spyOn(console, 'warn').mockImplementation(() => {});
533+
534+
await render(<Checkbox.Item label="Notify me" status="checked" />);
535+
536+
expect(console.warn).not.toHaveBeenCalled();
537+
});
538+
});
539+
509540
it('renders the focus ring outside the pressable so clipping cannot crop it', async () => {
510541
await render(
511542
<Checkbox

0 commit comments

Comments
 (0)