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/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); 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'); + }); +});