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

fix(a11y): associate group label with radio buttons #1784

Closed
wants to merge 2 commits into from
Closed
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
57 changes: 52 additions & 5 deletions docs/pages/components/RadioGroup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ import { RadioGroup } from '@deque/cauldron-react'

```jsx example
<FieldWrap>
{/* This label is visually rendered, but you could also just use groupLabel below. */}
<div className="Field__label" id="gyros-label">Do you like gyros?</div>
<RadioGroup
aria-labelledby="gyros-label"
name="gyros"
aria-labelledby="gyros-label"
groupLabel="Gyros Question"
radios={[
{
id: 'gyros-yes',
Expand All @@ -37,11 +39,11 @@ import { RadioGroup } from '@deque/cauldron-react'
},
{
id: 'gyros-friday',
label: 'Only on fridays',
label: 'Only on Fridays',
value: 'friday'
}
]}
value="yes"
defaultValue="yes"
/>
</FieldWrap>
```
Expand Down Expand Up @@ -89,9 +91,9 @@ If your list of options is short, `RadioGroup` can optionally accept an `inline`
<div className="Field__label" id="robot-label">Are you a robot?</div>
<RadioGroup
aria-labelledby="robot-label"
defaultValue="no"
name="robot"
groupLabel="Robot Check"
inline
name="robot"
radios={[
{
id: 'robot-yes',
Expand All @@ -108,6 +110,41 @@ If your list of options is short, `RadioGroup` can optionally accept an `inline`
</FieldWrap>
```

### Descriptions and Disabled Radios

Each radio supports a labelDescription that displays an extra line of text beneath its label, useful for clarifications or hints. The disabled property disallows selection.

```jsx example
<FieldWrap>
<RadioGroup
groupLabel="Movie Genres"
name="genres"
defaultValue="comedy"
radios={[
{
id: 'comedy',
label: 'Comedy',
value: 'comedy',
labelDescription: 'Make me laugh.'
},
{
id: 'horror',
label: 'Horror',
value: 'horror',
labelDescription: 'Scare me!'
},
{
id: 'romance',
label: 'Romance',
value: 'romance',
labelDescription: 'Love is in the air.',
disabled: true // This option is not selectable
}
]}
/>
</FieldWrap>
```

## Props

<ComponentProps
Expand All @@ -134,6 +171,16 @@ If your list of options is short, `RadioGroup` can optionally accept an `inline`
type: 'string',
description: 'The "name" value of the HTMLInputElement.'
},
{
name: 'className',
type: 'string',
description: 'Optional additional class name(s) for the fieldset container.'
},
{
name: 'groupLabel',
type: 'string',
description: 'Displayed in the <legend>. If omitted, use aria-label or aria-labelledby to ensure the group is properly labeled.'
},
{
name: 'defaultValue',
type: 'any',
Expand Down
69 changes: 45 additions & 24 deletions packages/react/src/components/RadioGroup/RadioGroup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,26 @@ const defaultOptions: RadioGroupProps['radios'] = [
{ id: '3', label: 'Green', value: 'green' }
];

const renderRadioGroup = ({
'aria-label': ariaLabel,
name,
radios,
...props
}: RadioGroupProps = {}): HTMLInputElement[] => {
const renderRadioGroup = (props: RadioGroupProps = {}): HTMLInputElement[] => {
const {
'aria-label': ariaLabel = 'radio group',
name = 'radios',
radios = defaultOptions,
groupLabel = 'radio group',
...rest
} = props;

render(
<RadioGroup
aria-label={ariaLabel || 'radio group'}
name={name || 'radios'}
radios={radios || defaultOptions}
{...props}
aria-label={ariaLabel}
name={name}
radios={radios}
groupLabel={groupLabel}
{...rest}
/>
);
return screen.queryAllByRole('radio', {
name: ariaLabel as string
}) as HTMLInputElement[];

return screen.queryAllByRole('radio') as HTMLInputElement[];
};

test('should render radio group', () => {
Expand All @@ -54,7 +57,10 @@ test('shound render disabled radio group item', () => {
...defaultOptions,
{ id: '4', label: 'Yellow', value: 'yellow', disabled: true }
];
const inputs = renderRadioGroup({ radios: optionsWithDisabledOption });
const inputs = renderRadioGroup({
radios: optionsWithDisabledOption,
groupLabel: 'radio group'
});
for (const index in defaultOptions) {
expect(inputs[index]).not.toBeDisabled();
}
Expand All @@ -69,6 +75,7 @@ test('should support value prop', () => {
radios={defaultOptions}
name="radios"
value="green"
groupLabel="radio group"
/>
</form>
);
Expand All @@ -88,6 +95,7 @@ test('should support defaultValue prop', () => {
radios={defaultOptions}
name="radios"
defaultValue="green"
groupLabel="radio group"
/>
</form>
);
Expand All @@ -110,7 +118,10 @@ test('should support labelDescription for radio group items', () => {
...defaultOptions,
optionWithLabelDescription
];
const inputs = renderRadioGroup({ radios: optionsWithLabelDescription });
const inputs = renderRadioGroup({
radios: optionsWithLabelDescription,
groupLabel: 'radio group'
});
expect(inputs[inputs.length - 1]).toHaveAccessibleDescription(
optionWithLabelDescription.labelDescription
);
Expand All @@ -120,19 +131,23 @@ test('should support labelDescription for radio group items', () => {
});

test('should support inline prop', () => {
renderRadioGroup({ inline: true });
renderRadioGroup({ inline: true, groupLabel: 'radio group' });
expect(screen.getByRole('radiogroup')).toHaveClass('Radio--inline');
});

test('should support className prop', () => {
renderRadioGroup({ inline: true, className: 'banana' });
renderRadioGroup({
inline: true,
className: 'banana',
groupLabel: 'radio group'
});
expect(screen.getByRole('radiogroup')).toHaveClass('Radio--inline', 'banana');
});

test('should support ref prop', () => {
const ref = createRef<HTMLDivElement>();
const ref = createRef<HTMLFieldSetElement>();
renderRadioGroup({ ref });
expect(ref.current).toBeInstanceOf(HTMLDivElement);
expect(ref.current).toBeInstanceOf(HTMLFieldSetElement);
expect(ref.current).toEqual(screen.queryByRole('radiogroup'));
});

Expand All @@ -154,7 +169,7 @@ test('should toggle radio correctly', async () => {
test('should handle focus correctly', async () => {
const user = userEvent.setup();
const onFocus = spy();
const [input] = renderRadioGroup({ onFocus });
const [input] = renderRadioGroup({ onFocus, groupLabel: 'radio group' });
const radioIcon = input.parentElement!.querySelector(
'.Radio__overlay'
) as HTMLElement;
Expand All @@ -169,7 +184,7 @@ test('should handle focus correctly', async () => {

test('should handle blur correctly', async () => {
const onBlur = spy();
const [input] = renderRadioGroup({ onBlur });
const [input] = renderRadioGroup({ onBlur, groupLabel: 'radio group' });
const radioIcon = input.parentElement!.querySelector(
'.Radio__overlay'
) as HTMLElement;
Expand All @@ -188,7 +203,7 @@ test('should handle blur correctly', async () => {
test('should handle onChange correctly', async () => {
const user = userEvent.setup();
const onChange = spy();
const [input] = renderRadioGroup({ onChange });
const [input] = renderRadioGroup({ onChange, groupLabel: 'radio group' });

expect(onChange.notCalled).toBeTruthy();
await user.click(input);
Expand All @@ -208,7 +223,10 @@ test('should have no axe violations with disabled radio item', async () => {
...defaultOptions,
{ id: '4', label: 'Yellow', value: 'yellow', disabled: true }
];
renderRadioGroup({ radios: optionsWithDisabledOption });
renderRadioGroup({
radios: optionsWithDisabledOption,
groupLabel: 'radio group'
});
const group = screen.getByRole('radiogroup');
const results = await axe(group);
expect(results).toHaveNoViolations();
Expand All @@ -224,7 +242,10 @@ test('should have no axe violations with radio item and labelDescription', async
labelDescription: 'like a banana'
}
];
renderRadioGroup({ radios: optionsWithDisabledOption });
renderRadioGroup({
radios: optionsWithDisabledOption,
groupLabel: 'radio group'
});
const group = screen.getByRole('radiogroup');
const results = await axe(group);
expect(results).toHaveNoViolations();
Expand Down
15 changes: 10 additions & 5 deletions packages/react/src/components/RadioGroup/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ export interface RadioItem extends React.InputHTMLAttributes<HTMLInputElement> {
}

export interface RadioGroupProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
extends Omit<React.HTMLAttributes<HTMLFieldSetElement>, 'onChange'> {
name?: string;
className?: string;
radios: RadioItem[];
defaultValue?: string;
value?: any;
inline?: boolean;
onChange?: (radio: RadioItem, input: HTMLElement) => void;
groupLabel?: string;
}

const RadioGroup = forwardRef(
Expand All @@ -30,9 +31,10 @@ const RadioGroup = forwardRef(
onChange = () => {},
className,
inline = false,
groupLabel,
...other
}: RadioGroupProps,
ref: Ref<HTMLDivElement>
ref: Ref<HTMLFieldSetElement>
) => {
const [currentValue, setCurrentValue] = useState<string | null>(
value || defaultValue || null
Expand Down Expand Up @@ -138,14 +140,17 @@ const RadioGroup = forwardRef(
inputs.current = [];

return (
<div
className={classNames(className, { 'Radio--inline': inline })}
<fieldset
role="radiogroup"
className={classNames('Radio__fieldset', className, {
'Radio--inline': inline
})}
ref={ref}
{...other}
>
{groupLabel && <legend>{groupLabel}</legend>}
{radioButtons}
</div>
</fieldset>
);
}
);
Expand Down
4 changes: 4 additions & 0 deletions packages/styles/forms.css
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,10 @@ textarea.Field--has-error:focus:hover,
margin-top: var(--space-smallest);
}

.Radio__fieldset {
border: none;
}

.Radio__overlay,
.Checkbox__overlay {
border: 1px solid transparent;
Expand Down
Loading