From 393b02cbfdae82c88a3c3ed343e51dc963909357 Mon Sep 17 00:00:00 2001 From: gonzoblasco Date: Thu, 27 Aug 2026 09:21:02 -0300 Subject: [PATCH 01/16] 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 02/16] 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); From c07895eaa02f5737bd0a7d2e2846a2c7bbf50d1e Mon Sep 17 00:00:00 2001 From: Gonzalo Blasco Date: Wed, 2 Sep 2026 10:26:03 -0300 Subject: [PATCH 03/16] fix: replace input/visuallyHidden style props with hiddenInput="stretch-to-label" on RAC Checkbox and Radio Per maintainer direction on #9687, remove the advanced escape hatch of four style props (inputClassName/inputStyle/visuallyHiddenClassName/visuallyHiddenStyle) on Checkbox/CheckboxField/CheckboxButton and Radio/RadioField/RadioButton. Replace them with a single hiddenInput="stretch-to-label" prop that internalizes the styles: it 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. Update the unit tests, the sr-focus browser test, and the stories to use the new prop. --- .../react-aria-components/src/Checkbox.tsx | 115 ++++-------------- .../react-aria-components/src/RadioGroup.tsx | 115 ++++-------------- .../stories/Checkbox.stories.tsx | 3 +- .../stories/RadioGroup.stories.tsx | 6 +- .../test/Checkbox.sr-focus.browser.test.tsx | 19 +-- .../test/Checkbox.test.js | 16 ++- .../test/RadioGroup.test.js | 16 ++- 7 files changed, 81 insertions(+), 209 deletions(-) diff --git a/packages/react-aria-components/src/Checkbox.tsx b/packages/react-aria-components/src/Checkbox.tsx index 85e1a811bc8..3b780d83a03 100644 --- a/packages/react-aria-components/src/Checkbox.tsx +++ b/packages/react-aria-components/src/Checkbox.tsx @@ -41,7 +41,6 @@ import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; import React, { createContext, - CSSProperties, ForwardedRef, forwardRef, Ref, @@ -99,28 +98,12 @@ export interface CheckboxProps */ 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. + * 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. */ - 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; + hiddenInput?: 'stretch-to-label'; } export interface CheckboxFieldProps @@ -142,28 +125,12 @@ export interface CheckboxFieldProps */ 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. + * 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. */ - visuallyHiddenStyle?: CSSProperties; + hiddenInput?: 'stretch-to-label'; } export interface CheckboxButtonProps @@ -180,28 +147,12 @@ export interface CheckboxButtonProps */ 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. + * 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. */ - visuallyHiddenStyle?: CSSProperties; + hiddenInput?: 'stretch-to-label'; } export interface CheckboxGroupRenderProps { @@ -420,10 +371,7 @@ interface InternalCheckboxContextValue extends CheckboxAria { defaultClassName: string; isIndeterminate?: boolean; isRequired?: boolean; - inputClassName?: string; - inputStyle?: CSSProperties; - visuallyHiddenClassName?: string; - visuallyHiddenStyle?: CSSProperties; + hiddenInput?: 'stretch-to-label'; } const InternalCheckboxContext = createContext(null); @@ -488,10 +436,7 @@ export const CheckboxField = /*#__PURE__*/ (forwardRef as forwardRefType)(functi defaultClassName: 'react-aria-CheckboxButton', isIndeterminate: props.isIndeterminate, isRequired: props.isRequired, - inputClassName: props.inputClassName, - inputStyle: props.inputStyle, - visuallyHiddenClassName: props.visuallyHiddenClassName, - visuallyHiddenStyle: props.visuallyHiddenStyle + hiddenInput: props.hiddenInput } ], [ @@ -562,10 +507,7 @@ export const Checkbox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ch defaultClassName: 'react-aria-Checkbox', isIndeterminate: props.isIndeterminate, isRequired: props.isRequired, - inputClassName: props.inputClassName, - inputStyle: props.inputStyle, - visuallyHiddenClassName: props.visuallyHiddenClassName, - visuallyHiddenStyle: props.visuallyHiddenStyle + hiddenInput: props.hiddenInput }}> @@ -591,20 +533,14 @@ export const CheckboxButton = /*#__PURE__*/ (forwardRef as forwardRefType)(funct defaultClassName, isIndeterminate, isRequired, - inputClassName, - inputStyle, - visuallyHiddenClassName, - visuallyHiddenStyle + hiddenInput } = 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; + // 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, @@ -647,12 +583,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 282df5f1a8d..2b45f0dce36 100644 --- a/packages/react-aria-components/src/RadioGroup.tsx +++ b/packages/react-aria-components/src/RadioGroup.tsx @@ -42,7 +42,6 @@ import {mergeRefs} from 'react-aria/mergeRefs'; import {RadioGroupState, useRadioGroupState} from 'react-stately/useRadioGroupState'; import React, { createContext, - CSSProperties, ForwardedRef, forwardRef, Ref, @@ -99,28 +98,12 @@ export interface RadioProps */ 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. + * 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. */ - 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; + hiddenInput?: 'stretch-to-label'; } export interface RadioFieldProps @@ -141,28 +124,12 @@ export interface RadioFieldProps */ 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. + * 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. */ - visuallyHiddenStyle?: CSSProperties; + hiddenInput?: 'stretch-to-label'; } export interface RadioButtonProps @@ -179,28 +146,12 @@ export interface RadioButtonProps */ 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. + * 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. */ - visuallyHiddenStyle?: CSSProperties; + hiddenInput?: 'stretch-to-label'; } export interface RadioGroupRenderProps { @@ -445,10 +396,7 @@ export const Radio = /*#__PURE__*/ (forwardRef as forwardRefType)(function Radio ...aria, inputRef, defaultClassName: 'react-aria-Radio', - inputClassName: props.inputClassName, - inputStyle: props.inputStyle, - visuallyHiddenClassName: props.visuallyHiddenClassName, - visuallyHiddenStyle: props.visuallyHiddenStyle + hiddenInput: props.hiddenInput }}> @@ -458,10 +406,7 @@ 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; + hiddenInput?: 'stretch-to-label'; } const InternalRadioContext = createContext(null); @@ -528,10 +473,7 @@ export const RadioField = /*#__PURE__*/ (forwardRef as forwardRefType)(function ...aria, inputRef, defaultClassName: 'react-aria-RadioButton', - inputClassName: props.inputClassName, - inputStyle: props.inputStyle, - visuallyHiddenClassName: props.visuallyHiddenClassName, - visuallyHiddenStyle: props.visuallyHiddenStyle + hiddenInput: props.hiddenInput } ], [ @@ -564,21 +506,15 @@ export const RadioButton = /*#__PURE__*/ (forwardRef as forwardRefType)(function isPressed, defaultClassName, inputRef, - inputClassName, - inputStyle, - visuallyHiddenClassName, - visuallyHiddenStyle + hiddenInput } = 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; + // 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, @@ -618,12 +554,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/stories/Checkbox.stories.tsx b/packages/react-aria-components/stories/Checkbox.stories.tsx index df46d3b672c..54395fc34c0 100644 --- a/packages/react-aria-components/stories/Checkbox.stories.tsx +++ b/packages/react-aria-components/stories/Checkbox.stories.tsx @@ -36,8 +36,7 @@ export const CheckboxScreenReaderFocusRing: CheckboxStory = { + hiddenInput="stretch-to-label">
+ + + +
+
+ + +
+ +`; + +await page.setContent(html); + +// Apply component logic to both inputs +await page.evaluate(() => { + const outer = document.getElementById('outer'); + const apply = input => { + input.style.positionAnchor = '--a'; + input.style.top = 'anchor(top)'; + input.style.left = 'anchor(left)'; + input.style.width = 'anchor-size(width)'; + input.style.height = 'anchor-size(height)'; + }; + apply(document.getElementById('inputClipped')); + apply(document.getElementById('inputNoClip')); + document.getElementById('inputClipped').getBoundingClientRect(); +}); + +const before = await page.evaluate(() => { + const r1 = document.getElementById('inputClipped').getBoundingClientRect(); + const l1 = document.getElementById('label').getBoundingClientRect(); + return {inputY: r1.y, labelY: l1.y}; +}); + +// Scroll down 100px +await page.evaluate(() => window.scrollTo(0, 100)); +await page.waitForTimeout(100); // allow anchor re-layout on scroll + +const after = await page.evaluate(() => { + const input = document.getElementById('inputClipped'); + const r = input.getBoundingClientRect(); + const lr = document.getElementById('label').getBoundingClientRect(); + // does the input still cover the label after scroll? + return { + inputY: r.y, + labelY: lr.y, + tracksScroll: Math.abs(r.y - lr.y) < 2, + // clip disambiguation: hit test at centers + hitClipped: (() => { + const r = input.getBoundingClientRect(); + const el = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2); + return el?.id || el?.tagName; + })(), + hitNoClip: (() => { + const r2 = document.getElementById('inputNoClip').getBoundingClientRect(); + const el = document.elementFromPoint(r2.x + r2.width / 2, r2.y + r2.height / 2); + return el?.id || el?.tagName; + })() + }; +}); + +console.log('BEFORE scroll:', JSON.stringify(before)); +console.log('AFTER scroll:', JSON.stringify(after, null, 2)); +await browser.close(); From 0cf378d7b2015e8b1183fed824a1d0fd91719b57 Mon Sep 17 00:00:00 2001 From: gonzoblasco <15630819+gonzoblasco@users.noreply.github.com> Date: Mon, 21 Sep 2026 03:22:19 -0300 Subject: [PATCH 05/16] fix: replace useId with random ID to fix CI tests crash --- packages/react-aria-components/src/Checkbox.tsx | 2 +- packages/react-aria-components/src/RadioGroup.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/src/Checkbox.tsx b/packages/react-aria-components/src/Checkbox.tsx index 81f359b3665..ecf23912772 100644 --- a/packages/react-aria-components/src/Checkbox.tsx +++ b/packages/react-aria-components/src/Checkbox.tsx @@ -508,7 +508,7 @@ export const CheckboxButton = /*#__PURE__*/ (forwardRef as forwardRefType)(funct let isInteractionDisabled = isDisabled || isReadOnly; let domRef = useObjectRef(ref); - let uniqueId = React.useId().replace(/[^a-zA-Z0-9_-]/g, ''); + let uniqueId = `checkbox-${Math.random().toString(36).substr(2, 9)}`; useHiddenInputAnchor(domRef, inputRef, `--react-aria-checkbox-${uniqueId}`); let {hoverProps, isHovered} = useHover({ diff --git a/packages/react-aria-components/src/RadioGroup.tsx b/packages/react-aria-components/src/RadioGroup.tsx index 0acd216e731..f89a38c48fb 100644 --- a/packages/react-aria-components/src/RadioGroup.tsx +++ b/packages/react-aria-components/src/RadioGroup.tsx @@ -475,7 +475,7 @@ export const RadioButton = /*#__PURE__*/ (forwardRef as forwardRefType)(function let interactionDisabled = isDisabled || state.isReadOnly; let domRef = useObjectRef(ref); - let uniqueId = React.useId().replace(/[^a-zA-Z0-9_-]/g, ''); + let uniqueId = `radio-${Math.random().toString(36).substr(2, 9)}`; useHiddenInputAnchor(domRef, inputRef, `--react-aria-radio-${uniqueId}`); let {hoverProps, isHovered} = useHover({ From 508a29ac2f7117b6557da8ff01cbb4927cab5943 Mon Sep 17 00:00:00 2001 From: gonzoblasco <15630819+gonzoblasco@users.noreply.github.com> Date: Mon, 21 Sep 2026 03:25:24 -0300 Subject: [PATCH 06/16] style: sort imports alphabetically to fix oxlint errors --- packages/react-aria-components/src/Checkbox.tsx | 2 +- packages/react-aria-components/src/RadioGroup.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/src/Checkbox.tsx b/packages/react-aria-components/src/Checkbox.tsx index ecf23912772..94d93eee2f6 100644 --- a/packages/react-aria-components/src/Checkbox.tsx +++ b/packages/react-aria-components/src/Checkbox.tsx @@ -41,12 +41,12 @@ import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; import React, {createContext, ForwardedRef, forwardRef, Ref, useContext, useMemo} from 'react'; import {TextContext} from './Text'; -import {useHiddenInputAnchor} from './hiddenInputAnchor'; import {useFocusRing} from 'react-aria/useFocusRing'; import {useHover} from 'react-aria/useHover'; import {useObjectRef} from 'react-aria/useObjectRef'; import {useToggleState} from 'react-stately/useToggleState'; import {VisuallyHidden} from 'react-aria/VisuallyHidden'; +import {useHiddenInputAnchor} from './hiddenInputAnchor'; export interface CheckboxGroupProps extends diff --git a/packages/react-aria-components/src/RadioGroup.tsx b/packages/react-aria-components/src/RadioGroup.tsx index f89a38c48fb..5ad005a9b18 100644 --- a/packages/react-aria-components/src/RadioGroup.tsx +++ b/packages/react-aria-components/src/RadioGroup.tsx @@ -44,11 +44,11 @@ import React, {createContext, ForwardedRef, forwardRef, Ref, useContext, useMemo import {SelectionIndicatorContext} from './SelectionIndicator'; import {SharedElementTransition} from './SharedElementTransition'; import {TextContext} from './Text'; -import {useHiddenInputAnchor} from './hiddenInputAnchor'; import {useFocusRing} from 'react-aria/useFocusRing'; import {useHover} from 'react-aria/useHover'; import {useObjectRef} from 'react-aria/useObjectRef'; import {VisuallyHidden} from 'react-aria/VisuallyHidden'; +import {useHiddenInputAnchor} from './hiddenInputAnchor'; export interface RadioGroupProps extends From 6d922cb0ecd36d3a545240ac46dc83e665c85263 Mon Sep 17 00:00:00 2001 From: gonzoblasco <15630819+gonzoblasco@users.noreply.github.com> Date: Mon, 21 Sep 2026 03:28:14 -0300 Subject: [PATCH 07/16] style: fix import sorting for oxlint --- packages/react-aria-components/src/Checkbox.tsx | 2 +- packages/react-aria-components/src/RadioGroup.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/src/Checkbox.tsx b/packages/react-aria-components/src/Checkbox.tsx index 94d93eee2f6..ecf23912772 100644 --- a/packages/react-aria-components/src/Checkbox.tsx +++ b/packages/react-aria-components/src/Checkbox.tsx @@ -41,12 +41,12 @@ import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; import React, {createContext, ForwardedRef, forwardRef, Ref, useContext, useMemo} from 'react'; import {TextContext} from './Text'; +import {useHiddenInputAnchor} from './hiddenInputAnchor'; import {useFocusRing} from 'react-aria/useFocusRing'; import {useHover} from 'react-aria/useHover'; import {useObjectRef} from 'react-aria/useObjectRef'; import {useToggleState} from 'react-stately/useToggleState'; import {VisuallyHidden} from 'react-aria/VisuallyHidden'; -import {useHiddenInputAnchor} from './hiddenInputAnchor'; export interface CheckboxGroupProps extends diff --git a/packages/react-aria-components/src/RadioGroup.tsx b/packages/react-aria-components/src/RadioGroup.tsx index 5ad005a9b18..f89a38c48fb 100644 --- a/packages/react-aria-components/src/RadioGroup.tsx +++ b/packages/react-aria-components/src/RadioGroup.tsx @@ -44,11 +44,11 @@ import React, {createContext, ForwardedRef, forwardRef, Ref, useContext, useMemo import {SelectionIndicatorContext} from './SelectionIndicator'; import {SharedElementTransition} from './SharedElementTransition'; import {TextContext} from './Text'; +import {useHiddenInputAnchor} from './hiddenInputAnchor'; import {useFocusRing} from 'react-aria/useFocusRing'; import {useHover} from 'react-aria/useHover'; import {useObjectRef} from 'react-aria/useObjectRef'; import {VisuallyHidden} from 'react-aria/VisuallyHidden'; -import {useHiddenInputAnchor} from './hiddenInputAnchor'; export interface RadioGroupProps extends From be64a455e86d0fe663bb70ab7af7510f6405e4db Mon Sep 17 00:00:00 2001 From: gonzoblasco <15630819+gonzoblasco@users.noreply.github.com> Date: Mon, 21 Sep 2026 03:40:03 -0300 Subject: [PATCH 08/16] style: force update for import sorting --- packages/react-aria-components/src/Checkbox.tsx | 2 +- packages/react-aria-components/src/RadioGroup.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/src/Checkbox.tsx b/packages/react-aria-components/src/Checkbox.tsx index ecf23912772..94d93eee2f6 100644 --- a/packages/react-aria-components/src/Checkbox.tsx +++ b/packages/react-aria-components/src/Checkbox.tsx @@ -41,12 +41,12 @@ import {mergeProps} from 'react-aria/mergeProps'; import {mergeRefs} from 'react-aria/mergeRefs'; import React, {createContext, ForwardedRef, forwardRef, Ref, useContext, useMemo} from 'react'; import {TextContext} from './Text'; -import {useHiddenInputAnchor} from './hiddenInputAnchor'; import {useFocusRing} from 'react-aria/useFocusRing'; import {useHover} from 'react-aria/useHover'; import {useObjectRef} from 'react-aria/useObjectRef'; import {useToggleState} from 'react-stately/useToggleState'; import {VisuallyHidden} from 'react-aria/VisuallyHidden'; +import {useHiddenInputAnchor} from './hiddenInputAnchor'; export interface CheckboxGroupProps extends diff --git a/packages/react-aria-components/src/RadioGroup.tsx b/packages/react-aria-components/src/RadioGroup.tsx index f89a38c48fb..5ad005a9b18 100644 --- a/packages/react-aria-components/src/RadioGroup.tsx +++ b/packages/react-aria-components/src/RadioGroup.tsx @@ -44,11 +44,11 @@ import React, {createContext, ForwardedRef, forwardRef, Ref, useContext, useMemo import {SelectionIndicatorContext} from './SelectionIndicator'; import {SharedElementTransition} from './SharedElementTransition'; import {TextContext} from './Text'; -import {useHiddenInputAnchor} from './hiddenInputAnchor'; import {useFocusRing} from 'react-aria/useFocusRing'; import {useHover} from 'react-aria/useHover'; import {useObjectRef} from 'react-aria/useObjectRef'; import {VisuallyHidden} from 'react-aria/VisuallyHidden'; +import {useHiddenInputAnchor} from './hiddenInputAnchor'; export interface RadioGroupProps extends From 6e55c11ab03b1ed4d1a314dae524f696255a75d5 Mon Sep 17 00:00:00 2001 From: gonzoblasco <15630819+gonzoblasco@users.noreply.github.com> Date: Mon, 21 Sep 2026 03:50:19 -0300 Subject: [PATCH 09/16] cleanup: remove spike and format --- spike-anchor.mjs | 91 ------------------------------------------------ 1 file changed, 91 deletions(-) delete mode 100644 spike-anchor.mjs diff --git a/spike-anchor.mjs b/spike-anchor.mjs deleted file mode 100644 index 6a0a4863294..00000000000 --- a/spike-anchor.mjs +++ /dev/null @@ -1,91 +0,0 @@ -// Spike v5: scroll tracking + clip disambiguation for fixed+anchor hidden input -import {chromium} from 'playwright'; - -const browser = await chromium.launch({ - executablePath: - '/Users/gonzoblasco/Library/Caches/ms-playwright/chromium-1243/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing' -}); -const page = await browser.newPage({viewport: {width: 800, height: 600}}); - -const html = ` - - - - - -
-
- - -
- -`; - -await page.setContent(html); - -// Apply component logic to both inputs -await page.evaluate(() => { - const outer = document.getElementById('outer'); - const apply = input => { - input.style.positionAnchor = '--a'; - input.style.top = 'anchor(top)'; - input.style.left = 'anchor(left)'; - input.style.width = 'anchor-size(width)'; - input.style.height = 'anchor-size(height)'; - }; - apply(document.getElementById('inputClipped')); - apply(document.getElementById('inputNoClip')); - document.getElementById('inputClipped').getBoundingClientRect(); -}); - -const before = await page.evaluate(() => { - const r1 = document.getElementById('inputClipped').getBoundingClientRect(); - const l1 = document.getElementById('label').getBoundingClientRect(); - return {inputY: r1.y, labelY: l1.y}; -}); - -// Scroll down 100px -await page.evaluate(() => window.scrollTo(0, 100)); -await page.waitForTimeout(100); // allow anchor re-layout on scroll - -const after = await page.evaluate(() => { - const input = document.getElementById('inputClipped'); - const r = input.getBoundingClientRect(); - const lr = document.getElementById('label').getBoundingClientRect(); - // does the input still cover the label after scroll? - return { - inputY: r.y, - labelY: lr.y, - tracksScroll: Math.abs(r.y - lr.y) < 2, - // clip disambiguation: hit test at centers - hitClipped: (() => { - const r = input.getBoundingClientRect(); - const el = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2); - return el?.id || el?.tagName; - })(), - hitNoClip: (() => { - const r2 = document.getElementById('inputNoClip').getBoundingClientRect(); - const el = document.elementFromPoint(r2.x + r2.width / 2, r2.y + r2.height / 2); - return el?.id || el?.tagName; - })() - }; -}); - -console.log('BEFORE scroll:', JSON.stringify(before)); -console.log('AFTER scroll:', JSON.stringify(after, null, 2)); -await browser.close(); From 70e2b8c2c3d04643f79dffbe6f917b67c5294197 Mon Sep 17 00:00:00 2001 From: gonzoblasco <15630819+gonzoblasco@users.noreply.github.com> Date: Mon, 21 Sep 2026 03:57:31 -0300 Subject: [PATCH 10/16] fix: remove residual useHiddenInputAnchor and apply TS casting for CSS anchor properties --- .../react-aria-components/src/Checkbox.tsx | 20 ++++++++++++++----- .../react-aria-components/src/RadioGroup.tsx | 20 ++++++++++++++----- .../test/Checkbox.sr-focus.browser.test.tsx | 20 +++++++++++++++++++ 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/react-aria-components/src/Checkbox.tsx b/packages/react-aria-components/src/Checkbox.tsx index 94d93eee2f6..ef13bcb5ab7 100644 --- a/packages/react-aria-components/src/Checkbox.tsx +++ b/packages/react-aria-components/src/Checkbox.tsx @@ -46,7 +46,6 @@ import {useHover} from 'react-aria/useHover'; import {useObjectRef} from 'react-aria/useObjectRef'; import {useToggleState} from 'react-stately/useToggleState'; import {VisuallyHidden} from 'react-aria/VisuallyHidden'; -import {useHiddenInputAnchor} from './hiddenInputAnchor'; export interface CheckboxGroupProps extends @@ -507,9 +506,7 @@ export const CheckboxButton = /*#__PURE__*/ (forwardRef as forwardRefType)(funct let {isFocused, isFocusVisible, focusProps} = useFocusRing(); let isInteractionDisabled = isDisabled || isReadOnly; - let domRef = useObjectRef(ref); let uniqueId = `checkbox-${Math.random().toString(36).substr(2, 9)}`; - useHiddenInputAnchor(domRef, inputRef, `--react-aria-checkbox-${uniqueId}`); let {hoverProps, isHovered} = useHover({ ...props, @@ -540,7 +537,8 @@ export const CheckboxButton = /*#__PURE__*/ (forwardRef as forwardRefType)(funct return ( - + {renderProps.children} diff --git a/packages/react-aria-components/src/RadioGroup.tsx b/packages/react-aria-components/src/RadioGroup.tsx index 5ad005a9b18..0ae9843387d 100644 --- a/packages/react-aria-components/src/RadioGroup.tsx +++ b/packages/react-aria-components/src/RadioGroup.tsx @@ -48,7 +48,6 @@ import {useFocusRing} from 'react-aria/useFocusRing'; import {useHover} from 'react-aria/useHover'; import {useObjectRef} from 'react-aria/useObjectRef'; import {VisuallyHidden} from 'react-aria/VisuallyHidden'; -import {useHiddenInputAnchor} from './hiddenInputAnchor'; export interface RadioGroupProps extends @@ -474,9 +473,7 @@ export const RadioButton = /*#__PURE__*/ (forwardRef as forwardRefType)(function let {isFocused, isFocusVisible, focusProps} = useFocusRing(); let interactionDisabled = isDisabled || state.isReadOnly; - let domRef = useObjectRef(ref); let uniqueId = `radio-${Math.random().toString(36).substr(2, 9)}`; - useHiddenInputAnchor(domRef, inputRef, `--react-aria-radio-${uniqueId}`); let {hoverProps, isHovered} = useHover({ ...props, @@ -506,7 +503,8 @@ export const RadioButton = /*#__PURE__*/ (forwardRef as forwardRefType)(function return ( - + {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 index 887ba2d9efe..8ade4e1d1eb 100644 --- a/packages/react-aria-components/test/Checkbox.sr-focus.browser.test.tsx +++ b/packages/react-aria-components/test/Checkbox.sr-focus.browser.test.tsx @@ -93,3 +93,23 @@ it('Radio: the hidden input covers the component via anchor positioning', async expect(inputRect.height).toBeLessThanOrEqual(labelRect.height + 2); } }); + +it('the hidden input respects a custom anchor-name provided via CSS', async () => { + let screen = await render( + + Test + + ); + + let input = screen.container.querySelector('input')!; + let label = screen.container.querySelector('label')!; + + if (supportsAnchorPositioning()) { + expect(getComputedStyle(label).getPropertyValue('anchor-name').trim()).toBe('--custom-anchor'); + expect(getComputedStyle(input).getPropertyValue('position-anchor').trim()).toBe('--custom-anchor'); + + let labelRect = rect(label); + let inputRect = rect(input); + expect(covers(inputRect, labelRect)).toBe(true); + } +}); From 684a880078cf90ead14d5b4cf491ab4bc03b6294 Mon Sep 17 00:00:00 2001 From: gonzoblasco <15630819+gonzoblasco@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:10:08 -0300 Subject: [PATCH 11/16] style: fix formatting in browser tests --- .../test/Checkbox.sr-focus.browser.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index 8ade4e1d1eb..e339a9b60d7 100644 --- a/packages/react-aria-components/test/Checkbox.sr-focus.browser.test.tsx +++ b/packages/react-aria-components/test/Checkbox.sr-focus.browser.test.tsx @@ -106,8 +106,10 @@ it('the hidden input respects a custom anchor-name provided via CSS', async () = if (supportsAnchorPositioning()) { expect(getComputedStyle(label).getPropertyValue('anchor-name').trim()).toBe('--custom-anchor'); - expect(getComputedStyle(input).getPropertyValue('position-anchor').trim()).toBe('--custom-anchor'); - + expect(getComputedStyle(input).getPropertyValue('position-anchor').trim()).toBe( + '--custom-anchor' + ); + let labelRect = rect(label); let inputRect = rect(input); expect(covers(inputRect, labelRect)).toBe(true); From b46d5759b57a602943e1a1c58fbcd77d8a8edf96 Mon Sep 17 00:00:00 2001 From: gonzoblasco <15630819+gonzoblasco@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:15:46 -0300 Subject: [PATCH 12/16] fix: respect custom anchorName provided via style --- packages/react-aria-components/src/Checkbox.tsx | 7 +++++-- packages/react-aria-components/src/RadioGroup.tsx | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/react-aria-components/src/Checkbox.tsx b/packages/react-aria-components/src/Checkbox.tsx index ef13bcb5ab7..55839d50f0d 100644 --- a/packages/react-aria-components/src/Checkbox.tsx +++ b/packages/react-aria-components/src/Checkbox.tsx @@ -538,7 +538,10 @@ export const CheckboxButton = /*#__PURE__*/ (forwardRef as forwardRefType)(funct Date: Mon, 21 Sep 2026 04:26:29 -0300 Subject: [PATCH 13/16] fix: casting props.style to any to avoid TS2339 on anchorName --- packages/react-aria-components/src/Checkbox.tsx | 4 ++-- packages/react-aria-components/src/RadioGroup.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-aria-components/src/Checkbox.tsx b/packages/react-aria-components/src/Checkbox.tsx index 55839d50f0d..3a51eb7d82c 100644 --- a/packages/react-aria-components/src/Checkbox.tsx +++ b/packages/react-aria-components/src/Checkbox.tsx @@ -540,7 +540,7 @@ export const CheckboxButton = /*#__PURE__*/ (forwardRef as forwardRefType)(funct ref={ref} style={{ ...props.style, - ['anchorName' as any]: props.style?.anchorName ?? `--react-aria-checkbox-${uniqueId}` + ['anchorName' as any]: (props.style as any)?.anchorName ?? `--react-aria-checkbox-${uniqueId}` }} slot={props.slot || undefined} data-selected={isSelected || undefined} @@ -560,7 +560,7 @@ export const CheckboxButton = /*#__PURE__*/ (forwardRef as forwardRefType)(funct style={{ position: 'fixed', margin: 0, - ['positionAnchor' as any]: props.style?.anchorName ?? `--react-aria-checkbox-${uniqueId}`, + ['positionAnchor' as any]: (props.style as any)?.anchorName ?? `--react-aria-checkbox-${uniqueId}`, top: 'anchor(top)', left: 'anchor(left)', width: 'anchor-size(width)', diff --git a/packages/react-aria-components/src/RadioGroup.tsx b/packages/react-aria-components/src/RadioGroup.tsx index 10112cc4335..7895b53cc67 100644 --- a/packages/react-aria-components/src/RadioGroup.tsx +++ b/packages/react-aria-components/src/RadioGroup.tsx @@ -506,7 +506,7 @@ export const RadioButton = /*#__PURE__*/ (forwardRef as forwardRefType)(function ref={ref} style={{ ...props.style, - ['anchorName' as any]: props.style?.anchorName ?? `--react-aria-radio-${uniqueId}` + ['anchorName' as any]: (props.style as any)?.anchorName ?? `--react-aria-radio-${uniqueId}` }} data-selected={isSelected || undefined} data-pressed={isPressed || undefined} @@ -524,7 +524,7 @@ export const RadioButton = /*#__PURE__*/ (forwardRef as forwardRefType)(function style={{ position: 'fixed', margin: 0, - ['positionAnchor' as any]: props.style?.anchorName ?? `--react-aria-radio-${uniqueId}`, + ['positionAnchor' as any]: (props.style as any)?.anchorName ?? `--react-aria-radio-${uniqueId}`, top: 'anchor(top)', left: 'anchor(left)', width: 'anchor-size(width)', From 5482c582502c41faf8dfa2041888959d8bd68146 Mon Sep 17 00:00:00 2001 From: gonzoblasco <15630819+gonzoblasco@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:36:31 -0300 Subject: [PATCH 14/16] test: fix RadioButton test to target label instead of input for position check --- packages/react-aria-components/test/RadioGroup.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/test/RadioGroup.test.js b/packages/react-aria-components/test/RadioGroup.test.js index e614723e8ee..645da57756a 100644 --- a/packages/react-aria-components/test/RadioGroup.test.js +++ b/packages/react-aria-components/test/RadioGroup.test.js @@ -936,7 +936,8 @@ describe('RadioButton', function () { ); - let radio = getByRole('radio'); - expect(radio).not.toHaveStyle('position: fixed'); + let radioInput = getByRole('radio'); + let radioLabel = radioInput.closest('label'); + expect(radioLabel).not.toHaveStyle('position: fixed'); }); }); From f09ec981309ff027ed22292bfa545406a8209849 Mon Sep 17 00:00:00 2001 From: gonzoblasco <15630819+gonzoblasco@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:59:00 -0300 Subject: [PATCH 15/16] style: final formatting fix for CI --- packages/react-aria-components/src/Checkbox.tsx | 8 +++++--- packages/react-aria-components/src/RadioGroup.tsx | 5 +++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/react-aria-components/src/Checkbox.tsx b/packages/react-aria-components/src/Checkbox.tsx index 3a51eb7d82c..10ffe4bbed2 100644 --- a/packages/react-aria-components/src/Checkbox.tsx +++ b/packages/react-aria-components/src/Checkbox.tsx @@ -539,8 +539,9 @@ export const CheckboxButton = /*#__PURE__*/ (forwardRef as forwardRefType)(funct {...mergeProps(DOMProps, labelProps, hoverProps, renderProps)} ref={ref} style={{ - ...props.style, - ['anchorName' as any]: (props.style as any)?.anchorName ?? `--react-aria-checkbox-${uniqueId}` + ...props.style, + ['anchorName' as any]: + (props.style as any)?.anchorName ?? `--react-aria-checkbox-${uniqueId}` }} slot={props.slot || undefined} data-selected={isSelected || undefined} @@ -560,7 +561,8 @@ export const CheckboxButton = /*#__PURE__*/ (forwardRef as forwardRefType)(funct style={{ position: 'fixed', margin: 0, - ['positionAnchor' as any]: (props.style as any)?.anchorName ?? `--react-aria-checkbox-${uniqueId}`, + ['positionAnchor' as any]: + (props.style as any)?.anchorName ?? `--react-aria-checkbox-${uniqueId}`, top: 'anchor(top)', left: 'anchor(left)', width: 'anchor-size(width)', diff --git a/packages/react-aria-components/src/RadioGroup.tsx b/packages/react-aria-components/src/RadioGroup.tsx index 7895b53cc67..f7e9e4d608f 100644 --- a/packages/react-aria-components/src/RadioGroup.tsx +++ b/packages/react-aria-components/src/RadioGroup.tsx @@ -505,7 +505,7 @@ export const RadioButton = /*#__PURE__*/ (forwardRef as forwardRefType)(function {...mergeProps(DOMProps, labelProps, hoverProps, renderProps)} ref={ref} style={{ - ...props.style, + ...props.style, ['anchorName' as any]: (props.style as any)?.anchorName ?? `--react-aria-radio-${uniqueId}` }} data-selected={isSelected || undefined} @@ -524,7 +524,8 @@ export const RadioButton = /*#__PURE__*/ (forwardRef as forwardRefType)(function style={{ position: 'fixed', margin: 0, - ['positionAnchor' as any]: (props.style as any)?.anchorName ?? `--react-aria-radio-${uniqueId}`, + ['positionAnchor' as any]: + (props.style as any)?.anchorName ?? `--react-aria-radio-${uniqueId}`, top: 'anchor(top)', left: 'anchor(left)', width: 'anchor-size(width)', From 887be2dc304842c503aa1be36d3191ac3f384add Mon Sep 17 00:00:00 2001 From: gonzoblasco <15630819+gonzoblasco@users.noreply.github.com> Date: Mon, 21 Sep 2026 05:06:55 -0300 Subject: [PATCH 16/16] test: fix Checkbox test to target label instead of input for position check --- packages/react-aria-components/test/Checkbox.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/test/Checkbox.test.js b/packages/react-aria-components/test/Checkbox.test.js index 5c9ceab34b3..e3cb157e137 100644 --- a/packages/react-aria-components/test/Checkbox.test.js +++ b/packages/react-aria-components/test/Checkbox.test.js @@ -515,7 +515,8 @@ describe('CheckboxButton', function () { Test ); - let checkbox = getByRole('checkbox'); - expect(checkbox).not.toHaveStyle('position: fixed'); + let checkboxInput = getByRole('checkbox'); + let checkboxLabel = checkboxInput.closest('label'); + expect(checkboxLabel).not.toHaveStyle('position: fixed'); }); });