Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-checkbox-bubble-input-a11y-3167.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@radix-ui/react-checkbox': patch
---

Fix bubble input accessibility by using the native `hidden` attribute instead of `aria-hidden`, and mirror label association from the control to the hidden input.
39 changes: 24 additions & 15 deletions packages/react/checkbox/src/checkbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,27 @@ describe('Checkbox', () => {
expect(onChange).toHaveBeenCalledWith(false);
});
});

// Regression test for https://github.com/radix-ui/primitives/issues/3167
describe('given a Checkbox with label association in a form', () => {
it('should hide the bubble input with the native hidden attribute', async () => {
const rendered = render(
<form>
<label htmlFor="pikachu">
Pikachu
<Checkbox.Root id="pikachu" name="Pikachu" value="Pikachu">
<Checkbox.Indicator data-testid={INDICATOR_TEST_ID} />
</Checkbox.Root>
</label>
</form>,
);

const input = rendered.container.querySelector('input[type="checkbox"]');
expect(input).toHaveAttribute('hidden');
expect(input).not.toHaveAttribute('aria-hidden');
expect(await axe(rendered.container)).toHaveNoViolations();
});
});
});

describe('Legacy Checkbox', () => {
Expand Down Expand Up @@ -478,21 +499,9 @@ describe('Legacy Checkbox', () => {
});

function LegacyCheckbox(props: React.ComponentProps<typeof Checkbox.Root>) {
const containerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
// We use the `hidden` attribute to hide the nested input from both sighted users and the
// accessibility tree. This is perfectly valid so long as users don't override the display of
// `hidden` in CSS. Unfortunately axe doesn't recognize this, so we get a violation because the
// input doesn't have a label. This adds an additional `aria-hidden` attribute to the input to
// get around that.
// https://developer.paciellogroup.com/blog/2012/05/html5-accessibility-chops-hidden-and-aria-hidden/
containerRef.current?.querySelector('input')?.setAttribute('aria-hidden', 'true');
}, []);
return (
<div ref={containerRef}>
<Checkbox.Root aria-label="basic checkbox" {...props}>
<Checkbox.Indicator data-testid={INDICATOR_TEST_ID} />
</Checkbox.Root>
</div>
<Checkbox.Root aria-label="basic checkbox" {...props}>
<Checkbox.Indicator data-testid={INDICATOR_TEST_ID} />
</Checkbox.Root>
);
}
7 changes: 6 additions & 1 deletion packages/react/checkbox/src/checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -341,16 +341,21 @@ const CheckboxBubbleInput = React.forwardRef<HTMLInputElement, CheckboxBubbleInp
}, [bubbleInput, prevChecked, checked, hasConsumerStoppedPropagationRef]);

const defaultCheckedRef = React.useRef(isIndeterminate(checked) ? false : checked);
const controlAriaLabel = control?.getAttribute('aria-label') ?? undefined;
const controlAriaLabelledBy = control?.getAttribute('aria-labelledby') ?? undefined;

return (
<Primitive.input
type="checkbox"
aria-hidden
hidden
defaultChecked={defaultChecked ?? defaultCheckedRef.current}
required={required}
disabled={disabled}
name={name}
value={value}
form={form}
aria-label={controlAriaLabel}
aria-labelledby={controlAriaLabelledBy}
{...props}
tabIndex={-1}
ref={composedRefs}
Expand Down