Skip to content

Commit 4c2d52d

Browse files
authored
fix: Extra Checkbox and Radio focus events (#8567)
* fix: Extra Checkbox and Radio focus events * add RAC story actions * add use checkbox example with independent label and input * add code comments
1 parent 0d96e0d commit 4c2d52d

File tree

9 files changed

+190
-30
lines changed

9 files changed

+190
-30
lines changed

packages/@react-aria/checkbox/docs/useCheckbox.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,10 @@ function Checkbox(props) {
8585
let {children} = props;
8686
let state = useToggleState(props);
8787
let ref = React.useRef(null);
88-
let {inputProps} = useCheckbox(props, state, ref);
88+
let {inputProps, labelProps} = useCheckbox(props, state, ref);
8989

9090
return (
91-
<label style={{display: 'block'}}>
91+
<label {...labelProps} style={{display: 'block'}}>
9292
<input {...inputProps} ref={ref} />
9393
{children}
9494
</label>
@@ -120,12 +120,12 @@ import {mergeProps} from '@react-aria/utils';
120120
function Checkbox(props) {
121121
let state = useToggleState(props);
122122
let ref = React.useRef(null);
123-
let {inputProps} = useCheckbox(props, state, ref);
123+
let {inputProps, labelProps} = useCheckbox(props, state, ref);
124124
let {isFocusVisible, focusProps} = useFocusRing();
125125
let isSelected = state.isSelected && !props.isIndeterminate;
126126

127127
return (
128-
<label style={{display: 'flex', alignItems: 'center', opacity: props.isDisabled ? 0.4 : 1}}>
128+
<label {...labelProps} style={{display: 'flex', alignItems: 'center', opacity: props.isDisabled ? 0.4 : 1}}>
129129
<VisuallyHidden>
130130
<input {...mergeProps(inputProps, focusProps)} ref={ref} />
131131
</VisuallyHidden>

packages/@react-aria/checkbox/docs/useCheckboxGroup.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,14 @@ function Checkbox(props) {
127127
let {children} = props;
128128
let state = React.useContext(CheckboxGroupContext);
129129
let ref = React.useRef(null);
130-
let {inputProps} = useCheckboxGroupItem(props, state, ref);
130+
let {inputProps, labelProps} = useCheckboxGroupItem(props, state, ref);
131131

132132
let isDisabled = state.isDisabled || props.isDisabled;
133133
let isSelected = state.isSelected(props.value);
134134

135135
return (
136136
<label
137+
{...labelProps}
137138
style={{
138139
display: 'block',
139140
color: (isDisabled && 'var(--gray)') || (isSelected && 'var(--blue)'),

packages/@react-aria/checkbox/src/useCheckbox.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import {AriaCheckboxProps} from '@react-types/checkbox';
14-
import {InputHTMLAttributes, LabelHTMLAttributes, useEffect} from 'react';
14+
import {InputHTMLAttributes, LabelHTMLAttributes, useEffect, useMemo} from 'react';
1515
import {mergeProps} from '@react-aria/utils';
1616
import {privateValidationStateProp, useFormValidationState} from '@react-stately/form';
1717
import {RefObject, ValidationResult} from '@react-types/shared';
@@ -69,17 +69,24 @@ export function useCheckbox(props: AriaCheckboxProps, state: ToggleState, inputR
6969
onPress() {
7070
// @ts-expect-error
7171
let {[privateValidationStateProp]: groupValidationState} = props;
72-
72+
7373
let {commitValidation} = groupValidationState
7474
? groupValidationState
7575
: validationState;
76-
76+
7777
commitValidation();
7878
}
7979
});
8080

8181
return {
82-
labelProps: mergeProps(labelProps, pressProps),
82+
labelProps: mergeProps(
83+
labelProps,
84+
pressProps,
85+
useMemo(() => ({
86+
// Prevent label from being focused when mouse down on it.
87+
// Note, this does not prevent the input from being focused in the `click` event.
88+
onMouseDown: e => e.preventDefault()
89+
}), [])),
8390
inputProps: {
8491
...inputProps,
8592
checked: isSelected,
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {action} from '@storybook/addon-actions';
14+
import {AriaCheckboxProps, useCheckbox} from '../';
15+
import React from 'react';
16+
import {StoryObj} from '@storybook/react';
17+
import {useToggleState} from '@react-stately/toggle';
18+
19+
export default {
20+
title: 'useCheckbox'
21+
};
22+
23+
export type CheckboxStory = StoryObj<typeof Checkbox>;
24+
25+
function Checkbox(props: AriaCheckboxProps) {
26+
let {children} = props;
27+
let state = useToggleState(props);
28+
let ref = React.useRef(null);
29+
let {inputProps, labelProps} = useCheckbox(props, state, ref);
30+
31+
return (
32+
<>
33+
<label {...labelProps} style={{display: 'block'}}>
34+
{children}
35+
</label>
36+
<input {...inputProps} ref={ref} />
37+
</>
38+
);
39+
}
40+
41+
export const Example: CheckboxStory = {
42+
render: (args) => <Checkbox {...args}>Unsubscribe</Checkbox>,
43+
args: {
44+
onFocus: action('onFocus'),
45+
onBlur: action('onBlur')
46+
}
47+
};

packages/@react-aria/radio/src/useRadio.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import {AriaRadioProps} from '@react-types/radio';
1414
import {filterDOMProps, mergeProps, useFormReset} from '@react-aria/utils';
15-
import {InputHTMLAttributes, LabelHTMLAttributes} from 'react';
15+
import {InputHTMLAttributes, LabelHTMLAttributes, useMemo} from 'react';
1616
import {radioGroupData} from './utils';
1717
import {RadioGroupState} from '@react-stately/radio';
1818
import {RefObject} from '@react-types/shared';
@@ -116,7 +116,15 @@ export function useRadio(props: AriaRadioProps, state: RadioGroupState, ref: Ref
116116
useFormValidation({validationBehavior}, state, ref);
117117

118118
return {
119-
labelProps: mergeProps(labelProps, {onClick: e => e.preventDefault()}),
119+
labelProps: mergeProps(
120+
labelProps,
121+
useMemo(() => ({
122+
onClick: e => e.preventDefault(),
123+
124+
// Prevent label from being focused when mouse down on it.
125+
// Note, this does not prevent the input from being focused in the `click` event.
126+
onMouseDown: e => e.preventDefault()
127+
}), [])),
120128
inputProps: mergeProps(domProps, {
121129
...interactions,
122130
type: 'radio',

packages/react-aria-components/stories/Checkbox.stories.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {action} from '@storybook/addon-actions';
12
import {Checkbox} from 'react-aria-components';
23
import {Meta, StoryObj} from '@storybook/react';
34
import React from 'react';
@@ -6,14 +7,18 @@ import './styles.css';
67

78
export default {
89
title: 'React Aria Components/Checkbox',
9-
component: Checkbox
10+
component: Checkbox,
11+
args: {
12+
onFocus: action('onFocus'),
13+
onBlur: action('onBlur')
14+
}
1015
} as Meta<typeof Checkbox>;
1116

1217
export type CheckboxStory = StoryObj<typeof Checkbox>;
1318

1419
export const CheckboxExample: CheckboxStory = {
15-
render: () => (
16-
<Checkbox>
20+
render: (args) => (
21+
<Checkbox {...args}>
1722
<div className="checkbox">
1823
<svg viewBox="0 0 18 18" aria-hidden="true">
1924
<polyline points="1 9 7 14 15 4" />

packages/react-aria-components/stories/RadioGroup.stories.tsx

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {action} from '@storybook/addon-actions';
1314
import {Button, Dialog, DialogTrigger, FieldError, Form, Label, Modal, ModalOverlay, Radio, RadioGroup} from 'react-aria-components';
14-
import {Meta, StoryFn} from '@storybook/react';
15+
import {Meta, StoryFn, StoryObj} from '@storybook/react';
1516
import React, {useState} from 'react';
1617
import styles from '../example/index.css';
1718
import './styles.css';
@@ -22,25 +23,34 @@ export default {
2223
} as Meta<typeof RadioGroup>;
2324

2425
export type RadioGroupStory = StoryFn<typeof RadioGroup>;
26+
export type RadioGroupStoryObj = StoryObj<typeof RadioGroup>;
2527

26-
export const RadioGroupExample: RadioGroupStory = () => {
27-
return (
28-
<RadioGroup
29-
data-testid="radio-group-example"
30-
className={styles.radiogroup}>
31-
<Label>Favorite pet</Label>
32-
<Radio className={styles.radio} value="dogs" data-testid="radio-dog">Dog</Radio>
33-
<Radio className={styles.radio} value="cats">Cat</Radio>
34-
<Radio className={styles.radio} value="dragon">Dragon</Radio>
35-
</RadioGroup>
36-
);
28+
export const RadioGroupExample: RadioGroupStoryObj = {
29+
render: (props) => {
30+
return (
31+
<RadioGroup
32+
{...props}
33+
data-testid="radio-group-example"
34+
className={styles.radiogroup}>
35+
<Label>Favorite pet</Label>
36+
<Radio onFocus={action('radio focus')} onBlur={action('radio blur')} className={styles.radio} value="dogs" data-testid="radio-dog">Dog</Radio>
37+
<Radio onFocus={action('radio focus')} onBlur={action('radio blur')} className={styles.radio} value="cats">Cat</Radio>
38+
<Radio onFocus={action('radio focus')} onBlur={action('radio blur')} className={styles.radio} value="dragon">Dragon</Radio>
39+
</RadioGroup>
40+
);
41+
},
42+
args: {
43+
onFocus: action('onFocus'),
44+
onBlur: action('onBlur')
45+
}
3746
};
3847

39-
export const RadioGroupControlledExample: RadioGroupStory = () => {
48+
export const RadioGroupControlledExample: RadioGroupStory = (props) => {
4049
let [selected, setSelected] = useState<string|null>(null);
4150

4251
return (
4352
<RadioGroup
53+
{...props}
4454
data-testid="radio-group-example"
4555
className={styles.radiogroup}
4656
value={selected}
@@ -53,7 +63,7 @@ export const RadioGroupControlledExample: RadioGroupStory = () => {
5363
);
5464
};
5565

56-
export const RadioGroupInDialogExample: RadioGroupStory = () => {
66+
export const RadioGroupInDialogExample: RadioGroupStory = (props) => {
5767
return (
5868
<DialogTrigger>
5969
<Button>Open dialog</Button>
@@ -88,6 +98,7 @@ export const RadioGroupInDialogExample: RadioGroupStory = () => {
8898
<div style={{display: 'flex', flexDirection: 'row', gap: 20}}>
8999
<div>
90100
<RadioGroup
101+
{...props}
91102
data-testid="radio-group-example"
92103
className={styles.radiogroup}>
93104
<Label>Favorite pet</Label>
@@ -142,10 +153,11 @@ export const RadioGroupInDialogExample: RadioGroupStory = () => {
142153
);
143154
};
144155

145-
export const RadioGroupSubmitExample: RadioGroupStory = () => {
156+
export const RadioGroupSubmitExample: RadioGroupStory = (props) => {
146157
return (
147158
<Form>
148159
<RadioGroup
160+
{...props}
149161
className={styles.radiogroup}
150162
data-testid="radio-group-example"
151163
isRequired>

packages/react-aria-components/test/Checkbox.test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,4 +277,27 @@ describe('Checkbox', () => {
277277
let checkbox = getByRole('checkbox');
278278
expect(checkbox).toHaveAttribute('form', 'test');
279279
});
280+
281+
282+
it('should not trigger onBlur/onFocus on sequential presses', async () => {
283+
let onBlur = jest.fn();
284+
let onFocus = jest.fn();
285+
let {getByRole} = render(
286+
<Checkbox onFocus={onFocus} onBlur={onBlur}>Test</Checkbox>
287+
);
288+
289+
let checkbox = getByRole('checkbox');
290+
let label = checkbox.closest('label');
291+
292+
await user.click(label);
293+
expect(onFocus).toHaveBeenCalledTimes(1);
294+
expect(onBlur).not.toHaveBeenCalled();
295+
296+
onFocus.mockClear();
297+
298+
await user.click(label);
299+
300+
expect(onBlur).not.toHaveBeenCalled();
301+
expect(onFocus).not.toHaveBeenCalled();
302+
});
280303
});

packages/react-aria-components/test/RadioGroup.test.js

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -607,11 +607,68 @@ describe('RadioGroup', () => {
607607
await user.keyboard('[ArrowLeft]');
608608
expect(document.activeElement).toBe(radios[0]);
609609
});
610-
610+
611611
it('should support form prop', () => {
612612
let {getAllByRole} = renderGroup({form: 'test'});
613613
for (let radio of getAllByRole('radio')) {
614614
expect(radio).toHaveAttribute('form', 'test');
615615
}
616616
});
617+
618+
it('should not trigger onBlur/onFocus on sequential presses of a Radio', async () => {
619+
let onBlur = jest.fn();
620+
let onFocus = jest.fn();
621+
622+
let {getAllByRole} = render(
623+
<RadioGroup>
624+
<Label>Test</Label>
625+
<Radio value="a" onFocus={onFocus} onBlur={onBlur}>A</Radio>
626+
<Radio value="b">B</Radio>
627+
</RadioGroup>
628+
);
629+
630+
let radios = getAllByRole('radio');
631+
let radio = radios[0];
632+
let label = radio.closest('label');
633+
634+
await user.click(label);
635+
expect(onFocus).toHaveBeenCalledTimes(1);
636+
expect(onBlur).not.toHaveBeenCalled();
637+
638+
onFocus.mockClear();
639+
640+
await user.click(label);
641+
642+
expect(onFocus).not.toHaveBeenCalled();
643+
expect(onBlur).not.toHaveBeenCalled();
644+
});
645+
646+
it('should trigger onBlur when moving focus between different Radio buttons', async () => {
647+
let onBlurA = jest.fn();
648+
let onFocusA = jest.fn();
649+
let onBlurB = jest.fn();
650+
let onFocusB = jest.fn();
651+
652+
let {getAllByRole} = render(
653+
<RadioGroup>
654+
<Label>Test</Label>
655+
<Radio value="a" onFocus={onFocusA} onBlur={onBlurA}>A</Radio>
656+
<Radio value="b" onFocus={onFocusB} onBlur={onBlurB}>B</Radio>
657+
</RadioGroup>
658+
);
659+
660+
let radios = getAllByRole('radio');
661+
let radioA = radios[0];
662+
let radioB = radios[1];
663+
let labelA = radioA.closest('label');
664+
let labelB = radioB.closest('label');
665+
666+
await user.click(labelA);
667+
expect(onFocusA).toHaveBeenCalledTimes(1);
668+
expect(onBlurA).not.toHaveBeenCalled();
669+
670+
await user.click(labelB);
671+
expect(onFocusB).toHaveBeenCalledTimes(1);
672+
expect(onBlurA).toHaveBeenCalledTimes(1);
673+
});
617674
});

0 commit comments

Comments
 (0)