From 42bb1b1b6c66bf90fc58f5d3514f4e7c3678ab96 Mon Sep 17 00:00:00 2001 From: gitdallas <5322142+gitdallas@users.noreply.github.com> Date: Sat, 4 Oct 2025 17:36:54 -0500 Subject: [PATCH 1/2] feat(Radio/Checkbox): support for aria-describedBy Signed-off-by: gitdallas <5322142+gitdallas@users.noreply.github.com> --- .../src/components/Checkbox/Checkbox.tsx | 23 +++++++++++-- .../Checkbox/__tests__/Checkbox.test.tsx | 34 +++++++++++++++++++ .../react-core/src/components/Radio/Radio.tsx | 24 +++++++++++-- .../components/Radio/__tests__/Radio.test.tsx | 34 +++++++++++++++++++ 4 files changed, 110 insertions(+), 5 deletions(-) diff --git a/packages/react-core/src/components/Checkbox/Checkbox.tsx b/packages/react-core/src/components/Checkbox/Checkbox.tsx index cbd488ce62d..ed91f65dd9a 100644 --- a/packages/react-core/src/components/Checkbox/Checkbox.tsx +++ b/packages/react-core/src/components/Checkbox/Checkbox.tsx @@ -3,6 +3,7 @@ import styles from '@patternfly/react-styles/css/components/Check/check'; import { css } from '@patternfly/react-styles'; import { PickOptional } from '../../helpers/typeUtils'; import { getDefaultOUIAId, getOUIAProps, OUIAProps } from '../../helpers'; +import { getUniqueId } from '../../helpers/util'; import { ASTERISK } from '../../helpers/htmlConstants'; export interface CheckboxProps @@ -39,6 +40,8 @@ export interface CheckboxProps description?: React.ReactNode; /** Body text of the checkbox */ body?: React.ReactNode; + /** Custom aria-describedby value for the checkbox input. If not provided and description is set, a unique ID will be generated automatically. */ + 'aria-describedby'?: string; /** Sets the checkbox wrapper component to render. Defaults to "div". If set to "label", behaves the same as if isLabelWrapped prop was specified. */ component?: React.ElementType; /** Value to overwrite the randomly generated data-ouia-component-id.*/ @@ -52,6 +55,7 @@ const defaultOnChange = () => {}; interface CheckboxState { ouiaStateId: string; + descriptionId: string; } class Checkbox extends Component { @@ -70,7 +74,8 @@ class Checkbox extends Component { constructor(props: any) { super(props); this.state = { - ouiaStateId: getDefaultOUIAId(Checkbox.displayName) + ouiaStateId: getDefaultOUIAId(Checkbox.displayName), + descriptionId: getUniqueId('pf-checkbox-description') }; } @@ -81,6 +86,7 @@ class Checkbox extends Component { render() { const { 'aria-label': ariaLabel, + 'aria-describedby': ariaDescribedBy, className, inputClassName, onChange, @@ -115,6 +121,14 @@ class Checkbox extends Component { checkedProps.defaultChecked = defaultChecked; } + // Handle aria-describedby logic + let ariaDescribedByValue: string | undefined; + if (ariaDescribedBy !== undefined) { + ariaDescribedByValue = ariaDescribedBy === '' ? undefined : ariaDescribedBy; + } else if (description) { + ariaDescribedByValue = this.state.descriptionId; + } + const inputRendered = ( { onChange={this.handleChange} aria-invalid={!isValid} aria-label={ariaLabel} + aria-describedby={ariaDescribedByValue} disabled={isDisabled} required={isRequired} ref={(elem) => { @@ -169,7 +184,11 @@ class Checkbox extends Component { {labelRendered} )} - {description && {description}} + {description && ( + + {description} + + )} {body && {body}} ); diff --git a/packages/react-core/src/components/Checkbox/__tests__/Checkbox.test.tsx b/packages/react-core/src/components/Checkbox/__tests__/Checkbox.test.tsx index cdd5f3ab289..0ccba957468 100644 --- a/packages/react-core/src/components/Checkbox/__tests__/Checkbox.test.tsx +++ b/packages/react-core/src/components/Checkbox/__tests__/Checkbox.test.tsx @@ -287,3 +287,37 @@ test('Matches snapshot', () => { expect(asFragment()).toMatchSnapshot(); }); + +test('Sets aria-describedby when description is provided', () => { + render(); + + const checkbox = screen.getByRole('checkbox'); + const descriptionElement = screen.getByText('test description'); + + expect(checkbox).toHaveAttribute('aria-describedby', descriptionElement.id); + expect(descriptionElement).toHaveAttribute('id'); +}); + +test('Sets custom aria-describedby when provided', () => { + render(); + + const checkbox = screen.getByRole('checkbox'); + + expect(checkbox).toHaveAttribute('aria-describedby', 'custom-id'); +}); + +test('Does not set aria-describedby when no description is provided', () => { + render(); + + const checkbox = screen.getByRole('checkbox'); + + expect(checkbox).not.toHaveAttribute('aria-describedby'); +}); + +test('Does not set aria-describedby when description is provided but aria-describedby is empty string', () => { + render(); + + const checkbox = screen.getByRole('checkbox'); + + expect(checkbox).not.toHaveAttribute('aria-describedby'); +}); diff --git a/packages/react-core/src/components/Radio/Radio.tsx b/packages/react-core/src/components/Radio/Radio.tsx index 7c3a1c45b6b..cd565a130bc 100644 --- a/packages/react-core/src/components/Radio/Radio.tsx +++ b/packages/react-core/src/components/Radio/Radio.tsx @@ -3,6 +3,7 @@ import styles from '@patternfly/react-styles/css/components/Radio/radio'; import { css } from '@patternfly/react-styles'; import { PickOptional } from '../../helpers/typeUtils'; import { getOUIAProps, OUIAProps, getDefaultOUIAId } from '../../helpers'; +import { getUniqueId } from '../../helpers/util'; export interface RadioProps extends Omit, 'disabled' | 'label' | 'onChange' | 'type'>, @@ -39,6 +40,8 @@ export interface RadioProps description?: React.ReactNode; /** Body of the radio. */ body?: React.ReactNode; + /** Custom aria-describedby value for the radio input. If not provided and description is set, a unique ID will be generated automatically. */ + 'aria-describedby'?: string; /** Sets the radio wrapper component to render. Defaults to "div". If set to "label", behaves the same as if isLabelWrapped prop was specified. */ component?: React.ElementType; /** Value to overwrite the randomly generated data-ouia-component-id.*/ @@ -47,7 +50,7 @@ export interface RadioProps ouiaSafe?: boolean; } -class Radio extends Component { +class Radio extends Component { static displayName = 'Radio'; static defaultProps: PickOptional = { className: '', @@ -63,7 +66,8 @@ class Radio extends Component { console.error('Radio:', 'Radio requires an aria-label to be specified'); } this.state = { - ouiaStateId: getDefaultOUIAId(Radio.displayName) + ouiaStateId: getDefaultOUIAId(Radio.displayName), + descriptionId: getUniqueId('pf-radio-description') }; } @@ -74,6 +78,7 @@ class Radio extends Component { render() { const { 'aria-label': ariaLabel, + 'aria-describedby': ariaDescribedBy, checked, className, inputClassName, @@ -98,6 +103,14 @@ class Radio extends Component { console.error('Radio:', 'id is required to make input accessible'); } + // Handle aria-describedby logic + let ariaDescribedByValue: string | undefined; + if (ariaDescribedBy !== undefined) { + ariaDescribedByValue = ariaDescribedBy === '' ? undefined : ariaDescribedBy; + } else if (description) { + ariaDescribedByValue = this.state.descriptionId; + } + const inputRendered = ( { type="radio" onChange={this.handleChange} aria-invalid={!isValid} + aria-describedby={ariaDescribedByValue} disabled={isDisabled} checked={checked || isChecked} {...(checked === undefined && { defaultChecked })} @@ -143,7 +157,11 @@ class Radio extends Component { {labelRendered} )} - {description && {description}} + {description && ( + + {description} + + )} {body && {body}} ); diff --git a/packages/react-core/src/components/Radio/__tests__/Radio.test.tsx b/packages/react-core/src/components/Radio/__tests__/Radio.test.tsx index 4e6f2c2808e..95d770a1232 100644 --- a/packages/react-core/src/components/Radio/__tests__/Radio.test.tsx +++ b/packages/react-core/src/components/Radio/__tests__/Radio.test.tsx @@ -139,4 +139,38 @@ describe('Radio', () => { expect(wrapper.children[0].tagName).toBe('LABEL'); expect(wrapper.children[1].tagName).toBe('INPUT'); }); + + test('Sets aria-describedby when description is provided', () => { + render(); + + const radio = screen.getByRole('radio'); + const descriptionElement = screen.getByText('test description'); + + expect(radio).toHaveAttribute('aria-describedby', descriptionElement.id); + expect(descriptionElement).toHaveAttribute('id'); + }); + + test('Sets custom aria-describedby when provided', () => { + render(); + + const radio = screen.getByRole('radio'); + + expect(radio).toHaveAttribute('aria-describedby', 'custom-id'); + }); + + test('Does not set aria-describedby when no description is provided', () => { + render(); + + const radio = screen.getByRole('radio'); + + expect(radio).not.toHaveAttribute('aria-describedby'); + }); + + test('Does not set aria-describedby when description is provided but aria-describedby is empty string', () => { + render(); + + const radio = screen.getByRole('radio'); + + expect(radio).not.toHaveAttribute('aria-describedby'); + }); }); From 1eba459fb52105cd59e4f78bcf24805b0798fe0b Mon Sep 17 00:00:00 2001 From: gitdallas <5322142+gitdallas@users.noreply.github.com> Date: Sun, 5 Oct 2025 09:34:23 -0500 Subject: [PATCH 2/2] lints Signed-off-by: gitdallas <5322142+gitdallas@users.noreply.github.com> --- .../Checkbox/__tests__/Checkbox.test.tsx | 8 +++---- .../components/Radio/__tests__/Radio.test.tsx | 22 ++++++++++++++----- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/react-core/src/components/Checkbox/__tests__/Checkbox.test.tsx b/packages/react-core/src/components/Checkbox/__tests__/Checkbox.test.tsx index 0ccba957468..46b0244dd3e 100644 --- a/packages/react-core/src/components/Checkbox/__tests__/Checkbox.test.tsx +++ b/packages/react-core/src/components/Checkbox/__tests__/Checkbox.test.tsx @@ -293,7 +293,7 @@ test('Sets aria-describedby when description is provided', () => { const checkbox = screen.getByRole('checkbox'); const descriptionElement = screen.getByText('test description'); - + expect(checkbox).toHaveAttribute('aria-describedby', descriptionElement.id); expect(descriptionElement).toHaveAttribute('id'); }); @@ -302,7 +302,7 @@ test('Sets custom aria-describedby when provided', () => { render(); const checkbox = screen.getByRole('checkbox'); - + expect(checkbox).toHaveAttribute('aria-describedby', 'custom-id'); }); @@ -310,7 +310,7 @@ test('Does not set aria-describedby when no description is provided', () => { render(); const checkbox = screen.getByRole('checkbox'); - + expect(checkbox).not.toHaveAttribute('aria-describedby'); }); @@ -318,6 +318,6 @@ test('Does not set aria-describedby when description is provided but aria-descri render(); const checkbox = screen.getByRole('checkbox'); - + expect(checkbox).not.toHaveAttribute('aria-describedby'); }); diff --git a/packages/react-core/src/components/Radio/__tests__/Radio.test.tsx b/packages/react-core/src/components/Radio/__tests__/Radio.test.tsx index 95d770a1232..26f18876e47 100644 --- a/packages/react-core/src/components/Radio/__tests__/Radio.test.tsx +++ b/packages/react-core/src/components/Radio/__tests__/Radio.test.tsx @@ -145,16 +145,24 @@ describe('Radio', () => { const radio = screen.getByRole('radio'); const descriptionElement = screen.getByText('test description'); - + expect(radio).toHaveAttribute('aria-describedby', descriptionElement.id); expect(descriptionElement).toHaveAttribute('id'); }); test('Sets custom aria-describedby when provided', () => { - render(); + render( + + ); const radio = screen.getByRole('radio'); - + expect(radio).toHaveAttribute('aria-describedby', 'custom-id'); }); @@ -162,15 +170,17 @@ describe('Radio', () => { render(); const radio = screen.getByRole('radio'); - + expect(radio).not.toHaveAttribute('aria-describedby'); }); test('Does not set aria-describedby when description is provided but aria-describedby is empty string', () => { - render(); + render( + + ); const radio = screen.getByRole('radio'); - + expect(radio).not.toHaveAttribute('aria-describedby'); }); });