Skip to content

Commit 76d6d27

Browse files
committed
feat(utils): added a low level RadioGroup widget for the radiogroup role
1 parent 51bcf92 commit 76d6d27

File tree

8 files changed

+842
-0
lines changed

8 files changed

+842
-0
lines changed

packages/utils/src/wia-aria/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from "./FocusContainer";
22

33
export * from "./movement";
4+
export * from "./radio";
45

56
export * from "./useScrollLock";
67
export * from "./useFocusOnMount";
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
import React, {
2+
createRef,
3+
CSSProperties,
4+
FocusEvent,
5+
forwardRef,
6+
HTMLAttributes,
7+
KeyboardEvent,
8+
MouseEvent,
9+
ReactNode,
10+
useCallback,
11+
useMemo,
12+
useState,
13+
} from "react";
14+
15+
import { loop } from "../../loop";
16+
import { LabelRequiredForA11y } from "../../types";
17+
import { tryToSubmitRelatedForm } from "../tryToSubmitRelatedForm";
18+
import { RadioWidget } from "./RadioWidget";
19+
import {
20+
RadioWidgetAttributes,
21+
RadioItemStyleObject,
22+
RadioItem,
23+
} from "./types";
24+
import {
25+
defaultGetRadioClassName,
26+
defaultGetRadioStyle,
27+
getRadioItemValue,
28+
} from "./utils";
29+
30+
/**
31+
* This is a controlled component to render a group of radio buttons when the
32+
* `<input type="radio">` does not work.
33+
*
34+
* @since 2.7.0
35+
*/
36+
export interface BaseRadioGroupProps
37+
extends Omit<HTMLAttributes<HTMLSpanElement>, "onChange"> {
38+
/**
39+
*/
40+
id: string;
41+
42+
/**
43+
* The current value for the radio group. This should be the empty string
44+
* (`""`) if no values are selected. Otherwise it should match one of the
45+
* `values`' value.
46+
*/
47+
value: string;
48+
49+
/**
50+
* A list of values/radio props that should be used to render the radio items.
51+
*/
52+
items: readonly RadioItem[];
53+
54+
/**
55+
* A function that changes the current selection within the radio group.
56+
*/
57+
onChange(nextValue: string): void;
58+
59+
/**
60+
* An optional function to get a `style` object for each rendered radio.
61+
*/
62+
getRadioStyle?(item: RadioItemStyleObject): CSSProperties | undefined;
63+
64+
/**
65+
* An optional function to get a `className` for each rendered radio.
66+
*/
67+
getRadioClassName?(item: RadioItemStyleObject): string | undefined;
68+
}
69+
70+
/**
71+
* @since 2.7.0
72+
*/
73+
export type RadioGroupProps = LabelRequiredForA11y<BaseRadioGroupProps>;
74+
75+
/**
76+
* The `RadioGroup` is a low-level component that does not provide any styles
77+
* and instead only provides the accessibility required for a
78+
* `role="radiogroup"` and rendering each `role="radio"` item.
79+
*
80+
* @since 2.7.0
81+
*/
82+
export const RadioGroup = forwardRef<HTMLSpanElement, RadioGroupProps>(
83+
function RadioGroup(
84+
{
85+
id,
86+
getRadioStyle = defaultGetRadioStyle,
87+
getRadioClassName = defaultGetRadioClassName,
88+
items,
89+
value: currentValue,
90+
onBlur,
91+
onFocus,
92+
onClick,
93+
onChange,
94+
onKeyDown,
95+
...props
96+
},
97+
ref
98+
) {
99+
const refs = items.map(() => createRef<HTMLSpanElement>());
100+
const [focused, setFocused] = useState(false);
101+
const handleBlur = useCallback(
102+
(event: FocusEvent<HTMLSpanElement>) => {
103+
onBlur?.(event);
104+
setFocused(false);
105+
},
106+
[onBlur]
107+
);
108+
const handleFocus = useCallback(
109+
(event: FocusEvent<HTMLSpanElement>) => {
110+
onFocus?.(event);
111+
setFocused(true);
112+
},
113+
[onFocus]
114+
);
115+
const handleClick = useCallback(
116+
(event: MouseEvent<HTMLSpanElement>) => {
117+
onClick?.(event);
118+
119+
/* istanbul ignore next: can't really happen */
120+
const radio = (event.target as HTMLElement)?.closest<HTMLSpanElement>(
121+
'[role="radio"]'
122+
);
123+
const index = radio
124+
? refs.findIndex(({ current }) => radio === current)
125+
: -1;
126+
if (index !== -1) {
127+
onChange(getRadioItemValue(items[index]));
128+
/* istanbul ignore next: can't really happen */
129+
refs[index].current?.focus();
130+
}
131+
},
132+
[onChange, onClick, refs, items]
133+
);
134+
135+
const handleKeyDown = useCallback(
136+
(event: KeyboardEvent<HTMLSpanElement>) => {
137+
onKeyDown?.(event);
138+
139+
if (tryToSubmitRelatedForm(event)) {
140+
return;
141+
}
142+
143+
if (
144+
![" ", "ArrowLeft", "ArrowUp", "ArrowRight", "ArrowDown"].includes(
145+
event.key
146+
)
147+
) {
148+
return;
149+
}
150+
151+
/* istanbul ignore next: can't really happen */
152+
const radio = (event.target as HTMLElement)?.closest<HTMLSpanElement>(
153+
'[role="radio"]'
154+
);
155+
if (!radio) {
156+
return;
157+
}
158+
159+
event.preventDefault();
160+
event.stopPropagation();
161+
if (event.key === " ") {
162+
radio.click();
163+
return;
164+
}
165+
166+
const increment =
167+
event.key === "ArrowRight" || event.key === "ArrowDown";
168+
const index = refs.findIndex(({ current }) => current === radio);
169+
/* istanbul ignore next: can't really happen */
170+
if (index !== -1) {
171+
const nextIndex = loop({
172+
value: index,
173+
max: items.length - 1,
174+
increment,
175+
});
176+
refs[nextIndex].current?.focus();
177+
onChange(getRadioItemValue(items[nextIndex]));
178+
}
179+
},
180+
[onChange, onKeyDown, refs, items]
181+
);
182+
183+
const focusable = useMemo(
184+
() => items.some((value) => getRadioItemValue(value) === currentValue),
185+
[currentValue, items]
186+
);
187+
188+
return (
189+
<span
190+
{...props}
191+
id={id}
192+
ref={ref}
193+
role="radiogroup"
194+
onBlur={handleBlur}
195+
onFocus={handleFocus}
196+
onClick={handleClick}
197+
onKeyDown={handleKeyDown}
198+
tabIndex={-1}
199+
>
200+
{items.map((item, i) => {
201+
let props: RadioWidgetAttributes | undefined;
202+
let value: string;
203+
let checked = false;
204+
let children: ReactNode;
205+
let itemStyle: CSSProperties | undefined;
206+
let itemClassName: string | undefined;
207+
if (typeof item === "string") {
208+
value = item;
209+
checked = currentValue === value;
210+
children = value;
211+
itemStyle = getRadioStyle({ index: i, checked, value: item });
212+
itemClassName = getRadioClassName({
213+
index: i,
214+
checked,
215+
value: item,
216+
});
217+
} else {
218+
({ value, children, ...props } = item);
219+
checked = currentValue === value;
220+
itemStyle = getRadioStyle({ index: i, checked, ...item });
221+
itemClassName =
222+
getRadioClassName({
223+
index: i,
224+
checked,
225+
...item,
226+
}) || undefined;
227+
228+
if (typeof children === "undefined") {
229+
children = value;
230+
}
231+
}
232+
233+
return (
234+
<RadioWidget
235+
{...props}
236+
key={value}
237+
id={`${id}-${i + 1}`}
238+
ref={refs[i]}
239+
style={itemStyle}
240+
className={itemClassName}
241+
checked={checked}
242+
tabIndex={checked || (!focused && !focusable) ? 0 : -1}
243+
>
244+
{children}
245+
</RadioWidget>
246+
);
247+
})}
248+
</span>
249+
);
250+
}
251+
);
252+
253+
/* istanbul ignore next */
254+
if (process.env.NODE_ENV !== "production") {
255+
try {
256+
const PropTypes = require("prop-types");
257+
258+
RadioGroup.propTypes = {
259+
id: PropTypes.string.isRequired,
260+
value: PropTypes.string.isRequired,
261+
items: PropTypes.arrayOf(
262+
PropTypes.oneOfType([
263+
PropTypes.string,
264+
PropTypes.shape({
265+
value: PropTypes.string.isRequired,
266+
children: PropTypes.node,
267+
}),
268+
])
269+
).isRequired,
270+
onChange: PropTypes.func.isRequired,
271+
getRadioStyle: PropTypes.func,
272+
getRadioClassName: PropTypes.func,
273+
};
274+
} catch (e) {}
275+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React, { forwardRef } from "react";
2+
import { RadioWidgetAttributes } from "./types";
3+
4+
/**
5+
* @since 2.7.0
6+
*/
7+
export interface RadioWidgetProps extends RadioWidgetAttributes {
8+
/**
9+
* An id to use for the item that is required for a11y. This should normally
10+
* be handled and provided automatically by the `RadioGroup` component.
11+
*/
12+
id: string;
13+
14+
/**
15+
* Boolean if the radio is currently checked.
16+
*/
17+
checked: boolean;
18+
19+
/**
20+
* The current tab index for the item that should normally be handled
21+
* automatically by the `RadioGroup` component. When there are no checked
22+
* radio items or the item is checked, this should be `0`. Otherwise this
23+
* should be set to `-1` so that it is shown that it can be focused but isn't
24+
* included in the tab index flow.
25+
*/
26+
tabIndex: 0 | -1;
27+
}
28+
29+
/**
30+
* This component offers no styles and probably shouldn't be used externally
31+
* since it is just rendered by the `RadioGroup` component.
32+
*
33+
* @since 2.7.0
34+
*/
35+
export const RadioWidget = forwardRef<HTMLSpanElement, RadioWidgetProps>(
36+
function RadioGroupRadio({ checked, children, ...props }, ref) {
37+
return (
38+
<span {...props} aria-checked={checked} ref={ref} role="radio">
39+
{children}
40+
</span>
41+
);
42+
}
43+
);
44+
45+
/* istanbul ignore next */
46+
if (process.env.NODE_ENV !== "production") {
47+
try {
48+
const PropTypes = require("prop-types");
49+
50+
RadioWidget.propTypes = {
51+
id: PropTypes.string.isRequired,
52+
checked: PropTypes.bool.isRequired,
53+
tabIndex: PropTypes.oneOf([0, -1]).isRequired,
54+
};
55+
} catch (e) {}
56+
}

0 commit comments

Comments
 (0)