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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 48 additions & 6 deletions packages/react-aria-components/src/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -90,6 +97,13 @@ export interface CheckboxProps
* A ref for the HTML input element.
*/
inputRef?: Ref<HTMLInputElement | null>;
/**
* 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
Expand All @@ -110,6 +124,13 @@ export interface CheckboxFieldProps
* A ref for the HTML input element.
*/
inputRef?: Ref<HTMLInputElement | null>;
/**
* 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
Expand All @@ -125,6 +146,13 @@ export interface CheckboxButtonProps
* @default 'react-aria-CheckboxButton'
*/
className?: ClassNameOrFunction<CheckboxButtonRenderProps>;
/**
* 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 {
Expand Down Expand Up @@ -343,6 +371,7 @@ interface InternalCheckboxContextValue extends CheckboxAria {
defaultClassName: string;
isIndeterminate?: boolean;
isRequired?: boolean;
hiddenInput?: 'stretch-to-label';
}

const InternalCheckboxContext = createContext<InternalCheckboxContextValue | null>(null);
Expand Down Expand Up @@ -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
}
],
[
Expand Down Expand Up @@ -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
}}>
<CheckboxButton {...props} ref={ref} />
</InternalCheckboxContext.Provider>
Expand All @@ -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
Expand Down Expand Up @@ -547,8 +583,14 @@ export const CheckboxButton = /*#__PURE__*/ (forwardRef as forwardRefType)(funct
data-readonly={isReadOnly || undefined}
data-invalid={isInvalid || undefined}
data-required={isRequired || undefined}>
<VisuallyHidden elementType="span">
<input {...mergeProps(inputProps, focusProps)} ref={inputRef} />
<VisuallyHidden
elementType="span"
style={hiddenInput === 'stretch-to-label' ? {inset: 0, width: 'auto', height: 'auto'} : undefined}>
<input
{...mergeProps(inputProps, focusProps)}
ref={inputRef}
style={hiddenInput === 'stretch-to-label' ? {position: 'absolute', inset: 0, width: '100%', height: '100%'} : undefined}
/>
</VisuallyHidden>
{renderProps.children}
</dom.label>
Expand Down
67 changes: 60 additions & 7 deletions packages/react-aria-components/src/RadioGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -90,6 +97,13 @@ export interface RadioProps
* A ref for the HTML input element.
*/
inputRef?: Ref<HTMLInputElement | null>;
/**
* 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
Expand All @@ -109,6 +123,13 @@ export interface RadioFieldProps
* A ref for the HTML input element.
*/
inputRef?: Ref<HTMLInputElement | null>;
/**
* 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
Expand All @@ -124,6 +145,13 @@ export interface RadioButtonProps
* @default 'react-aria-RadioButton'
*/
className?: ClassNameOrFunction<RadioButtonRenderProps>;
/**
* 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 {
Expand Down Expand Up @@ -364,7 +392,12 @@ export const Radio = /*#__PURE__*/ (forwardRef as forwardRefType)(function Radio

return (
<InternalRadioContext.Provider
value={{...aria, inputRef, defaultClassName: 'react-aria-Radio'}}>
value={{
...aria,
inputRef,
defaultClassName: 'react-aria-Radio',
hiddenInput: props.hiddenInput
}}>
<RadioButton {...props} ref={ref} />
</InternalRadioContext.Provider>
);
Expand All @@ -373,6 +406,7 @@ export const Radio = /*#__PURE__*/ (forwardRef as forwardRefType)(function Radio
interface InternalRadioContextValue extends RadioAria {
inputRef: RefObject<HTMLInputElement | null>;
defaultClassName: string;
hiddenInput?: 'stretch-to-label';
}

const InternalRadioContext = createContext<InternalRadioContextValue | null>(null);
Expand Down Expand Up @@ -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
}
],
[
Expand All @@ -463,12 +498,24 @@ export const RadioButton = /*#__PURE__*/ (forwardRef as forwardRefType)(function
props: RadioButtonProps,
ref: ForwardedRef<HTMLLabelElement>
) {
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
Expand Down Expand Up @@ -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}>
<VisuallyHidden elementType="span">
<input {...mergeProps(inputProps, focusProps)} ref={inputRef} />
<VisuallyHidden
elementType="span"
style={hiddenInput === 'stretch-to-label' ? {inset: 0, width: 'auto', height: 'auto'} : undefined}>
<input
{...mergeProps(inputProps, focusProps)}
ref={inputRef}
style={hiddenInput === 'stretch-to-label' ? {position: 'absolute', inset: 0, width: '100%', height: '100%'} : undefined}
/>
</VisuallyHidden>
{renderProps.children}
</dom.label>
Expand Down
19 changes: 19 additions & 0 deletions packages/react-aria-components/stories/Checkbox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,22 @@ export const CheckboxExample: CheckboxStory = {
</Checkbox>
)
};

// 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 => (
<Checkbox
{...args}
style={{position: 'relative'}}
hiddenInput="stretch-to-label">
<div className="checkbox">
<svg viewBox="0 0 18 18" aria-hidden="true">
<polyline points="1 9 7 14 15 4" />
</svg>
</div>
Unsubscribe
</Checkbox>
)
};
29 changes: 29 additions & 0 deletions packages/react-aria-components/stories/RadioGroup.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<RadioGroup {...props} data-testid="radio-group-focus-ring">
<Label>Favorite pet</Label>
<Radio
onFocus={action('radio focus')}
onBlur={action('radio blur')}
value="dogs"
style={{position: 'relative'}}
hiddenInput="stretch-to-label">
Dog
</Radio>
<Radio
onFocus={action('radio focus')}
onBlur={action('radio blur')}
value="cats"
style={{position: 'relative'}}
hiddenInput="stretch-to-label">
Cat
</Radio>
</RadioGroup>
);
}
};

export const RadioGroupControlledExample: RadioGroupStory = props => {
let [selected, setSelected] = useState<string | null>(null);

Expand Down
Loading