Skip to content

Commit 286de49

Browse files
authored
feat(Radio/Checkbox): support for aria-describedBy (#12042)
Signed-off-by: gitdallas <[email protected]>
1 parent 3d202a8 commit 286de49

File tree

4 files changed

+120
-5
lines changed

4 files changed

+120
-5
lines changed

packages/react-core/src/components/Checkbox/Checkbox.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import styles from '@patternfly/react-styles/css/components/Check/check';
33
import { css } from '@patternfly/react-styles';
44
import { PickOptional } from '../../helpers/typeUtils';
55
import { getDefaultOUIAId, getOUIAProps, OUIAProps } from '../../helpers';
6+
import { getUniqueId } from '../../helpers/util';
67
import { ASTERISK } from '../../helpers/htmlConstants';
78

89
export interface CheckboxProps
@@ -39,6 +40,8 @@ export interface CheckboxProps
3940
description?: React.ReactNode;
4041
/** Body text of the checkbox */
4142
body?: React.ReactNode;
43+
/** Custom aria-describedby value for the checkbox input. If not provided and description is set, a unique ID will be generated automatically. */
44+
'aria-describedby'?: string;
4245
/** Sets the checkbox wrapper component to render. Defaults to "div". If set to "label", behaves the same as if isLabelWrapped prop was specified. */
4346
component?: React.ElementType;
4447
/** Value to overwrite the randomly generated data-ouia-component-id.*/
@@ -52,6 +55,7 @@ const defaultOnChange = () => {};
5255

5356
interface CheckboxState {
5457
ouiaStateId: string;
58+
descriptionId: string;
5559
}
5660

5761
class Checkbox extends Component<CheckboxProps, CheckboxState> {
@@ -70,7 +74,8 @@ class Checkbox extends Component<CheckboxProps, CheckboxState> {
7074
constructor(props: any) {
7175
super(props);
7276
this.state = {
73-
ouiaStateId: getDefaultOUIAId(Checkbox.displayName)
77+
ouiaStateId: getDefaultOUIAId(Checkbox.displayName),
78+
descriptionId: getUniqueId('pf-checkbox-description')
7479
};
7580
}
7681

@@ -81,6 +86,7 @@ class Checkbox extends Component<CheckboxProps, CheckboxState> {
8186
render() {
8287
const {
8388
'aria-label': ariaLabel,
89+
'aria-describedby': ariaDescribedBy,
8490
className,
8591
inputClassName,
8692
onChange,
@@ -115,6 +121,14 @@ class Checkbox extends Component<CheckboxProps, CheckboxState> {
115121
checkedProps.defaultChecked = defaultChecked;
116122
}
117123

124+
// Handle aria-describedby logic
125+
let ariaDescribedByValue: string | undefined;
126+
if (ariaDescribedBy !== undefined) {
127+
ariaDescribedByValue = ariaDescribedBy === '' ? undefined : ariaDescribedBy;
128+
} else if (description) {
129+
ariaDescribedByValue = this.state.descriptionId;
130+
}
131+
118132
const inputRendered = (
119133
<input
120134
{...props}
@@ -123,6 +137,7 @@ class Checkbox extends Component<CheckboxProps, CheckboxState> {
123137
onChange={this.handleChange}
124138
aria-invalid={!isValid}
125139
aria-label={ariaLabel}
140+
aria-describedby={ariaDescribedByValue}
126141
disabled={isDisabled}
127142
required={isRequired}
128143
ref={(elem) => {
@@ -169,7 +184,11 @@ class Checkbox extends Component<CheckboxProps, CheckboxState> {
169184
{labelRendered}
170185
</>
171186
)}
172-
{description && <span className={css(styles.checkDescription)}>{description}</span>}
187+
{description && (
188+
<span id={this.state.descriptionId} className={css(styles.checkDescription)}>
189+
{description}
190+
</span>
191+
)}
173192
{body && <span className={css(styles.checkBody)}>{body}</span>}
174193
</Component>
175194
);

packages/react-core/src/components/Checkbox/__tests__/Checkbox.test.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,3 +287,37 @@ test('Matches snapshot', () => {
287287

288288
expect(asFragment()).toMatchSnapshot();
289289
});
290+
291+
test('Sets aria-describedby when description is provided', () => {
292+
render(<Checkbox id="test-id" description="test description" />);
293+
294+
const checkbox = screen.getByRole('checkbox');
295+
const descriptionElement = screen.getByText('test description');
296+
297+
expect(checkbox).toHaveAttribute('aria-describedby', descriptionElement.id);
298+
expect(descriptionElement).toHaveAttribute('id');
299+
});
300+
301+
test('Sets custom aria-describedby when provided', () => {
302+
render(<Checkbox id="test-id" description="test description" aria-describedby="custom-id" />);
303+
304+
const checkbox = screen.getByRole('checkbox');
305+
306+
expect(checkbox).toHaveAttribute('aria-describedby', 'custom-id');
307+
});
308+
309+
test('Does not set aria-describedby when no description is provided', () => {
310+
render(<Checkbox id="test-id" />);
311+
312+
const checkbox = screen.getByRole('checkbox');
313+
314+
expect(checkbox).not.toHaveAttribute('aria-describedby');
315+
});
316+
317+
test('Does not set aria-describedby when description is provided but aria-describedby is empty string', () => {
318+
render(<Checkbox id="test-id" description="test description" aria-describedby="" />);
319+
320+
const checkbox = screen.getByRole('checkbox');
321+
322+
expect(checkbox).not.toHaveAttribute('aria-describedby');
323+
});

packages/react-core/src/components/Radio/Radio.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import styles from '@patternfly/react-styles/css/components/Radio/radio';
33
import { css } from '@patternfly/react-styles';
44
import { PickOptional } from '../../helpers/typeUtils';
55
import { getOUIAProps, OUIAProps, getDefaultOUIAId } from '../../helpers';
6+
import { getUniqueId } from '../../helpers/util';
67

78
export interface RadioProps
89
extends Omit<React.HTMLProps<HTMLInputElement>, 'disabled' | 'label' | 'onChange' | 'type'>,
@@ -39,6 +40,8 @@ export interface RadioProps
3940
description?: React.ReactNode;
4041
/** Body of the radio. */
4142
body?: React.ReactNode;
43+
/** Custom aria-describedby value for the radio input. If not provided and description is set, a unique ID will be generated automatically. */
44+
'aria-describedby'?: string;
4245
/** Sets the radio wrapper component to render. Defaults to "div". If set to "label", behaves the same as if isLabelWrapped prop was specified. */
4346
component?: React.ElementType;
4447
/** Value to overwrite the randomly generated data-ouia-component-id.*/
@@ -47,7 +50,7 @@ export interface RadioProps
4750
ouiaSafe?: boolean;
4851
}
4952

50-
class Radio extends Component<RadioProps, { ouiaStateId: string }> {
53+
class Radio extends Component<RadioProps, { ouiaStateId: string; descriptionId: string }> {
5154
static displayName = 'Radio';
5255
static defaultProps: PickOptional<RadioProps> = {
5356
className: '',
@@ -63,7 +66,8 @@ class Radio extends Component<RadioProps, { ouiaStateId: string }> {
6366
console.error('Radio:', 'Radio requires an aria-label to be specified');
6467
}
6568
this.state = {
66-
ouiaStateId: getDefaultOUIAId(Radio.displayName)
69+
ouiaStateId: getDefaultOUIAId(Radio.displayName),
70+
descriptionId: getUniqueId('pf-radio-description')
6771
};
6872
}
6973

@@ -74,6 +78,7 @@ class Radio extends Component<RadioProps, { ouiaStateId: string }> {
7478
render() {
7579
const {
7680
'aria-label': ariaLabel,
81+
'aria-describedby': ariaDescribedBy,
7782
checked,
7883
className,
7984
inputClassName,
@@ -98,13 +103,22 @@ class Radio extends Component<RadioProps, { ouiaStateId: string }> {
98103
console.error('Radio:', 'id is required to make input accessible');
99104
}
100105

106+
// Handle aria-describedby logic
107+
let ariaDescribedByValue: string | undefined;
108+
if (ariaDescribedBy !== undefined) {
109+
ariaDescribedByValue = ariaDescribedBy === '' ? undefined : ariaDescribedBy;
110+
} else if (description) {
111+
ariaDescribedByValue = this.state.descriptionId;
112+
}
113+
101114
const inputRendered = (
102115
<input
103116
{...props}
104117
className={css(styles.radioInput, inputClassName)}
105118
type="radio"
106119
onChange={this.handleChange}
107120
aria-invalid={!isValid}
121+
aria-describedby={ariaDescribedByValue}
108122
disabled={isDisabled}
109123
checked={checked || isChecked}
110124
{...(checked === undefined && { defaultChecked })}
@@ -143,7 +157,11 @@ class Radio extends Component<RadioProps, { ouiaStateId: string }> {
143157
{labelRendered}
144158
</>
145159
)}
146-
{description && <span className={css(styles.radioDescription)}>{description}</span>}
160+
{description && (
161+
<span id={this.state.descriptionId} className={css(styles.radioDescription)}>
162+
{description}
163+
</span>
164+
)}
147165
{body && <span className={css(styles.radioBody)}>{body}</span>}
148166
</Component>
149167
);

packages/react-core/src/components/Radio/__tests__/Radio.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,48 @@ describe('Radio', () => {
139139
expect(wrapper.children[0].tagName).toBe('LABEL');
140140
expect(wrapper.children[1].tagName).toBe('INPUT');
141141
});
142+
143+
test('Sets aria-describedby when description is provided', () => {
144+
render(<Radio id="test-id" name="check" aria-label="test radio" description="test description" />);
145+
146+
const radio = screen.getByRole('radio');
147+
const descriptionElement = screen.getByText('test description');
148+
149+
expect(radio).toHaveAttribute('aria-describedby', descriptionElement.id);
150+
expect(descriptionElement).toHaveAttribute('id');
151+
});
152+
153+
test('Sets custom aria-describedby when provided', () => {
154+
render(
155+
<Radio
156+
id="test-id"
157+
name="check"
158+
aria-label="test radio"
159+
description="test description"
160+
aria-describedby="custom-id"
161+
/>
162+
);
163+
164+
const radio = screen.getByRole('radio');
165+
166+
expect(radio).toHaveAttribute('aria-describedby', 'custom-id');
167+
});
168+
169+
test('Does not set aria-describedby when no description is provided', () => {
170+
render(<Radio id="test-id" name="check" aria-label="test radio" />);
171+
172+
const radio = screen.getByRole('radio');
173+
174+
expect(radio).not.toHaveAttribute('aria-describedby');
175+
});
176+
177+
test('Does not set aria-describedby when description is provided but aria-describedby is empty string', () => {
178+
render(
179+
<Radio id="test-id" name="check" aria-label="test radio" description="test description" aria-describedby="" />
180+
);
181+
182+
const radio = screen.getByRole('radio');
183+
184+
expect(radio).not.toHaveAttribute('aria-describedby');
185+
});
142186
});

0 commit comments

Comments
 (0)