diff --git a/packages/react-aria-components/src/Checkbox.tsx b/packages/react-aria-components/src/Checkbox.tsx index fd2298af0d5..3b780d83a03 100644 --- a/packages/react-aria-components/src/Checkbox.tsx +++ b/packages/react-aria-components/src/Checkbox.tsx @@ -39,7 +39,14 @@ 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, + 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 +97,13 @@ export interface CheckboxProps * A ref for the HTML input element. */ inputRef?: Ref; + /** + * Stretches the hidden native input and its VisuallyHidden wrapper to cover the visible + * label, so the screen reader focus ring (VoiceOver/NVDA draw the ring around the native + * input) matches the visual focus. Requires the label (or an ancestor) to be a positioned + * containing block (`position: relative`). No change in behavior by default. + */ + hiddenInput?: 'stretch-to-label'; } export interface CheckboxFieldProps @@ -110,6 +124,13 @@ export interface CheckboxFieldProps * A ref for the HTML input element. */ inputRef?: Ref; + /** + * Stretches the hidden native input and its VisuallyHidden wrapper to cover the visible + * label, so the screen reader focus ring (VoiceOver/NVDA draw the ring around the native + * input) matches the visual focus. Requires the label (or an ancestor) to be a positioned + * containing block (`position: relative`). No change in behavior by default. + */ + hiddenInput?: 'stretch-to-label'; } export interface CheckboxButtonProps @@ -125,6 +146,13 @@ export interface CheckboxButtonProps * @default 'react-aria-CheckboxButton' */ className?: ClassNameOrFunction; + /** + * Stretches the hidden native input and its VisuallyHidden wrapper to cover the visible + * label, so the screen reader focus ring (VoiceOver/NVDA draw the ring around the native + * input) matches the visual focus. Requires the label (or an ancestor) to be a positioned + * containing block (`position: relative`). No change in behavior by default. + */ + hiddenInput?: 'stretch-to-label'; } export interface CheckboxGroupRenderProps { @@ -343,6 +371,7 @@ interface InternalCheckboxContextValue extends CheckboxAria { defaultClassName: string; isIndeterminate?: boolean; isRequired?: boolean; + hiddenInput?: 'stretch-to-label'; } const InternalCheckboxContext = createContext(null); @@ -406,7 +435,8 @@ export const CheckboxField = /*#__PURE__*/ (forwardRef as forwardRefType)(functi inputRef, defaultClassName: 'react-aria-CheckboxButton', isIndeterminate: props.isIndeterminate, - isRequired: props.isRequired + isRequired: props.isRequired, + hiddenInput: props.hiddenInput } ], [ @@ -476,7 +506,8 @@ export const Checkbox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ch inputRef, defaultClassName: 'react-aria-Checkbox', isIndeterminate: props.isIndeterminate, - isRequired: props.isRequired + isRequired: props.isRequired, + hiddenInput: props.hiddenInput }}> @@ -501,11 +532,16 @@ export const CheckboxButton = /*#__PURE__*/ (forwardRef as forwardRefType)(funct inputRef, defaultClassName, isIndeterminate, - isRequired + isRequired, + hiddenInput } = useContext(InternalCheckboxContext)!; let {isFocused, isFocusVisible, focusProps} = useFocusRing(); let isInteractionDisabled = isDisabled || isReadOnly; + // Allow hiddenInput to be passed directly to CheckboxButton, taking precedence + // over the value inherited from a wrapping Checkbox/CheckboxField. + hiddenInput = props.hiddenInput ?? hiddenInput; + let {hoverProps, isHovered} = useHover({ ...props, isDisabled: isInteractionDisabled @@ -547,8 +583,14 @@ 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..2b45f0dce36 100644 --- a/packages/react-aria-components/src/RadioGroup.tsx +++ b/packages/react-aria-components/src/RadioGroup.tsx @@ -40,7 +40,14 @@ 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, + ForwardedRef, + forwardRef, + Ref, + useContext, + useMemo +} from 'react'; import {SelectionIndicatorContext} from './SelectionIndicator'; import {SharedElementTransition} from './SharedElementTransition'; import {TextContext} from './Text'; @@ -90,6 +97,13 @@ export interface RadioProps * A ref for the HTML input element. */ inputRef?: Ref; + /** + * Stretches the hidden native input and its VisuallyHidden wrapper to cover the visible + * label, so the screen reader focus ring (VoiceOver/NVDA draw the ring around the native + * input) matches the visual focus. Requires the label (or an ancestor) to be a positioned + * containing block (`position: relative`). No change in behavior by default. + */ + hiddenInput?: 'stretch-to-label'; } export interface RadioFieldProps @@ -109,6 +123,13 @@ export interface RadioFieldProps * A ref for the HTML input element. */ inputRef?: Ref; + /** + * Stretches the hidden native input and its VisuallyHidden wrapper to cover the visible + * label, so the screen reader focus ring (VoiceOver/NVDA draw the ring around the native + * input) matches the visual focus. Requires the label (or an ancestor) to be a positioned + * containing block (`position: relative`). No change in behavior by default. + */ + hiddenInput?: 'stretch-to-label'; } export interface RadioButtonProps @@ -124,6 +145,13 @@ export interface RadioButtonProps * @default 'react-aria-RadioButton' */ className?: ClassNameOrFunction; + /** + * Stretches the hidden native input and its VisuallyHidden wrapper to cover the visible + * label, so the screen reader focus ring (VoiceOver/NVDA draw the ring around the native + * input) matches the visual focus. Requires the label (or an ancestor) to be a positioned + * containing block (`position: relative`). No change in behavior by default. + */ + hiddenInput?: 'stretch-to-label'; } export interface RadioGroupRenderProps { @@ -364,7 +392,12 @@ export const Radio = /*#__PURE__*/ (forwardRef as forwardRefType)(function Radio return ( + value={{ + ...aria, + inputRef, + defaultClassName: 'react-aria-Radio', + hiddenInput: props.hiddenInput + }}> ); @@ -373,6 +406,7 @@ export const Radio = /*#__PURE__*/ (forwardRef as forwardRefType)(function Radio interface InternalRadioContextValue extends RadioAria { inputRef: RefObject; defaultClassName: string; + hiddenInput?: 'stretch-to-label'; } const InternalRadioContext = createContext(null); @@ -438,7 +472,8 @@ export const RadioField = /*#__PURE__*/ (forwardRef as forwardRefType)(function { ...aria, inputRef, - defaultClassName: 'react-aria-RadioButton' + defaultClassName: 'react-aria-RadioButton', + hiddenInput: props.hiddenInput } ], [ @@ -463,12 +498,24 @@ 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, + hiddenInput + } = useContext(InternalRadioContext)!; let state = React.useContext(RadioGroupStateContext)!; let {isFocused, isFocusVisible, focusProps} = useFocusRing(); let interactionDisabled = isDisabled || state.isReadOnly; + // Allow hiddenInput to be passed directly to RadioButton, taking precedence + // over the value inherited from a wrapping Radio/RadioField. + hiddenInput = props.hiddenInput ?? hiddenInput; + let {hoverProps, isHovered} = useHover({ ...props, isDisabled: interactionDisabled @@ -507,8 +554,14 @@ 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/stories/Checkbox.stories.tsx b/packages/react-aria-components/stories/Checkbox.stories.tsx index ca1d102076e..54395fc34c0 100644 --- a/packages/react-aria-components/stories/Checkbox.stories.tsx +++ b/packages/react-aria-components/stories/Checkbox.stories.tsx @@ -27,3 +27,22 @@ 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..9d2db2e2181 100644 --- a/packages/react-aria-components/stories/RadioGroup.stories.tsx +++ b/packages/react-aria-components/stories/RadioGroup.stories.tsx @@ -67,6 +67,35 @@ 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); 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..023c4a749e4 --- /dev/null +++ b/packages/react-aria-components/test/Checkbox.sr-focus.browser.test.tsx @@ -0,0 +1,89 @@ +/* + * 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 the hiddenInput="stretch-to-label" +// option 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: hiddenInput="stretch-to-label" makes 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: hiddenInput="stretch-to-label" makes 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..f8edd2da7fd 100644 --- a/packages/react-aria-components/test/Checkbox.test.js +++ b/packages/react-aria-components/test/Checkbox.test.js @@ -423,6 +423,19 @@ describe.each(['Checkbox', 'CheckboxField'])('%s', comp => { expect(inputRef.current).toBe(getByRole('checkbox')); }); + it('should support hiddenInput stretch-to-label', () => { + let {getByRole} = render( + + Test + + ); + let checkbox = getByRole('checkbox'); + expect(checkbox).toHaveStyle('position: absolute'); + expect(checkbox).toHaveStyle('inset: 0'); + expect(checkbox).toHaveStyle('width: 100%'); + expect(checkbox).toHaveStyle('height: 100%'); + }); + it('should support callback ref', () => { let cleanup = jest.fn(); let onRef = jest.fn(() => cleanup); @@ -491,3 +504,20 @@ describe.each(['Checkbox', 'CheckboxField'])('%s', comp => { expect(onSubmit).toHaveBeenCalledTimes(1); }); }); + +describe('CheckboxButton', function () { + it('should support hiddenInput stretch-to-label directly on CheckboxButton', () => { + let {getByRole} = render( + + + Test + + + ); + let checkbox = getByRole('checkbox'); + expect(checkbox).toHaveStyle('position: absolute'); + expect(checkbox).toHaveStyle('inset: 0'); + expect(checkbox).toHaveStyle('width: 100%'); + expect(checkbox).toHaveStyle('height: 100%'); + }); +}); diff --git a/packages/react-aria-components/test/RadioGroup.test.js b/packages/react-aria-components/test/RadioGroup.test.js index 2a95a682654..b35b987ca38 100644 --- a/packages/react-aria-components/test/RadioGroup.test.js +++ b/packages/react-aria-components/test/RadioGroup.test.js @@ -755,6 +755,22 @@ describe.each(['RadioGroup', 'RadioField'])('%s', comp => { expect(inputRef.current).toBe(radio); }); + it('should support hiddenInput stretch-to-label', () => { + let {getByRole} = render( + + + + A + + + ); + let radio = getByRole('radio'); + expect(radio).toHaveStyle('position: absolute'); + expect(radio).toHaveStyle('inset: 0'); + expect(radio).toHaveStyle('width: 100%'); + expect(radio).toHaveStyle('height: 100%'); + }); + it('should support callback ref', () => { let cleanup = jest.fn(); let onRef = jest.fn(() => cleanup); @@ -904,3 +920,23 @@ describe.each(['RadioGroup', 'RadioField'])('%s', comp => { expect(onSubmit).toHaveBeenCalledTimes(1); }); }); + +describe('RadioButton', function () { + it('should support hiddenInput stretch-to-label directly on RadioButton', () => { + let {getByRole} = render( + + + + + A + + + + ); + let radio = getByRole('radio'); + expect(radio).toHaveStyle('position: absolute'); + expect(radio).toHaveStyle('inset: 0'); + expect(radio).toHaveStyle('width: 100%'); + expect(radio).toHaveStyle('height: 100%'); + }); +});