From 9c7487226fddacb43e40ee0edef4ecbba31af634 Mon Sep 17 00:00:00 2001
From: it-rec <19797875+it-rec@users.noreply.github.com>
Date: Wed, 10 Jun 2026 05:58:30 +0000
Subject: [PATCH 01/44] fix(a11y): announce read-only state of form components
to screen readers
Form components previously did not communicate their read-only state to
assistive technology, or did so using poorly-supported `aria-readonly`
or misapplied `aria-disabled` (which announced editable fields as
"disabled"). This violated WCAG 2.1 SC 4.1.2 (Name, Role, Value).
Each affected component now renders a visually-hidden "Read only"
description that is referenced from the focusable control via
`aria-describedby`, providing a reliable, cross-screen-reader
announcement. Misapplied `aria-disabled` on read-only Dropdown,
MultiSelect and Checkbox group controls is replaced so disabled is only
announced for genuinely disabled controls. `aria-readonly` is kept only
on roles that support it.
Covers React and Web Components packages:
Checkbox, CheckboxGroup, Dropdown, MultiSelect, FilterableMultiSelect,
NumberInput, RadioButton, RadioButtonGroup, Select, Slider, Toggle
(and fluid variants, which inherit the behavior).
Fixes #22407
---
.../src/components/Checkbox/Checkbox.tsx | 12 +++++++
.../CheckboxGroup/CheckboxGroup-test.js | 4 +++
.../CheckboxGroup/CheckboxGroup.tsx | 15 +++++++--
.../src/components/Dropdown/Dropdown.tsx | 31 ++++++++++++-------
.../Dropdown/__tests__/Dropdown-test.js | 25 +++++++++++++++
.../MultiSelect/FilterableMultiSelect.tsx | 12 +++++++
.../components/MultiSelect/MultiSelect.tsx | 15 +++++++--
.../__tests__/FilterableMultiSelect-test.js | 29 +++++++++++++++++
.../MultiSelect/__tests__/MultiSelect-test.js | 22 +++++++++++++
.../components/NumberInput/NumberInput.tsx | 12 ++++++-
.../NumberInput/__tests__/NumberInput-test.js | 14 +++++++++
.../components/RadioButton/RadioButton.tsx | 11 +++++++
.../RadioButton/__tests__/RadioButton-test.js | 20 ++++++++++++
.../RadioButtonGroup/RadioButtonGroup-test.js | 25 +++++++++++++++
.../RadioButtonGroup/RadioButtonGroup.tsx | 23 ++++++++++++--
.../react/src/components/Select/Select.tsx | 12 +++++++
.../Select/__tests__/Select-test.js | 19 ++++++++++++
.../react/src/components/Slider/Slider.tsx | 10 ++++++
.../Slider/__tests__/Slider-test.js | 17 ++++++++++
.../react/src/components/Toggle/Toggle.tsx | 15 +++++++++
.../checkbox/__tests__/checkbox-group-test.js | 25 ++++++++++++++-
.../checkbox/__tests__/checkbox-test.js | 14 +++++++++
.../src/components/checkbox/checkbox-group.ts | 24 ++++++++++----
.../src/components/checkbox/checkbox.ts | 6 ++++
.../dropdown/__tests__/dropdown-test.js | 19 ++++++++++++
.../src/components/dropdown/dropdown.ts | 10 ++++++
.../__tests__/multi-select-test.js | 22 +++++++++++++
.../components/multi-select/multi-select.ts | 3 +-
.../__tests__/number-input-test.js | 11 +++++++
.../components/number-input/number-input.ts | 12 +++++++
.../__tests__/radio-button-group-test.js | 17 ++++++++++
.../__tests__/radio-button-test.js | 12 +++++++
.../radio-button/radio-button-group.ts | 9 +++++-
.../components/radio-button/radio-button.ts | 8 ++++-
.../select/__tests__/select-test.js | 15 +++++++++
.../src/components/select/select.ts | 12 ++++++-
.../slider/__tests__/slider-test.js | 27 ++++++++++++++++
.../src/components/slider/slider.ts | 22 +++++++++++++
.../src/components/toggle/toggle.ts | 8 +++++
39 files changed, 587 insertions(+), 32 deletions(-)
diff --git a/packages/react/src/components/Checkbox/Checkbox.tsx b/packages/react/src/components/Checkbox/Checkbox.tsx
index c38f6764f4c0..889bcebcc297 100644
--- a/packages/react/src/components/Checkbox/Checkbox.tsx
+++ b/packages/react/src/components/Checkbox/Checkbox.tsx
@@ -150,6 +150,8 @@ const Checkbox = React.forwardRef(
const checkboxGroupInstanceId = useId();
+ const readOnlyId = `${id}-readonly-text`;
+
const hasHelper = hasHelperText(helperText);
const helperId = !hasHelper
? undefined
@@ -212,6 +214,11 @@ const Checkbox = React.forwardRef(
// readonly attribute not applicable to type="checkbox"
// see - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox
aria-readonly={readOnly}
+ aria-describedby={
+ readOnly
+ ? classNames(readOnlyId, other['aria-describedby'])
+ : other['aria-describedby']
+ }
onClick={(evt) => {
if (readOnly) {
// prevent default stops the checkbox being updated
@@ -240,6 +247,11 @@ const Checkbox = React.forwardRef(
)}
+ {readOnly && (
+
+ Read only
+
+ )}
{normalizedProps.invalid && (
<>
diff --git a/packages/react/src/components/CheckboxGroup/CheckboxGroup-test.js b/packages/react/src/components/CheckboxGroup/CheckboxGroup-test.js
index de37e9715bd1..69bc4153e57d 100644
--- a/packages/react/src/components/CheckboxGroup/CheckboxGroup-test.js
+++ b/packages/react/src/components/CheckboxGroup/CheckboxGroup-test.js
@@ -386,12 +386,14 @@ describe('CheckboxGroup', () => {
class: `${prefix}--checkbox`,
id: 'checkbox-1',
'aria-readonly': 'true',
+ 'aria-describedby': 'checkbox-1-readonly-text',
type: 'checkbox',
});
expect(attributes2).toEqual({
class: `${prefix}--checkbox`,
id: 'checkbox-2',
'aria-readonly': 'true',
+ 'aria-describedby': 'checkbox-2-readonly-text',
type: 'checkbox',
});
});
@@ -422,6 +424,7 @@ describe('CheckboxGroup', () => {
id: 'checkbox-2',
// Should be read-only because it inherits from the group.
'aria-readonly': 'true',
+ 'aria-describedby': 'checkbox-2-readonly-text',
type: 'checkbox',
});
});
@@ -443,6 +446,7 @@ describe('CheckboxGroup', () => {
class: `${prefix}--checkbox`,
id: 'checkbox-1',
'aria-readonly': 'true',
+ 'aria-describedby': 'checkbox-1-readonly-text',
type: 'checkbox',
});
expect(nonCheckboxAttributes).toEqual({
diff --git a/packages/react/src/components/CheckboxGroup/CheckboxGroup.tsx b/packages/react/src/components/CheckboxGroup/CheckboxGroup.tsx
index 43d5524a1ef4..728c85811999 100644
--- a/packages/react/src/components/CheckboxGroup/CheckboxGroup.tsx
+++ b/packages/react/src/components/CheckboxGroup/CheckboxGroup.tsx
@@ -86,6 +86,8 @@ const CheckboxGroup = ({
? undefined
: `checkbox-group-helper-text-${checkboxGroupInstanceId}`;
+ const readOnlyId = `checkbox-group-readonly-text-${checkboxGroupInstanceId}`;
+
const helper = hasHelper && (
{helperText}
@@ -149,9 +151,11 @@ const CheckboxGroup = ({
aria-readonly={readOnly}
aria-disabled={disabled}
aria-describedby={
- !normalizedProps.invalid && !normalizedProps.warn && helper
- ? helperId
- : undefined
+ cx({
+ [helperId as string]:
+ !normalizedProps.invalid && !normalizedProps.warn && helper,
+ [readOnlyId]: readOnly,
+ }) || undefined
}
{...rest}>
)}
+ {readOnly && (
+
+ Read only
+
+ )}
);
}
diff --git a/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js b/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js
index fa0da8ddc8dc..fbda180274ed 100644
--- a/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js
+++ b/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js
@@ -87,6 +87,35 @@ describe('FilterableMultiSelect', () => {
).toBeFalsy();
});
+ it('should announce readonly state to screen readers', async () => {
+ const items = generateItems(4, generateGenericItem);
+ const label = 'test-label';
+ const { container } = render(
+
+ );
+ await waitForPosition();
+
+ // The visually-hidden "Read only" text node should exist
+ // eslint-disable-next-line testing-library/no-node-access
+ const readOnlyText = container.querySelector('#test-readonly-text');
+ expect(readOnlyText).toBeInTheDocument();
+ expect(readOnlyText).toHaveClass('cds--visually-hidden');
+ expect(readOnlyText).toHaveTextContent('Read only');
+
+ // The focusable combobox input should reference the readonly text and be aria-readonly
+ // eslint-disable-next-line testing-library/no-node-access
+ const input = container.querySelector('input');
+ expect(input).toHaveAttribute('aria-readonly', 'true');
+ expect(input.getAttribute('aria-describedby')).toContain(
+ 'test-readonly-text'
+ );
+ });
+
it('should display helper text instead of warning when disabled', async () => {
render(
{
).toBeFalsy();
});
+ it('should announce readonly state to screen readers', async () => {
+ const items = generateItems(4, generateGenericItem);
+ const { container } = render(
+
+ );
+ await waitForPosition();
+
+ const readOnlyText = container.querySelector(`.${prefix}--visually-hidden`);
+ expect(readOnlyText).toBeInTheDocument();
+ expect(readOnlyText).toHaveTextContent('Read only');
+ expect(readOnlyText).toHaveAttribute('id', 'test-readonly-text');
+
+ // The focusable control should reference the readonly description text and
+ // must not be announced as disabled purely because it is read-only.
+ // eslint-disable-next-line testing-library/no-node-access
+ const button = container.querySelector('.cds--list-box__field');
+ expect(button).not.toHaveAttribute('aria-disabled', 'true');
+ expect(button.getAttribute('aria-describedby')).toContain(
+ 'test-readonly-text'
+ );
+ });
+
describe('Component API', () => {
it('should set the default selected items with the `initialSelectedItems` prop', async () => {
const items = generateItems(4, generateGenericItem);
diff --git a/packages/react/src/components/NumberInput/NumberInput.tsx b/packages/react/src/components/NumberInput/NumberInput.tsx
index 737427cffebd..739b3bd00a06 100644
--- a/packages/react/src/components/NumberInput/NumberInput.tsx
+++ b/packages/react/src/components/NumberInput/NumberInput.tsx
@@ -736,6 +736,11 @@ const NumberInput = React.forwardRef(
ariaDescribedBy = helperText ? normalizedProps.helperId : undefined;
}
+ const readOnlyId = `${id}-readonly-text`;
+ const inputDescribedBy = readOnly
+ ? cx(readOnlyId, ariaDescribedBy) || undefined
+ : ariaDescribedBy;
+
function handleOnChange(event) {
if (disabled) {
return;
@@ -916,7 +921,7 @@ const NumberInput = React.forwardRef(
{...rest}
data-invalid={normalizedProps.invalid ? true : undefined}
aria-invalid={normalizedProps.invalid}
- aria-describedby={ariaDescribedBy}
+ aria-describedby={inputDescribedBy}
aria-readonly={readOnly}
disabled={normalizedProps.disabled}
ref={ref}
@@ -1033,6 +1038,11 @@ const NumberInput = React.forwardRef(
type={type}
value={type === 'number' ? value : inputValue}
/>
+ {readOnly && (
+
+ Read only
+
+ )}
{slug ? (
normalizedDecorator
) : decorator ? (
diff --git a/packages/react/src/components/NumberInput/__tests__/NumberInput-test.js b/packages/react/src/components/NumberInput/__tests__/NumberInput-test.js
index de61449477f3..f6d0f544d89e 100644
--- a/packages/react/src/components/NumberInput/__tests__/NumberInput-test.js
+++ b/packages/react/src/components/NumberInput/__tests__/NumberInput-test.js
@@ -454,6 +454,20 @@ describe('NumberInput', () => {
);
});
+ it('should announce the readonly state to screen readers via a visually-hidden description', () => {
+ render();
+
+ const readOnlyText = screen.getByText('Read only');
+ expect(readOnlyText).toBeInTheDocument();
+ expect(readOnlyText).toHaveClass('cds--visually-hidden');
+ expect(readOnlyText).toHaveAttribute('id');
+
+ const input = screen.getByLabelText('test-label');
+ expect(input.getAttribute('aria-describedby')).toContain(
+ readOnlyText.getAttribute('id')
+ );
+ });
+
it('should set the defaultValue of the with `defaultValue`', () => {
render();
expect(screen.getByLabelText('test-label')).toHaveValue(5);
diff --git a/packages/react/src/components/RadioButton/RadioButton.tsx b/packages/react/src/components/RadioButton/RadioButton.tsx
index 445f4b57e1cb..b0d8d29682c8 100644
--- a/packages/react/src/components/RadioButton/RadioButton.tsx
+++ b/packages/react/src/components/RadioButton/RadioButton.tsx
@@ -159,6 +159,7 @@ const RadioButton = React.forwardRef(
const prefix = usePrefix();
const uid = useId('radio-button');
const uniqueId = id || uid;
+ const readOnlyId = `${uniqueId}-readonly-text`;
const normalizedProps = useNormalizedInputProps({
id: uniqueId,
@@ -218,7 +219,17 @@ const RadioButton = React.forwardRef(
name={name}
required={required}
readOnly={readOnly}
+ aria-describedby={
+ readOnly
+ ? classNames(readOnlyId, rest['aria-describedby'])
+ : rest['aria-describedby']
+ }
/>
+ {readOnly && (
+
+ Read only
+
+ )}