Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9c74872
fix(a11y): announce read-only state of form components to screen readers
it-rec Jun 10, 2026
aa6d33e
Merge branch 'main' into claude/carbon-issue-22407-qesd41
it-rec Jun 19, 2026
6fcb9e0
Merge branch 'main' into claude/carbon-issue-22407-qesd41
it-rec Jun 25, 2026
119adbf
fix(a11y): omit aria-disabled when not disabled on WC multi-select
it-rec Jun 27, 2026
db10488
Merge branch 'main' into claude/carbon-issue-22407-qesd41
it-rec Jun 28, 2026
b0786a8
Merge branch 'main' into claude/carbon-issue-22407-qesd41
it-rec Jul 1, 2026
64c3eef
test(web-components): cover readonly thumbs and disabled clear button
it-rec Jul 1, 2026
db7c98b
Merge branch 'main' into claude/carbon-issue-22407-qesd41
heloiselui Jul 2, 2026
c41f76f
Merge branch 'main' into claude/carbon-issue-22407-qesd41
heloiselui Jul 6, 2026
71e516a
fix(a11y): announce read-only on focusable group children, not the group
it-rec Jul 7, 2026
a4d5976
feat(a11y): localize read-only screen-reader text via translateWithId
it-rec Jul 7, 2026
7694173
Merge branch 'main' into claude/carbon-issue-22407-qesd41
heloiselui Jul 7, 2026
bf40c33
test(react): update Public API snapshot for translateWithId props
it-rec Jul 9, 2026
32aac05
test(number-input): teach translateWithId mock the read-only id
it-rec Jul 9, 2026
f8e1935
Merge branch 'main' into claude/carbon-issue-22407-qesd41
it-rec Jul 9, 2026
3a5b2fc
Merge branch 'main' into claude/carbon-issue-22407-qesd41
heloiselui Jul 14, 2026
5a3ae77
Merge branch 'main' into claude/carbon-issue-22407-qesd41
annawen1 Jul 20, 2026
97ea7aa
fix(a11y): expose helper text in read-only mode
claude Jul 24, 2026
450ea1a
Merge branch 'main' into claude/carbon-issue-22407-qesd41
it-rec Jul 31, 2026
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
12 changes: 12 additions & 0 deletions packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,9 @@ Map {
"title": {
"type": "string",
},
"translateWithId": {
"type": "func",
},
"warn": {
"type": "bool",
},
Expand Down Expand Up @@ -8464,6 +8467,9 @@ Map {
"type": "bool",
},
"slug": [Function],
"translateWithId": {
"type": "func",
},
"value": {
"args": [
[
Expand Down Expand Up @@ -8869,6 +8875,9 @@ Map {
"type": "oneOf",
},
"slug": [Function],
"translateWithId": {
"type": "func",
},
"warn": {
"type": "bool",
},
Expand Down Expand Up @@ -11276,6 +11285,9 @@ Map {
"toggled": {
"type": "bool",
},
"translateWithId": {
"type": "func",
},
},
},
"ToggleSkeleton" => {
Expand Down
42 changes: 38 additions & 4 deletions packages/react/src/components/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,27 @@ import { noopFn } from '../../internal/noopFn';
import { useNormalizedInputProps } from '../../internal/useNormalizedInputProps';
import { AILabel } from '../AILabel';
import { isComponentElement } from '../../internal';
import type { TFunc, TranslateWithId } from '../../types/common';

const translationIds = {
'carbon.checkbox.read-only': 'carbon.checkbox.read-only',
} as const;

type TranslationKey = keyof typeof translationIds;

const defaultTranslations: Record<TranslationKey, string> = {
[translationIds['carbon.checkbox.read-only']]: 'Read only',
};

const defaultTranslateWithId: TFunc<TranslationKey> = (messageId) => {
return defaultTranslations[messageId];
};

type ExcludedAttributes = 'id' | 'onChange' | 'onClick' | 'type';

export interface CheckboxProps
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
ExcludedAttributes
> {
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, ExcludedAttributes>,
TranslateWithId<TranslationKey> {
/**
* Provide an `id` to uniquely identify the Checkbox input
*/
Expand Down Expand Up @@ -124,6 +137,7 @@ const Checkbox = React.forwardRef(
invalidText,
hideLabel,
readOnly,
translateWithId: t = defaultTranslateWithId,
title = '',
warn,
warnText,
Expand All @@ -150,6 +164,8 @@ const Checkbox = React.forwardRef(

const checkboxGroupInstanceId = useId();

const readOnlyId = `${id}-readonly-text`;

const hasHelper = hasHelperText(helperText);
const helperId = !hasHelper
? undefined
Expand Down Expand Up @@ -212,6 +228,13 @@ 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={
classNames(
other['aria-describedby'],
showHelper && helperId,
readOnly && readOnlyId
) || undefined
}
onClick={(evt) => {
if (readOnly) {
// prevent default stops the checkbox being updated
Expand Down Expand Up @@ -240,6 +263,11 @@ const Checkbox = React.forwardRef(
)}
</Text>
</label>
{readOnly && (
<span id={readOnlyId} className={`${prefix}--visually-hidden`}>
{t('carbon.checkbox.read-only')}
</span>
)}
<div className={`${prefix}--checkbox__validation-msg`}>
{normalizedProps.invalid && (
<>
Expand Down Expand Up @@ -336,6 +364,12 @@ Checkbox.propTypes = {
*/
readOnly: PropTypes.bool,

/**
* Optional prop to specify the translation function for internationalization.
* Currently used to translate the read-only screen reader announcement.
*/
translateWithId: PropTypes.func,

/**
* **Experimental**: Provide a `Slug` component to be rendered inside the `Checkbox` component
*/
Expand Down
15 changes: 15 additions & 0 deletions packages/react/src/components/Checkbox/__tests__/Checkbox-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,21 @@ describe('Checkbox', () => {
);
});

it('supports translateWithId for the read-only screen reader text', () => {
const { container } = render(
<Checkbox
labelText="Checkbox label"
id="checkbox-label-1"
readOnly
translateWithId={() => 'Solo lectura'}
/>
);

const readOnlyText = container.querySelector(`.${prefix}--visually-hidden`);
expect(readOnlyText).toBeInTheDocument();
expect(readOnlyText).toHaveTextContent('Solo lectura');
});

it('should respect warn prop', () => {
const { container } = render(
<Checkbox
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
});
Expand Down Expand Up @@ -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',
});
});
Expand All @@ -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({
Expand Down
35 changes: 23 additions & 12 deletions packages/react/src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ export interface OnChangeData<ItemType> {

export interface DropdownProps<ItemType>
extends Omit<HTMLAttributes<HTMLDivElement>, ExcludedAttributes>,
TranslateWithId<ListBoxMenuIconTranslationKey> {
TranslateWithId<
ListBoxMenuIconTranslationKey | 'carbon.dropdown.read-only'
> {
/**
* Specify a label to be read by screen readers on the container node
* 'aria-label' of the ListBox component.
Expand Down Expand Up @@ -596,6 +598,8 @@ const Dropdown = React.forwardRef(
}
}, [readOnly, onKeyDownHandler]);

const readOnlyId = `${id}-readonly-text`;

const menuProps = useMemo(
() =>
getMenuProps({
Expand Down Expand Up @@ -651,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
Expand All @@ -685,6 +691,11 @@ const Dropdown = React.forwardRef(
translateWithId={translateWithId}
/>
</button>
{readOnly && (
<span id={readOnlyId} className={`${prefix}--visually-hidden`}>
{translateWithId?.('carbon.dropdown.read-only') ?? 'Read only'}
</span>
)}
{slug ? (
normalizedDecorator
) : decorator ? (
Expand Down
47 changes: 47 additions & 0 deletions packages/react/src/components/Dropdown/__tests__/Dropdown-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Dropdown
id="my-dropdown"
titleText="Dropdown label"
label="input"
items={generateItems(5, generateGenericItem)}
readOnly
/>
);
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',
Expand Down Expand Up @@ -719,3 +744,25 @@ describe('Validation message ids', () => {
);
});
});

describe('Dropdown readOnly translation', () => {
it('should support a custom translateWithId for the read-only text', async () => {
render(
<Dropdown
id="my-dropdown"
titleText="Dropdown label"
label="input"
items={generateItems(5, generateGenericItem)}
readOnly
translateWithId={(id) =>
id === 'carbon.dropdown.read-only' ? 'Custom read only' : ''
}
/>
);
await waitForPosition();

const readOnlyText = document.querySelector('.cds--visually-hidden');
expect(readOnlyText).toBeInTheDocument();
expect(readOnlyText).toHaveTextContent('Custom read only');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ export interface FilterableMultiSelectProps<ItemType>
extends MultiSelectSortingProps<ItemType>,
React.RefAttributes<HTMLDivElement>,
TranslateWithId<
ListBoxSelectionTranslationKey | ListBoxMenuIconTranslationKey
| ListBoxSelectionTranslationKey
| ListBoxMenuIconTranslationKey
| 'carbon.multi-select.read-only'
> {
/**
* Specify a label to be read by screen readers on the container node
Expand Down Expand Up @@ -592,6 +594,7 @@ export const FilterableMultiSelect = forwardRef(function FilterableMultiSelect<
);
const menuId = `${id}__menu`;
const inputId = `${id}-input`;
const readOnlyId = `${id}-readonly-text`;

useEffect(() => {
if (!isOpen) {
Expand Down Expand Up @@ -1024,6 +1027,11 @@ export const FilterableMultiSelect = forwardRef(function FilterableMultiSelect<
<input
className={inputClasses}
{...inputProp}
aria-readonly={readOnly || undefined}
aria-describedby={
cx(inputProp['aria-describedby'], readOnly && readOnlyId) ||
undefined
}
ref={mergedRef}
{...readOnlyEventHandlers}
readOnly={readOnly}
Expand Down Expand Up @@ -1139,6 +1147,11 @@ export const FilterableMultiSelect = forwardRef(function FilterableMultiSelect<
</ListBox.Menu>
</ListBox>
{!inline && showHelperText ? helper : null}
{readOnly && (
<span id={readOnlyId} className={`${prefix}--visually-hidden`}>
{translateWithId?.('carbon.multi-select.read-only') ?? 'Read only'}
</span>
)}
</div>
);
}) as {
Expand Down
20 changes: 16 additions & 4 deletions packages/react/src/components/MultiSelect/MultiSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ interface OnChangeData<ItemType> {
export interface MultiSelectProps<ItemType>
extends MultiSelectSortingProps<ItemType>,
TranslateWithId<
ListBoxMenuIconTranslationKey | ListBoxSelectionTranslationKey
| ListBoxMenuIconTranslationKey
| ListBoxSelectionTranslationKey
| 'carbon.multi-select.read-only'
> {
/**
* **Experimental**: Will attempt to automatically align the floating
Expand Down Expand Up @@ -572,6 +574,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,
});
Expand Down Expand Up @@ -806,11 +809,15 @@ 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'],
!inline && showHelperText && helperId,
readOnly && readOnlyId
) || undefined
}
{...toggleButtonProps}
ref={mergedRef}
{...readOnlyEventHandlers}>
<span id={fieldLabelId} className={`${prefix}--list-box__label`}>
Expand Down Expand Up @@ -893,6 +900,11 @@ export const MultiSelect = React.forwardRef(
{helperText}
</div>
)}
{readOnly && (
<span id={readOnlyId} className={`${prefix}--visually-hidden`}>
{translateWithId?.('carbon.multi-select.read-only') ?? 'Read only'}
</span>
)}
</div>
);
}
Expand Down
Loading
Loading