Skip to content
Open
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
23 changes: 21 additions & 2 deletions packages/react-core/src/components/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.*/
Expand All @@ -52,6 +55,7 @@ const defaultOnChange = () => {};

interface CheckboxState {
ouiaStateId: string;
descriptionId: string;
}

class Checkbox extends Component<CheckboxProps, CheckboxState> {
Expand All @@ -70,7 +74,8 @@ class Checkbox extends Component<CheckboxProps, CheckboxState> {
constructor(props: any) {
super(props);
this.state = {
ouiaStateId: getDefaultOUIAId(Checkbox.displayName)
ouiaStateId: getDefaultOUIAId(Checkbox.displayName),
descriptionId: getUniqueId('pf-checkbox-description')
};
}

Expand All @@ -81,6 +86,7 @@ class Checkbox extends Component<CheckboxProps, CheckboxState> {
render() {
const {
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy,
className,
inputClassName,
onChange,
Expand Down Expand Up @@ -115,6 +121,14 @@ class Checkbox extends Component<CheckboxProps, CheckboxState> {
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 = (
<input
{...props}
Expand All @@ -123,6 +137,7 @@ class Checkbox extends Component<CheckboxProps, CheckboxState> {
onChange={this.handleChange}
aria-invalid={!isValid}
aria-label={ariaLabel}
aria-describedby={ariaDescribedByValue}
disabled={isDisabled}
required={isRequired}
ref={(elem) => {
Expand Down Expand Up @@ -169,7 +184,11 @@ class Checkbox extends Component<CheckboxProps, CheckboxState> {
{labelRendered}
</>
)}
{description && <span className={css(styles.checkDescription)}>{description}</span>}
{description && (
<span id={this.state.descriptionId} className={css(styles.checkDescription)}>
{description}
</span>
)}
{body && <span className={css(styles.checkBody)}>{body}</span>}
</Component>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,3 +287,37 @@ test('Matches snapshot', () => {

expect(asFragment()).toMatchSnapshot();
});

test('Sets aria-describedby when description is provided', () => {
render(<Checkbox id="test-id" description="test description" />);

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(<Checkbox id="test-id" description="test description" aria-describedby="custom-id" />);

const checkbox = screen.getByRole('checkbox');

expect(checkbox).toHaveAttribute('aria-describedby', 'custom-id');
});

test('Does not set aria-describedby when no description is provided', () => {
render(<Checkbox id="test-id" />);

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(<Checkbox id="test-id" description="test description" aria-describedby="" />);

const checkbox = screen.getByRole('checkbox');

expect(checkbox).not.toHaveAttribute('aria-describedby');
});
24 changes: 21 additions & 3 deletions packages/react-core/src/components/Radio/Radio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<React.HTMLProps<HTMLInputElement>, 'disabled' | 'label' | 'onChange' | 'type'>,
Expand Down Expand Up @@ -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.*/
Expand All @@ -47,7 +50,7 @@ export interface RadioProps
ouiaSafe?: boolean;
}

class Radio extends Component<RadioProps, { ouiaStateId: string }> {
class Radio extends Component<RadioProps, { ouiaStateId: string; descriptionId: string }> {
static displayName = 'Radio';
static defaultProps: PickOptional<RadioProps> = {
className: '',
Expand All @@ -63,7 +66,8 @@ class Radio extends Component<RadioProps, { ouiaStateId: string }> {
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')
};
}

Expand All @@ -74,6 +78,7 @@ class Radio extends Component<RadioProps, { ouiaStateId: string }> {
render() {
const {
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy,
checked,
className,
inputClassName,
Expand All @@ -98,13 +103,22 @@ class Radio extends Component<RadioProps, { ouiaStateId: string }> {
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 = (
<input
{...props}
className={css(styles.radioInput, inputClassName)}
type="radio"
onChange={this.handleChange}
aria-invalid={!isValid}
aria-describedby={ariaDescribedByValue}
disabled={isDisabled}
checked={checked || isChecked}
{...(checked === undefined && { defaultChecked })}
Expand Down Expand Up @@ -143,7 +157,11 @@ class Radio extends Component<RadioProps, { ouiaStateId: string }> {
{labelRendered}
</>
)}
{description && <span className={css(styles.radioDescription)}>{description}</span>}
{description && (
<span id={this.state.descriptionId} className={css(styles.radioDescription)}>
{description}
</span>
)}
{body && <span className={css(styles.radioBody)}>{body}</span>}
</Component>
);
Expand Down
44 changes: 44 additions & 0 deletions packages/react-core/src/components/Radio/__tests__/Radio.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,48 @@ 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(<Radio id="test-id" name="check" aria-label="test radio" description="test description" />);

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(
<Radio
id="test-id"
name="check"
aria-label="test radio"
description="test description"
aria-describedby="custom-id"
/>
);

const radio = screen.getByRole('radio');

expect(radio).toHaveAttribute('aria-describedby', 'custom-id');
});

test('Does not set aria-describedby when no description is provided', () => {
render(<Radio id="test-id" name="check" aria-label="test radio" />);

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(
<Radio id="test-id" name="check" aria-label="test radio" description="test description" aria-describedby="" />
);

const radio = screen.getByRole('radio');

expect(radio).not.toHaveAttribute('aria-describedby');
});
});
Loading