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}> {clonedChildren} + {readOnly && ( + + Read only + + )}
{normalizedProps.invalid && ( <> diff --git a/packages/react/src/components/Dropdown/Dropdown.tsx b/packages/react/src/components/Dropdown/Dropdown.tsx index a22c81f2fe43..513cc037184f 100644 --- a/packages/react/src/components/Dropdown/Dropdown.tsx +++ b/packages/react/src/components/Dropdown/Dropdown.tsx @@ -598,6 +598,8 @@ const Dropdown = React.forwardRef( } }, [readOnly, onKeyDownHandler]); + const readOnlyId = `${id}-readonly-text`; + const menuProps = useMemo( () => getMenuProps({ @@ -653,18 +655,20 @@ const Dropdown = React.forwardRef( // aria-expanded is already being passed through {...toggleButtonProps} className={`${prefix}--list-box__field`} disabled={normalizedProps.disabled} - aria-disabled={readOnly ? true : undefined} // aria-disabled to remain focusable aria-describedby={ - !inline && - !normalizedProps.invalid && - !normalizedProps.warn && - helper - ? normalizedProps.helperId - : normalizedProps.invalid - ? normalizedProps.invalidId - : normalizedProps.warn - ? normalizedProps.warnId - : undefined + cx( + !inline && + !normalizedProps.invalid && + !normalizedProps.warn && + helper + ? normalizedProps.helperId + : normalizedProps.invalid + ? normalizedProps.invalidId + : normalizedProps.warn + ? normalizedProps.warnId + : undefined, + { [readOnlyId]: readOnly } + ) || undefined } title={ selectedItem && itemToString !== undefined @@ -687,6 +691,11 @@ const Dropdown = React.forwardRef( translateWithId={translateWithId} /> + {readOnly && ( + + Read only + + )} {slug ? ( normalizedDecorator ) : decorator ? ( diff --git a/packages/react/src/components/Dropdown/__tests__/Dropdown-test.js b/packages/react/src/components/Dropdown/__tests__/Dropdown-test.js index 645c9bb14d06..4f750e19a40f 100644 --- a/packages/react/src/components/Dropdown/__tests__/Dropdown-test.js +++ b/packages/react/src/components/Dropdown/__tests__/Dropdown-test.js @@ -679,6 +679,31 @@ describe('Test useEffect ', () => { }); }); +describe('Dropdown readOnly accessibility', () => { + it('should announce readOnly state to screen readers without using aria-disabled', async () => { + render( + + ); + await waitForPosition(); + + const readOnlyText = document.querySelector('.cds--visually-hidden'); + expect(readOnlyText).toBeInTheDocument(); + expect(readOnlyText).toHaveTextContent('Read only'); + + const button = screen.getByRole('combobox'); + expect(button).not.toHaveAttribute('aria-disabled', 'true'); + expect(button.getAttribute('aria-describedby')).toContain( + readOnlyText.getAttribute('id') + ); + }); +}); + describe('Validation message ids', () => { const mockProps = { id: 'test-dropdown', diff --git a/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx b/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx index cd8f6a8984b7..eb631de2b680 100644 --- a/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx +++ b/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx @@ -593,6 +593,7 @@ export const FilterableMultiSelect = forwardRef(function FilterableMultiSelect< ); const menuId = `${id}__menu`; const inputId = `${id}-input`; + const readOnlyId = `${id}-readonly-text`; useEffect(() => { if (!isOpen) { @@ -1027,6 +1028,12 @@ export const FilterableMultiSelect = forwardRef(function FilterableMultiSelect< {!inline && showHelperText ? helper : null} + {readOnly && ( + + Read only + + )}
); }) as { diff --git a/packages/react/src/components/MultiSelect/MultiSelect.tsx b/packages/react/src/components/MultiSelect/MultiSelect.tsx index 44129b28de1a..b232d6bfb22b 100644 --- a/packages/react/src/components/MultiSelect/MultiSelect.tsx +++ b/packages/react/src/components/MultiSelect/MultiSelect.tsx @@ -568,6 +568,7 @@ export const MultiSelect = React.forwardRef( ? undefined : `multiselect-helper-text-${multiSelectInstanceId}`; const fieldLabelId = `multiselect-field-label-${multiSelectInstanceId}`; + const readOnlyId = `${id}-readonly-text`; const helperClasses = cx(`${prefix}--form__helper-text`, { [`${prefix}--form__helper-text--disabled`]: disabled, }); @@ -802,11 +803,14 @@ export const MultiSelect = React.forwardRef( type="button" className={`${prefix}--list-box__field`} disabled={disabled} - aria-disabled={disabled || readOnly} + aria-disabled={disabled || undefined} + {...toggleButtonProps} aria-describedby={ - !inline && showHelperText ? helperId : undefined + cx(toggleButtonProps['aria-describedby'], { + [helperId as string]: !inline && showHelperText && helperId, + [readOnlyId]: readOnly, + }) || undefined } - {...toggleButtonProps} ref={mergedRef} {...readOnlyEventHandlers}> @@ -889,6 +893,11 @@ export const MultiSelect = React.forwardRef( {helperText}
)} + {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 + + )}
+ ${readonly + ? html`Read only` + : null} ${unstable_valueUpper || unstable_valueUpper === '' ? html` ` : ''} @@ -1018,6 +1024,10 @@ class CDSSlider extends HostListenerMixin(FormMixin(FocusMixin(LitElement))) { class="${thumbLowerClasses}" role="slider" tabindex="${!readonly ? 0 : -1}" + aria-readonly=${ifDefined(readonly ? 'true' : undefined)} + aria-describedby=${ifDefined( + readonly ? 'readonly-text' : undefined + )} aria-valuemax="${max}" aria-valuemin="${min}" aria-valuenow="${value}" @@ -1061,6 +1071,10 @@ class CDSSlider extends HostListenerMixin(FormMixin(FocusMixin(LitElement))) { class="${thumbLowerClasses}" role="slider" tabindex="${!readonly ? 0 : -1}" + aria-readonly=${ifDefined(readonly ? 'true' : undefined)} + aria-describedby=${ifDefined( + readonly ? 'readonly-text' : undefined + )} aria-valuemax="${max}" aria-valuemin="${min}" aria-valuenow="${value}" @@ -1105,6 +1119,10 @@ class CDSSlider extends HostListenerMixin(FormMixin(FocusMixin(LitElement))) { class="${thumbUpperClasses}" role="slider" tabindex="${!readonly ? 0 : -1}" + aria-readonly=${ifDefined(readonly ? 'true' : undefined)} + aria-describedby=${ifDefined( + readonly ? 'readonly-text' : undefined + )} aria-valuemax="${max}" aria-valuemin="${min}" aria-valuenow="${unstable_valueUpper}" @@ -1147,6 +1165,10 @@ class CDSSlider extends HostListenerMixin(FormMixin(FocusMixin(LitElement))) { class="${thumbUpperClasses}" role="slider" tabindex="${!readonly ? 0 : -1}" + aria-readonly=${ifDefined(readonly ? 'true' : undefined)} + aria-describedby=${ifDefined( + readonly ? 'readonly-text' : undefined + )} aria-valuemax="${max}" aria-valuemin="${min}" aria-valuenow="${unstable_valueUpper}" diff --git a/packages/web-components/src/components/toggle/toggle.ts b/packages/web-components/src/components/toggle/toggle.ts index aa0fee490d5c..99026b0f1d46 100644 --- a/packages/web-components/src/components/toggle/toggle.ts +++ b/packages/web-components/src/components/toggle/toggle.ts @@ -198,6 +198,7 @@ class CDSToggle extends HostListenerMixin(CDSCheckbox) { labelA, labelB, value, + readOnly, _handleChange: handleChange, } = this; const inputClasses = classMap({ @@ -235,12 +236,19 @@ class CDSToggle extends HostListenerMixin(CDSCheckbox) { type="button" aria-checked=${toggled} aria-labelledby=${ifDefined(ariaLabelledby)} + aria-readonly=${ifDefined(readOnly ? 'true' : undefined)} + aria-describedby=${ifDefined(readOnly ? 'readonly-text' : undefined)} .checked=${toggled} name="${ifDefined(name)}" value="${ifDefined(value)}" ?disabled=${disabled} id="${id}" @click=${handleChange}> + ${readOnly + ? html`Read only` + : null}