From 393b02cbfdae82c88a3c3ed343e51dc963909357 Mon Sep 17 00:00:00 2001 From: gonzoblasco Date: Thu, 27 Aug 2026 09:21:02 -0300 Subject: [PATCH 1/2] fix: expose input and VisuallyHidden styling on RAC Checkbox and Radio Screen readers (VoiceOver, NVDA) draw their focus indicator around the native input element, not the visible component. In Checkbox and Radio the input is rendered inside VisuallyHidden, which collapses it to 1x1px, so the screen reader focus ring shows up as a tiny square disconnected from the visual focus. Expose inputClassName/inputStyle and visuallyHiddenClassName/visuallyHiddenStyle on Checkbox, CheckboxField, Radio, RadioField, CheckboxButton, and RadioButton so users can size and position both the hidden input and its VisuallyHidden wrapper to encompass the visible component. The props follow the existing inputRef pattern and are optional, so there is no behavior change by default. To make the screen reader focus ring match the component, the label must be a positioned containing block (position: relative), the VisuallyHidden wrapper must be stretched to the label (e.g. {inset: 0, width: 'auto', height: 'auto'}), and the input must fill the wrapper ({position: 'absolute', inset: 0, width: '100%', height: '100%'}). This is documented on the new props. Adds real-browser layout tests (not jsdom) that measure the hidden input's bounding box against the component, verifying the input covers the component rather than the viewport. This addresses the review feedback that jsdom cannot validate layout. Fixes #9687 --- .../react-aria-components/src/Checkbox.tsx | 117 +++++++++++++++- .../react-aria-components/src/RadioGroup.tsx | 130 +++++++++++++++++- .../test/Checkbox.sr-focus.browser.test.tsx | 96 +++++++++++++ .../test/Checkbox.test.js | 26 ++++ .../test/RadioGroup.test.js | 32 +++++ 5 files changed, 388 insertions(+), 13 deletions(-) create mode 100644 packages/react-aria-components/test/Checkbox.sr-focus.browser.test.tsx diff --git a/packages/react-aria-components/src/Checkbox.tsx b/packages/react-aria-components/src/Checkbox.tsx index fd2298af0d5..85e1a811bc8 100644 --- a/packages/react-aria-components/src/Checkbox.tsx +++ b/packages/react-aria-components/src/Checkbox.tsx @@ -39,7 +39,15 @@ import {HoverEvents} from '@react-types/shared'; import {LabelContext} from './Label'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; -import React, {createContext, ForwardedRef, forwardRef, Ref, useContext, useMemo} from 'react'; +import React, { + createContext, + CSSProperties, + ForwardedRef, + forwardRef, + Ref, + useContext, + useMemo +} from 'react'; import {TextContext} from './Text'; import {useFocusRing} from 'react-aria/useFocusRing'; import {useHover} from 'react-aria/useHover'; @@ -90,6 +98,29 @@ export interface CheckboxProps * A ref for the HTML input element. */ inputRef?: Ref; + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the + * HTML input element. + */ + inputClassName?: string; + /** + * The inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for the + * HTML input element. + */ + inputStyle?: CSSProperties; + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the + * VisuallyHidden wrapper around the HTML input element. + */ + visuallyHiddenClassName?: string; + /** + * The inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for the + * VisuallyHidden wrapper around the HTML input element. To make the screen reader focus ring + * match the component, stretch this wrapper to the label (e.g. `{inset: 0, width: 'auto', height: 'auto'}`) + * and set `position: relative` on the label (or a positioned ancestor) so the input resolves + * against it rather than the viewport. + */ + visuallyHiddenStyle?: CSSProperties; } export interface CheckboxFieldProps @@ -110,6 +141,29 @@ export interface CheckboxFieldProps * A ref for the HTML input element. */ inputRef?: Ref; + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the + * HTML input element. + */ + inputClassName?: string; + /** + * The inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for the + * HTML input element. + */ + inputStyle?: CSSProperties; + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the + * VisuallyHidden wrapper around the HTML input element. + */ + visuallyHiddenClassName?: string; + /** + * The inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for the + * VisuallyHidden wrapper around the HTML input element. To make the screen reader focus ring + * match the component, stretch this wrapper to the label (e.g. `{inset: 0, width: 'auto', height: 'auto'}`) + * and set `position: relative` on the label (or a positioned ancestor) so the input resolves + * against it rather than the viewport. + */ + visuallyHiddenStyle?: CSSProperties; } export interface CheckboxButtonProps @@ -125,6 +179,29 @@ export interface CheckboxButtonProps * @default 'react-aria-CheckboxButton' */ className?: ClassNameOrFunction; + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the + * HTML input element. + */ + inputClassName?: string; + /** + * The inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for the + * HTML input element. + */ + inputStyle?: CSSProperties; + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the + * VisuallyHidden wrapper around the HTML input element. + */ + visuallyHiddenClassName?: string; + /** + * The inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for the + * VisuallyHidden wrapper around the HTML input element. To make the screen reader focus ring + * match the component, stretch this wrapper to the label (e.g. `{inset: 0, width: 'auto', height: 'auto'}`) + * and set `position: relative` on the label (or a positioned ancestor) so the input resolves + * against it rather than the viewport. + */ + visuallyHiddenStyle?: CSSProperties; } export interface CheckboxGroupRenderProps { @@ -343,6 +420,10 @@ interface InternalCheckboxContextValue extends CheckboxAria { defaultClassName: string; isIndeterminate?: boolean; isRequired?: boolean; + inputClassName?: string; + inputStyle?: CSSProperties; + visuallyHiddenClassName?: string; + visuallyHiddenStyle?: CSSProperties; } const InternalCheckboxContext = createContext(null); @@ -406,7 +487,11 @@ export const CheckboxField = /*#__PURE__*/ (forwardRef as forwardRefType)(functi inputRef, defaultClassName: 'react-aria-CheckboxButton', isIndeterminate: props.isIndeterminate, - isRequired: props.isRequired + isRequired: props.isRequired, + inputClassName: props.inputClassName, + inputStyle: props.inputStyle, + visuallyHiddenClassName: props.visuallyHiddenClassName, + visuallyHiddenStyle: props.visuallyHiddenStyle } ], [ @@ -476,7 +561,11 @@ export const Checkbox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ch inputRef, defaultClassName: 'react-aria-Checkbox', isIndeterminate: props.isIndeterminate, - isRequired: props.isRequired + isRequired: props.isRequired, + inputClassName: props.inputClassName, + inputStyle: props.inputStyle, + visuallyHiddenClassName: props.visuallyHiddenClassName, + visuallyHiddenStyle: props.visuallyHiddenStyle }}> @@ -501,11 +590,22 @@ export const CheckboxButton = /*#__PURE__*/ (forwardRef as forwardRefType)(funct inputRef, defaultClassName, isIndeterminate, - isRequired + isRequired, + inputClassName, + inputStyle, + visuallyHiddenClassName, + visuallyHiddenStyle } = useContext(InternalCheckboxContext)!; let {isFocused, isFocusVisible, focusProps} = useFocusRing(); let isInteractionDisabled = isDisabled || isReadOnly; + // Allow inputClassName/inputStyle to be passed directly to CheckboxButton, + // taking precedence over values inherited from a wrapping Checkbox/CheckboxField. + inputClassName = props.inputClassName ?? inputClassName; + inputStyle = props.inputStyle ?? inputStyle; + visuallyHiddenClassName = props.visuallyHiddenClassName ?? visuallyHiddenClassName; + visuallyHiddenStyle = props.visuallyHiddenStyle ?? visuallyHiddenStyle; + let {hoverProps, isHovered} = useHover({ ...props, isDisabled: isInteractionDisabled @@ -547,8 +647,13 @@ export const CheckboxButton = /*#__PURE__*/ (forwardRef as forwardRefType)(funct data-readonly={isReadOnly || undefined} data-invalid={isInvalid || undefined} data-required={isRequired || undefined}> - - + + {renderProps.children} diff --git a/packages/react-aria-components/src/RadioGroup.tsx b/packages/react-aria-components/src/RadioGroup.tsx index caf52c62906..282df5f1a8d 100644 --- a/packages/react-aria-components/src/RadioGroup.tsx +++ b/packages/react-aria-components/src/RadioGroup.tsx @@ -40,7 +40,15 @@ import {LabelContext} from './Label'; import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; import {RadioGroupState, useRadioGroupState} from 'react-stately/useRadioGroupState'; -import React, {createContext, ForwardedRef, forwardRef, Ref, useContext, useMemo} from 'react'; +import React, { + createContext, + CSSProperties, + ForwardedRef, + forwardRef, + Ref, + useContext, + useMemo +} from 'react'; import {SelectionIndicatorContext} from './SelectionIndicator'; import {SharedElementTransition} from './SharedElementTransition'; import {TextContext} from './Text'; @@ -90,6 +98,29 @@ export interface RadioProps * A ref for the HTML input element. */ inputRef?: Ref; + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the + * HTML input element. + */ + inputClassName?: string; + /** + * The inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for the + * HTML input element. + */ + inputStyle?: CSSProperties; + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the + * VisuallyHidden wrapper around the HTML input element. + */ + visuallyHiddenClassName?: string; + /** + * The inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for the + * VisuallyHidden wrapper around the HTML input element. To make the screen reader focus ring + * match the component, stretch this wrapper to the label (e.g. `{inset: 0, width: 'auto', height: 'auto'}`) + * and set `position: relative` on the label (or a positioned ancestor) so the input resolves + * against it rather than the viewport. + */ + visuallyHiddenStyle?: CSSProperties; } export interface RadioFieldProps @@ -109,6 +140,29 @@ export interface RadioFieldProps * A ref for the HTML input element. */ inputRef?: Ref; + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the + * HTML input element. + */ + inputClassName?: string; + /** + * The inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for the + * HTML input element. + */ + inputStyle?: CSSProperties; + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the + * VisuallyHidden wrapper around the HTML input element. + */ + visuallyHiddenClassName?: string; + /** + * The inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for the + * VisuallyHidden wrapper around the HTML input element. To make the screen reader focus ring + * match the component, stretch this wrapper to the label (e.g. `{inset: 0, width: 'auto', height: 'auto'}`) + * and set `position: relative` on the label (or a positioned ancestor) so the input resolves + * against it rather than the viewport. + */ + visuallyHiddenStyle?: CSSProperties; } export interface RadioButtonProps @@ -124,6 +178,29 @@ export interface RadioButtonProps * @default 'react-aria-RadioButton' */ className?: ClassNameOrFunction; + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the + * HTML input element. + */ + inputClassName?: string; + /** + * The inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for the + * HTML input element. + */ + inputStyle?: CSSProperties; + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the + * VisuallyHidden wrapper around the HTML input element. + */ + visuallyHiddenClassName?: string; + /** + * The inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for the + * VisuallyHidden wrapper around the HTML input element. To make the screen reader focus ring + * match the component, stretch this wrapper to the label (e.g. `{inset: 0, width: 'auto', height: 'auto'}`) + * and set `position: relative` on the label (or a positioned ancestor) so the input resolves + * against it rather than the viewport. + */ + visuallyHiddenStyle?: CSSProperties; } export interface RadioGroupRenderProps { @@ -364,7 +441,15 @@ export const Radio = /*#__PURE__*/ (forwardRef as forwardRefType)(function Radio return ( + value={{ + ...aria, + inputRef, + defaultClassName: 'react-aria-Radio', + inputClassName: props.inputClassName, + inputStyle: props.inputStyle, + visuallyHiddenClassName: props.visuallyHiddenClassName, + visuallyHiddenStyle: props.visuallyHiddenStyle + }}> ); @@ -373,6 +458,10 @@ export const Radio = /*#__PURE__*/ (forwardRef as forwardRefType)(function Radio interface InternalRadioContextValue extends RadioAria { inputRef: RefObject; defaultClassName: string; + inputClassName?: string; + inputStyle?: CSSProperties; + visuallyHiddenClassName?: string; + visuallyHiddenStyle?: CSSProperties; } const InternalRadioContext = createContext(null); @@ -438,7 +527,11 @@ export const RadioField = /*#__PURE__*/ (forwardRef as forwardRefType)(function { ...aria, inputRef, - defaultClassName: 'react-aria-RadioButton' + defaultClassName: 'react-aria-RadioButton', + inputClassName: props.inputClassName, + inputStyle: props.inputStyle, + visuallyHiddenClassName: props.visuallyHiddenClassName, + visuallyHiddenStyle: props.visuallyHiddenStyle } ], [ @@ -463,12 +556,30 @@ export const RadioButton = /*#__PURE__*/ (forwardRef as forwardRefType)(function props: RadioButtonProps, ref: ForwardedRef ) { - let {labelProps, inputProps, isSelected, isDisabled, isPressed, defaultClassName, inputRef} = - useContext(InternalRadioContext)!; + let { + labelProps, + inputProps, + isSelected, + isDisabled, + isPressed, + defaultClassName, + inputRef, + inputClassName, + inputStyle, + visuallyHiddenClassName, + visuallyHiddenStyle + } = useContext(InternalRadioContext)!; let state = React.useContext(RadioGroupStateContext)!; let {isFocused, isFocusVisible, focusProps} = useFocusRing(); let interactionDisabled = isDisabled || state.isReadOnly; + // Allow inputClassName/inputStyle to be passed directly to RadioButton, + // taking precedence over values inherited from a wrapping Radio/RadioField. + inputClassName = props.inputClassName ?? inputClassName; + inputStyle = props.inputStyle ?? inputStyle; + visuallyHiddenClassName = props.visuallyHiddenClassName ?? visuallyHiddenClassName; + visuallyHiddenStyle = props.visuallyHiddenStyle ?? visuallyHiddenStyle; + let {hoverProps, isHovered} = useHover({ ...props, isDisabled: interactionDisabled @@ -507,8 +618,13 @@ export const RadioButton = /*#__PURE__*/ (forwardRef as forwardRefType)(function data-readonly={state.isReadOnly || undefined} data-invalid={state.isInvalid || undefined} data-required={state.isRequired || undefined}> - - + + {renderProps.children} diff --git a/packages/react-aria-components/test/Checkbox.sr-focus.browser.test.tsx b/packages/react-aria-components/test/Checkbox.sr-focus.browser.test.tsx new file mode 100644 index 00000000000..bdcc885d8ff --- /dev/null +++ b/packages/react-aria-components/test/Checkbox.sr-focus.browser.test.tsx @@ -0,0 +1,96 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// Verifies in a real browser (not jsdom) that exposing visuallyHiddenStyle and +// inputStyle lets the hidden native input's bounding box match the visible +// component, so the screen reader focus ring aligns with the visual one. +// +// This is a layout test: jsdom does no layout, so it cannot validate this. +// +// The fix requires the component's label to be a positioned containing block +// (position: relative) for the input's inset: 0 to resolve against it rather +// than the viewport. The tests set position: relative on the label to reflect +// the documented usage. + +import {Checkbox} from '../src/Checkbox'; +import {expect, it} from 'vitest'; +import {Label} from '../src/Label'; +import {Radio, RadioGroup} from '../src/RadioGroup'; +import React from 'react'; +import {render} from 'vitest-browser-react'; + +function rect(el: Element) { + let r = el.getBoundingClientRect(); + return {x: r.x, y: r.y, width: r.width, height: r.height}; +} + +// The input should cover the component. The VisuallyHidden wrapper keeps a +// margin: -1px from its base styles, so the input can be up to 2px larger +// than the label; that is fine for the screen reader focus ring. +function covers(a: {width: number; height: number}, b: {width: number; height: number}) { + return a.width >= b.width - 1 && a.height >= b.height - 1; +} + +it('Checkbox: visuallyHiddenStyle + inputStyle make the input cover the component', async () => { + let screen = await render( + + Test + + ); + + let label = screen.container.querySelector('label')!; + let input = screen.container.querySelector('input')!; + + let labelRect = rect(label); + let inputRect = rect(input); + + // The visible component should be larger than 1x1. + expect(labelRect.width).toBeGreaterThan(1); + expect(labelRect.height).toBeGreaterThan(1); + + // The input should cover the component, not the viewport. + expect(covers(inputRect, labelRect)).toBe(true); + expect(inputRect.width).toBeLessThanOrEqual(labelRect.width + 2); + expect(inputRect.height).toBeLessThanOrEqual(labelRect.height + 2); +}); + +it('Radio: visuallyHiddenStyle + inputStyle make the input cover the component', async () => { + let screen = await render( + + + + A + + + ); + + // The Radio's own label is the one containing the input, not the standalone + // which precedes it in the DOM. + let input = screen.container.querySelector('input')!; + let label = input.closest('label')!; + + let labelRect = rect(label); + let inputRect = rect(input); + + expect(labelRect.width).toBeGreaterThan(1); + expect(labelRect.height).toBeGreaterThan(1); + expect(covers(inputRect, labelRect)).toBe(true); + expect(inputRect.width).toBeLessThanOrEqual(labelRect.width + 2); + expect(inputRect.height).toBeLessThanOrEqual(labelRect.height + 2); +}); diff --git a/packages/react-aria-components/test/Checkbox.test.js b/packages/react-aria-components/test/Checkbox.test.js index 7a44370b3e5..649d83ff8e6 100644 --- a/packages/react-aria-components/test/Checkbox.test.js +++ b/packages/react-aria-components/test/Checkbox.test.js @@ -423,6 +423,17 @@ describe.each(['Checkbox', 'CheckboxField'])('%s', comp => { expect(inputRef.current).toBe(getByRole('checkbox')); }); + it('should support input className and style', () => { + let {getByRole} = render( + + Test + + ); + let checkbox = getByRole('checkbox'); + expect(checkbox).toHaveClass('test'); + expect(checkbox).toHaveStyle('inset: 0'); + }); + it('should support callback ref', () => { let cleanup = jest.fn(); let onRef = jest.fn(() => cleanup); @@ -491,3 +502,18 @@ describe.each(['Checkbox', 'CheckboxField'])('%s', comp => { expect(onSubmit).toHaveBeenCalledTimes(1); }); }); + +describe('CheckboxButton', function () { + it('should support input className and style directly on CheckboxButton', () => { + let {getByRole} = render( + + + Test + + + ); + let checkbox = getByRole('checkbox'); + expect(checkbox).toHaveClass('test'); + expect(checkbox).toHaveStyle('inset: 0'); + }); +}); diff --git a/packages/react-aria-components/test/RadioGroup.test.js b/packages/react-aria-components/test/RadioGroup.test.js index 2a95a682654..6ef31ffe6a9 100644 --- a/packages/react-aria-components/test/RadioGroup.test.js +++ b/packages/react-aria-components/test/RadioGroup.test.js @@ -755,6 +755,20 @@ describe.each(['RadioGroup', 'RadioField'])('%s', comp => { expect(inputRef.current).toBe(radio); }); + it('should support input className and style', () => { + let {getByRole} = render( + + + + A + + + ); + let radio = getByRole('radio'); + expect(radio).toHaveClass('test'); + expect(radio).toHaveStyle('inset: 0'); + }); + it('should support callback ref', () => { let cleanup = jest.fn(); let onRef = jest.fn(() => cleanup); @@ -904,3 +918,21 @@ describe.each(['RadioGroup', 'RadioField'])('%s', comp => { expect(onSubmit).toHaveBeenCalledTimes(1); }); }); + +describe('RadioButton', function () { + it('should support input className and style directly on RadioButton', () => { + let {getByRole} = render( + + + + + A + + + + ); + let radio = getByRole('radio'); + expect(radio).toHaveClass('test'); + expect(radio).toHaveStyle('inset: 0'); + }); +}); From 182d45113969cd0f2a76b4aa033c7bfb9cad55bd Mon Sep 17 00:00:00 2001 From: gonzoblasco Date: Thu, 27 Aug 2026 21:59:22 -0300 Subject: [PATCH 2/2] feat: add stories for screen reader focus ring on Checkbox and Radio --- .../stories/Checkbox.stories.tsx | 20 ++++++++++++ .../stories/RadioGroup.stories.tsx | 31 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/packages/react-aria-components/stories/Checkbox.stories.tsx b/packages/react-aria-components/stories/Checkbox.stories.tsx index ca1d102076e..df46d3b672c 100644 --- a/packages/react-aria-components/stories/Checkbox.stories.tsx +++ b/packages/react-aria-components/stories/Checkbox.stories.tsx @@ -27,3 +27,23 @@ export const CheckboxExample: CheckboxStory = { ) }; + +// Demonstrates stretching the hidden input over the visible component so the +// screen reader focus ring tracks the checkbox instead of collapsing to a 1x1px +// square. Requires the label (or a positioned ancestor) to be a containing block. +export const CheckboxScreenReaderFocusRing: CheckboxStory = { + render: args => ( + +
+ +
+ Unsubscribe +
+ ) +}; diff --git a/packages/react-aria-components/stories/RadioGroup.stories.tsx b/packages/react-aria-components/stories/RadioGroup.stories.tsx index 870b6357434..9ce0c1ca68f 100644 --- a/packages/react-aria-components/stories/RadioGroup.stories.tsx +++ b/packages/react-aria-components/stories/RadioGroup.stories.tsx @@ -67,6 +67,37 @@ export const RadioGroupExample: RadioGroupStoryObj = { } }; +// Demonstrates stretching the hidden input over each visible radio so the +// screen reader focus ring tracks the component. Requires each label (or a +// positioned ancestor) to be a containing block. +export const RadioGroupScreenReaderFocusRing: RadioGroupStoryObj = { + render: props => { + return ( + + + + Dog + + + Cat + + + ); + } +}; + export const RadioGroupControlledExample: RadioGroupStory = props => { let [selected, setSelected] = useState(null);