From 177efc78d858bbde00f763ee0b3f768bede3f9e4 Mon Sep 17 00:00:00 2001 From: Andres Arevalo Date: Thu, 16 Jan 2025 23:16:33 -0500 Subject: [PATCH 1/2] fix(a11y): associate group label with radio buttons - Replaced the `div` with a `fieldset` element to semantically group radio buttons. - Added a `legend` element containing the `groupLabel` to serve as the group label. - Ensured that the `groupLabel` is properly associated with its group of radio buttons for improved accessibility compliance. - Updated the component's props to include `groupLabel` for specifying the group label. - Applied necessary styling to ensure the `fieldset` and `legend` elements integrate seamlessly with existing styles. Closes: #4498 --- .../react/src/components/RadioGroup/index.tsx | 16 ++++++++++------ packages/styles/forms.css | 4 ++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/react/src/components/RadioGroup/index.tsx b/packages/react/src/components/RadioGroup/index.tsx index f7b20db74..59c9de66a 100644 --- a/packages/react/src/components/RadioGroup/index.tsx +++ b/packages/react/src/components/RadioGroup/index.tsx @@ -9,7 +9,7 @@ export interface RadioItem extends React.InputHTMLAttributes { } export interface RadioGroupProps - extends Omit, 'onChange'> { + extends Omit, 'onChange'> { name?: string; className?: string; radios: RadioItem[]; @@ -17,6 +17,7 @@ export interface RadioGroupProps value?: any; inline?: boolean; onChange?: (radio: RadioItem, input: HTMLElement) => void; + groupLabel: string; } const RadioGroup = forwardRef( @@ -30,9 +31,10 @@ const RadioGroup = forwardRef( onChange = () => {}, className, inline = false, + groupLabel, ...other }: RadioGroupProps, - ref: Ref + ref: Ref ) => { const [currentValue, setCurrentValue] = useState( value || defaultValue || null @@ -138,14 +140,16 @@ const RadioGroup = forwardRef( inputs.current = []; return ( -
+ {groupLabel} {radioButtons} -
+ ); } ); diff --git a/packages/styles/forms.css b/packages/styles/forms.css index 8b89d4422..2758f42aa 100644 --- a/packages/styles/forms.css +++ b/packages/styles/forms.css @@ -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; From 57373dd3ace93fc69156ad5fb8cbae8c610579a8 Mon Sep 17 00:00:00 2001 From: Andres Arevalo Date: Fri, 17 Jan 2025 12:49:13 -0500 Subject: [PATCH 2/2] =?UTF-8?q?feat(A11y):=20ensure=20accessible=20labelin?= =?UTF-8?q?g=20with=20groupLabel,=20aria-label,=20or=20aria-labelledby=20?= =?UTF-8?q?=09=E2=80=A2=09Updated=20RadioGroup=20component=20to=20display?= =?UTF-8?q?=20groupLabel=20within=20the=20=20element.=20=09?= =?UTF-8?q?=E2=80=A2=09If=20groupLabel=20is=20omitted,=20the=20component?= =?UTF-8?q?=20now=20requires=20either=20aria-label=20or=20aria-labelledby?= =?UTF-8?q?=20to=20ensure=20the=20radio=20group=20is=20properly=20labeled?= =?UTF-8?q?=20for=20accessibility.=20=09=E2=80=A2=09Updated=20the=20MDX=20?= =?UTF-8?q?documentation=20to=20reflect=20these=20changes=20and=20provide?= =?UTF-8?q?=20clear=20usage=20examples.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes: #4498 --- docs/pages/components/RadioGroup.mdx | 57 +++++++++++++-- .../components/RadioGroup/RadioGroup.test.tsx | 69 ++++++++++++------- .../react/src/components/RadioGroup/index.tsx | 5 +- 3 files changed, 100 insertions(+), 31 deletions(-) diff --git a/docs/pages/components/RadioGroup.mdx b/docs/pages/components/RadioGroup.mdx index c7a31fc59..0b807bb17 100644 --- a/docs/pages/components/RadioGroup.mdx +++ b/docs/pages/components/RadioGroup.mdx @@ -20,10 +20,12 @@ import { RadioGroup } from '@deque/cauldron-react' ```jsx example + {/* This label is visually rendered, but you could also just use groupLabel below. */}
Do you like gyros?
``` @@ -89,9 +91,9 @@ If your list of options is short, `RadioGroup` can optionally accept an `inline`
Are you a robot?
``` +### 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 + + + +``` + ## Props . If omitted, use aria-label or aria-labelledby to ensure the group is properly labeled.' + }, { name: 'defaultValue', type: 'any', diff --git a/packages/react/src/components/RadioGroup/RadioGroup.test.tsx b/packages/react/src/components/RadioGroup/RadioGroup.test.tsx index e46bf28f7..e9d31e10b 100644 --- a/packages/react/src/components/RadioGroup/RadioGroup.test.tsx +++ b/packages/react/src/components/RadioGroup/RadioGroup.test.tsx @@ -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( ); - return screen.queryAllByRole('radio', { - name: ariaLabel as string - }) as HTMLInputElement[]; + + return screen.queryAllByRole('radio') as HTMLInputElement[]; }; test('should render radio group', () => { @@ -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(); } @@ -69,6 +75,7 @@ test('should support value prop', () => { radios={defaultOptions} name="radios" value="green" + groupLabel="radio group" /> ); @@ -88,6 +95,7 @@ test('should support defaultValue prop', () => { radios={defaultOptions} name="radios" defaultValue="green" + groupLabel="radio group" /> ); @@ -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 ); @@ -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(); + const ref = createRef(); renderRadioGroup({ ref }); - expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(ref.current).toBeInstanceOf(HTMLFieldSetElement); expect(ref.current).toEqual(screen.queryByRole('radiogroup')); }); @@ -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; @@ -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; @@ -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); @@ -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(); @@ -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(); diff --git a/packages/react/src/components/RadioGroup/index.tsx b/packages/react/src/components/RadioGroup/index.tsx index 59c9de66a..61d93dbc1 100644 --- a/packages/react/src/components/RadioGroup/index.tsx +++ b/packages/react/src/components/RadioGroup/index.tsx @@ -17,7 +17,7 @@ export interface RadioGroupProps value?: any; inline?: boolean; onChange?: (radio: RadioItem, input: HTMLElement) => void; - groupLabel: string; + groupLabel?: string; } const RadioGroup = forwardRef( @@ -141,13 +141,14 @@ const RadioGroup = forwardRef( return (
- {groupLabel} + {groupLabel && {groupLabel}} {radioButtons}
);