Skip to content

Commit 2c0321d

Browse files
fix [RAC]: RadioGroup/Dialog Focus (#4766)
* fix [RAC]: RadioGroup/Dialog Focus Resolved issue with RadioGroup tab navigation within Dialog Component. Fixes #4746 * Update packages/react-aria-components/example/index.css --------- Co-authored-by: Daniel Lu <[email protected]>
1 parent 8c9d3bd commit 2c0321d

File tree

4 files changed

+233
-4
lines changed

4 files changed

+233
-4
lines changed

packages/react-aria-components/example/index.css

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,85 @@ html {
206206
border: 2px solid blue;
207207
}
208208
}
209+
210+
.radiogroup {
211+
display: flex;
212+
flex-direction: column;
213+
gap: 8px;
214+
215+
&[aria-orientation=horizontal] {
216+
flex-direction: row;
217+
align-items: center;
218+
}
219+
220+
& [slot=description] {
221+
font-size: 12px;
222+
}
223+
224+
& [slot=errorMessage] {
225+
font-size: 12px;
226+
color: var(--spectrum-global-color-red-600);
227+
}
228+
}
229+
230+
.radio {
231+
--label-color: var(--spectrum-alias-text-color);
232+
--deselected-color: gray;
233+
--deselected-color-pressed: dimgray;
234+
--background-color: var(--spectrum-global-color-gray-50);
235+
--selected-color: slateblue;
236+
--selected-color-pressed: lch(from slateblue 38% c h);
237+
--invalid-color: var(--spectrum-global-color-static-red-600);
238+
--invalid-color-pressed: var(--spectrum-global-color-static-red-700);
239+
240+
display: flex;
241+
align-items: center;
242+
gap: 0.571rem;
243+
font-size: 1.143rem;
244+
color: var(--label-color);
245+
246+
&:before {
247+
content: '';
248+
display: block;
249+
width: 1.286rem;
250+
height: 1.286rem;
251+
box-sizing: border-box;
252+
border: 0.143rem solid var(--deselected-color);
253+
background: var(--background-color);
254+
border-radius: 1.286rem;
255+
transition: all 200ms;
256+
}
257+
258+
&[data-pressed]:before {
259+
border-color: var(--deselected-color-pressed);
260+
}
261+
262+
&[data-selected] {
263+
&:before {
264+
border-color: var(--selected-color);
265+
border-width: 0.429rem;
266+
}
267+
268+
&[data-pressed]:before {
269+
border-color: var(--selected-color-pressed);
270+
}
271+
}
272+
273+
&[data-validation-state=invalid] {
274+
&:before {
275+
border-color: var(--invalid-color);
276+
}
277+
278+
&[data-pressed]:before {
279+
border-color: var(--invalid-color-pressed);
280+
}
281+
}
282+
283+
&[data-focus-visible]:before {
284+
box-shadow: 0 0 0 2px canvas, 0 0 0 4px var(--selected-color);
285+
}
286+
287+
&[data-disabled] {
288+
opacity: 0.4;
289+
}
290+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ function Radio(props: RadioProps, ref: ForwardedRef<HTMLInputElement>) {
229229
data-validation-state={state.validationState || undefined}
230230
data-required={state.isRequired || undefined}>
231231
<VisuallyHidden elementType="span">
232-
<input {...inputProps} {...focusProps} ref={domRef} />
232+
<input {...mergeProps(inputProps, focusProps)} ref={domRef} />
233233
</VisuallyHidden>
234234
{renderProps.children}
235235
</label>

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

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

1313
import {action} from '@storybook/addon-actions';
14-
import {Button, Calendar, CalendarCell, CalendarGrid, Cell, Column, ComboBox, DateField, DateInput, DatePicker, DateRangePicker, DateSegment, Dialog, DialogTrigger, DropZone, FileTrigger, Group, Header, Heading, Input, Item, Keyboard, Label, Link, ListBox, ListBoxProps, Menu, MenuTrigger, Modal, ModalOverlay, NumberField, OverlayArrow, Popover, RangeCalendar, Row, Section, Select, SelectValue, Separator, Slider, SliderOutput, SliderThumb, SliderTrack, Tab, Table, TableBody, TableHeader, TabList, TabPanel, Tabs, TabsProps, Text, TimeField, Tooltip, TooltipTrigger, useDragAndDrop} from 'react-aria-components';
14+
import {Button, Calendar, CalendarCell, CalendarGrid, Cell, Column, ComboBox, DateField, DateInput, DatePicker, DateRangePicker, DateSegment, Dialog, DialogTrigger, DropZone, FileTrigger, Group, Header, Heading, Input, Item, Keyboard, Label, Link, ListBox, ListBoxProps, Menu, MenuTrigger, Modal, ModalOverlay, NumberField, OverlayArrow, Popover, Radio, RadioGroup, RangeCalendar, Row, Section, Select, SelectValue, Separator, Slider, SliderOutput, SliderThumb, SliderTrack, Tab, Table, TableBody, TableHeader, TabList, TabPanel, Tabs, TabsProps, Text, TimeField, Tooltip, TooltipTrigger, useDragAndDrop} from 'react-aria-components';
1515
import {classNames} from '@react-spectrum/utils';
1616
import clsx from 'clsx';
1717
import {FocusRing, mergeProps, useButton, useClipboard, useDrag} from 'react-aria';
@@ -1032,3 +1032,64 @@ ListBoxDnd.story = {
10321032
}
10331033
}
10341034
};
1035+
1036+
export const RadioGroupExample = () => {
1037+
return (
1038+
<RadioGroup
1039+
className={styles.radiogroup}>
1040+
<Label>Favorite pet</Label>
1041+
<Radio className={styles.radio} value="dogs">Dog</Radio>
1042+
<Radio className={styles.radio} value="cats">Cat</Radio>
1043+
<Radio className={styles.radio} value="dragon">Dragon</Radio>
1044+
</RadioGroup>
1045+
);
1046+
};
1047+
1048+
export const RadioGroupInDialogExample = () => {
1049+
return (
1050+
<DialogTrigger>
1051+
<Button>Open dialog</Button>
1052+
<ModalOverlay
1053+
style={{
1054+
position: 'fixed',
1055+
zIndex: 100,
1056+
top: 0,
1057+
left: 0,
1058+
bottom: 0,
1059+
right: 0,
1060+
background: 'rgba(0, 0, 0, 0.5)',
1061+
display: 'flex',
1062+
alignItems: 'center',
1063+
justifyContent: 'center'
1064+
}}>
1065+
<Modal
1066+
style={{
1067+
background: 'Canvas',
1068+
color: 'CanvasText',
1069+
border: '1px solid gray',
1070+
padding: 30
1071+
}}>
1072+
<Dialog
1073+
style={{
1074+
outline: '2px solid transparent',
1075+
outlineOffset: '2px',
1076+
position: 'relative'
1077+
}}>
1078+
{({close}) => (
1079+
<>
1080+
<div>
1081+
<RadioGroupExample />
1082+
</div>
1083+
<div>
1084+
<Button onPress={close} style={{marginTop: 10}}>
1085+
Close
1086+
</Button>
1087+
</div>
1088+
</>
1089+
)}
1090+
</Dialog>
1091+
</Modal>
1092+
</ModalOverlay>
1093+
</DialogTrigger>
1094+
);
1095+
};

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

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

13-
import {fireEvent, render} from '@react-spectrum/test-utils';
14-
import {Label, Radio, RadioGroup, RadioGroupContext, Text} from '../';
13+
import {Button, Dialog, DialogTrigger, Label, Modal, Radio, RadioGroup, RadioGroupContext, Text} from '../';
14+
import {fireEvent, render, within} from '@react-spectrum/test-utils';
1515
import React from 'react';
1616
import userEvent from '@testing-library/user-event';
1717

@@ -276,4 +276,90 @@ describe('RadioGroup', () => {
276276
expect(radio).toHaveAttribute('aria-describedby');
277277
expect(radio.getAttribute('aria-describedby').split(' ').map(id => document.getElementById(id).textContent).join(' ')).toBe('Error Description');
278278
});
279+
280+
it('should not navigate within the group using Tab', () => {
281+
let {getAllByRole} = renderGroup({}, {className: ({isFocusVisible}) => isFocusVisible ? 'focus' : ''});
282+
let radios = getAllByRole('radio');
283+
let labelA = radios[0].closest('label');
284+
let labelB = radios[1].closest('label');
285+
let labelC = radios[2].closest('label');
286+
287+
const expectNotFocused = (...labels) => {
288+
labels.forEach((label) => {
289+
expect(label).not.toHaveAttribute('data-focus-visible');
290+
expect(label).not.toHaveClass('focus');
291+
});
292+
};
293+
294+
expectNotFocused(labelA, labelB, labelC);
295+
296+
userEvent.tab();
297+
expect(document.activeElement).toBe(radios[0]);
298+
expect(labelA).toHaveAttribute('data-focus-visible', 'true');
299+
expect(labelA).toHaveClass('focus');
300+
expectNotFocused(labelB, labelC);
301+
302+
userEvent.tab();
303+
expectNotFocused(labelA, labelB, labelC);
304+
305+
userEvent.tab({shift: true});
306+
expect(document.activeElement).toBe(radios[2]);
307+
expect(labelC).toHaveAttribute('data-focus-visible', 'true');
308+
expect(labelC).toHaveClass('focus');
309+
expectNotFocused(labelA, labelB);
310+
});
311+
312+
it('should not navigate within the group using Tab in Dialog', () => {
313+
let {getByRole} = render(
314+
<DialogTrigger>
315+
<Button>Trigger</Button>
316+
<Modal data-test="modal">
317+
<Dialog role="alertdialog" data-test="dialog">
318+
{({close}) => (
319+
<>
320+
<TestRadioGroup radioProps={{className: ({isFocusVisible}) => isFocusVisible ? 'focus' : ''}} />
321+
<Button onPress={close}>Close</Button>
322+
</>
323+
)}
324+
</Dialog>
325+
</Modal>
326+
</DialogTrigger>
327+
);
328+
329+
let trigger = getByRole('button');
330+
userEvent.click(trigger);
331+
332+
let dialog = getByRole('alertdialog');
333+
334+
let radios = within(dialog).getAllByRole('radio');
335+
let labelA = radios[0].closest('label');
336+
let labelB = radios[1].closest('label');
337+
let labelC = radios[2].closest('label');
338+
339+
const expectNotFocused = (...labels) => {
340+
labels.forEach((label) => {
341+
expect(label).not.toHaveAttribute('data-focus-visible');
342+
expect(label).not.toHaveClass('focus');
343+
});
344+
};
345+
346+
expectNotFocused(labelA, labelB, labelC);
347+
348+
userEvent.tab();
349+
expect(document.activeElement).toBe(radios[0]);
350+
expect(labelA).toHaveAttribute('data-focus-visible', 'true');
351+
expect(labelA).toHaveClass('focus');
352+
expectNotFocused(labelB, labelC);
353+
354+
userEvent.tab();
355+
let close = within(dialog).getByRole('button');
356+
expect(document.activeElement).toBe(close);
357+
expectNotFocused(labelA, labelB, labelC);
358+
359+
userEvent.tab({shift: true});
360+
expect(document.activeElement).toBe(radios[2]);
361+
expect(labelC).toHaveAttribute('data-focus-visible', 'true');
362+
expect(labelC).toHaveClass('focus');
363+
expectNotFocused(labelA, labelB);
364+
});
279365
});

0 commit comments

Comments
 (0)