Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support refs for radioGroup component #1125

Merged
merged 5 commits into from
Jul 26, 2023
Merged
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
11 changes: 11 additions & 0 deletions packages/react/__tests__/src/components/RadioGroup/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,17 @@ test('handles `inline` prop', () => {
expect(wrapper.find('[role="radiogroup"].Radio--inline').exists()).toBe(true);
});

test('handles `ref` prop', () => {
const ref = React.createRef();
mount(<RadioGroup {...defaultProps} ref={ref} />);
expect(ref.current).toBeTruthy();
});

test('handles `tabIndex` prop', () => {
const wrapper = mount(<RadioGroup {...defaultProps} tabIndex={-1} />);
expect(wrapper.find('[role="radiogroup"]').prop('tabIndex')).toBe(-1);
});

test('should return no axe violations', async () => {
const radioGroup = mount(<RadioGroup {...defaultProps} />);
expect(await axe(radioGroup.html())).toHaveNoViolations();
Expand Down
259 changes: 131 additions & 128 deletions packages/react/src/components/RadioGroup/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { Ref, useState, useRef, useEffect, forwardRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Icon from '../Icon';
Expand All @@ -16,138 +16,140 @@ export interface RadioGroupProps {
defaultValue?: string;
value?: any;
inline?: boolean;
onChange: (radio: RadioItem, input: HTMLElement) => void;
onChange?: (radio: RadioItem, input: HTMLElement) => void;
hasLabel?: never;
}

const RadioGroup = ({
name,
radios,
defaultValue,
value,
// eslint-disable-next-line @typescript-eslint/no-empty-function
onChange = () => {},
className,
inline = false,
...other
}: RadioGroupProps) => {
const [currentValue, setCurrentValue] = useState<string | null>(
value || defaultValue || null
);
const [focusIndex, setFocusIndex] = useState<number | null>(null);
const inputs = useRef<HTMLInputElement[]>([]);
const handleChange = (value: any) => setCurrentValue(value);
const onRadioFocus = (index: number) => setFocusIndex(index);
const onRadioBlur = () => setFocusIndex(null);
const onRadioClick = (index: number) => {
const radio = inputs.current?.[index];

if (!radio) {
return;
}

radio.click();
radio.focus();
};

useEffect(() => {
if (typeof value === 'undefined') {
return;
}

setCurrentValue(value);
}, [value]);

const radioButtons = radios.map((radio, index) => {
const {
label,
disabled,
value: radioValue,
labelDescription,
id,
const RadioGroup = forwardRef(
(
{
name,
radios,
defaultValue,
value,
// eslint-disable-next-line @typescript-eslint/no-empty-function
onChange = () => {},
className,
inline = false,
...other
} = radio;
const isChecked = currentValue === radioValue;
const isFocused = focusIndex === index;
}: RadioGroupProps,
ref: Ref<HTMLDivElement>
) => {
const [currentValue, setCurrentValue] = useState<string | null>(
value || defaultValue || null
);
const [focusIndex, setFocusIndex] = useState<number | null>(null);
const inputs = useRef<HTMLInputElement[]>([]);
const handleChange = (value: any) => setCurrentValue(value);
const onRadioFocus = (index: number) => setFocusIndex(index);
const onRadioBlur = () => setFocusIndex(null);
const onRadioClick = (index: number) => {
const radio = inputs.current?.[index];

if (!radio) {
return;
}

radio.click();
radio.focus();
};

useEffect(() => {
if (typeof value === 'undefined') {
return;
}

setCurrentValue(value);
}, [value]);

const radioButtons = radios.map((radio, index) => {
const {
label,
disabled,
value: radioValue,
labelDescription,
id,
className,
...other
} = radio;
const isChecked = currentValue === radioValue;
const isFocused = focusIndex === index;

return (
<div className="Radio__wrap" key={id}>
<div className={classNames('Radio is--flex-row', className)}>
<input
type="radio"
name={name}
value={radioValue}
id={id}
ref={input => {
if (!input) {
return;
}

inputs.current.push(input);
}}
onFocus={() => onRadioFocus(index)}
onBlur={() => onRadioBlur()}
onChange={() => {
handleChange(radioValue);
onChange(radio, inputs.current?.[index]);
}}
disabled={disabled}
checked={isChecked}
aria-describedby={labelDescription ? `${id}Desc` : undefined}
{...other}
/>
<label
htmlFor={id}
className={classNames('Radio__label', {
'Field__label--disabled': disabled
})}
>
{label}
</label>
<Icon
className={classNames('Radio__overlay', {
'Radio__overlay--focused': isFocused,
'Radio__overlay--disabled': disabled
})}
type={isChecked ? 'radio-checked' : 'radio-unchecked'}
aria-hidden="true"
onClick={() => onRadioClick(index)}
/>
</div>
{labelDescription && (
<span
id={`${id}Desc`}
className={classNames('Field__labelDescription', {
'Field__labelDescription--disabled': disabled
})}
>
{labelDescription}
</span>
)}
</div>
);
});

return (
<div className="Radio__wrap" key={id}>
<div className={classNames('Radio is--flex-row', className)}>
<input
type="radio"
name={name}
value={radioValue}
id={id}
ref={input => {
if (!input) {
return;
}
// reset the input refs array
// refs get clobbered every re-render anyway and this supports "dynamic" radios
// (changing the number of radio buttons for example)
inputs.current = [];

inputs.current.push(input);
}}
onFocus={() => onRadioFocus(index)}
onBlur={() => onRadioBlur()}
onChange={() => {
handleChange(radioValue);
onChange(radio, inputs.current?.[index]);
}}
disabled={disabled}
checked={isChecked}
aria-describedby={labelDescription ? `${id}Desc` : undefined}
{...other}
/>
<label
htmlFor={id}
className={classNames('Radio__label', {
'Field__label--disabled': disabled
})}
>
{label}
</label>
<Icon
className={classNames('Radio__overlay', {
'Radio__overlay--focused': isFocused,
'Radio__overlay--disabled': disabled
})}
type={isChecked ? 'radio-checked' : 'radio-unchecked'}
aria-hidden="true"
onClick={() => onRadioClick(index)}
/>
</div>
{labelDescription && (
<span
id={`${id}Desc`}
className={classNames('Field__labelDescription', {
'Field__labelDescription--disabled': disabled
})}
>
{labelDescription}
</span>
)}
return (
<div
className={classNames(className, { 'Radio--inline': inline })}
role="radiogroup"
ref={ref}
{...other}
>
{radioButtons}
</div>
);
});

// Hack to prevent ESLint from erroring about this variable not
// being used. We want to pull it from `props` to ensure it's
// not passed through to the radiogroup element.
void defaultValue;

// reset the input refs array
// refs get clobbered every re-render anyway and this supports "dynamic" radios
// (changing the number of radio buttons for example)
inputs.current = [];

return (
<div
className={classNames(className, { 'Radio--inline': inline })}
role="radiogroup"
{...other}
>
{radioButtons}
</div>
);
};
}
);

RadioGroup.propTypes = {
name: PropTypes.string,
Expand All @@ -157,18 +159,19 @@ RadioGroup.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
labelDescription: PropTypes.string
})
}).isRequired
scurker marked this conversation as resolved.
Show resolved Hide resolved
).isRequired,
hasLabel: (
props: { [key: string]: string },
propName: string,
_propName: string,
componentName: string
) => {
): null | Error => {
if (!props['aria-label'] && !props['aria-labelledby']) {
return new Error(
`${componentName} must have an "aria-label" or "aria-labelledby" prop`
);
}
return null;
},
className: PropTypes.string,
defaultValue: PropTypes.string,
Expand Down
Loading