Skip to content

Commit 027539f

Browse files
authored
fix(ComposedModal-a11y): label body when scrollable (#22353)
* fix(ComposedModal): label body when scrollable * test: add tests, update snapshots * fix: circular dependency
1 parent 37dff96 commit 027539f

5 files changed

Lines changed: 186 additions & 26 deletions

File tree

packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6730,7 +6730,12 @@ Map {
67306730
"ModalBody" => {
67316731
"$$typeof": Symbol(react.forward_ref),
67326732
"propTypes": {
6733-
"aria-label": [Function],
6733+
"aria-label": {
6734+
"type": "string",
6735+
},
6736+
"aria-labelledby": {
6737+
"type": "string",
6738+
},
67346739
"children": {
67356740
"type": "node",
67366741
},

packages/react/src/components/ComposedModal/ComposedModal-test.js

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Copyright IBM Corp. 2016, 2023
2+
* Copyright IBM Corp. 2016, 2026
33
*
44
* This source code is licensed under the Apache-2.0 license found in the
55
* LICENSE file in the root directory of this source tree.
@@ -1111,3 +1111,74 @@ describe('state with hof withModalPresence', () => {
11111111
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
11121112
});
11131113
});
1114+
1115+
describe('ModalBody scrollable content accessibility', () => {
1116+
it('should apply aria-labelledby to scrollable content using label from ModalHeader', () => {
1117+
render(
1118+
<ComposedModal open>
1119+
<ModalHeader label="Test Label" title="Test Title" />
1120+
<ModalBody hasScrollingContent data-testid="modal-body">
1121+
Scrollable content
1122+
</ModalBody>
1123+
</ComposedModal>
1124+
);
1125+
1126+
const modalBody = screen.getByTestId('modal-body');
1127+
expect(modalBody).toHaveAttribute('role', 'region');
1128+
1129+
const ariaLabelledBy = modalBody.getAttribute('aria-labelledby');
1130+
expect(ariaLabelledBy).toBeTruthy();
1131+
expect(ariaLabelledBy).toContain('modal-header__label');
1132+
});
1133+
1134+
it('should apply aria-labelledby to scrollable content using title from ModalHeader when no label', () => {
1135+
render(
1136+
<ComposedModal open>
1137+
<ModalHeader title="Test Title" />
1138+
<ModalBody hasScrollingContent data-testid="modal-body">
1139+
Scrollable content
1140+
</ModalBody>
1141+
</ComposedModal>
1142+
);
1143+
1144+
const modalBody = screen.getByTestId('modal-body');
1145+
expect(modalBody).toHaveAttribute('role', 'region');
1146+
1147+
const ariaLabelledBy = modalBody.getAttribute('aria-labelledby');
1148+
expect(ariaLabelledBy).toBeTruthy();
1149+
expect(ariaLabelledBy).toContain('modal-header__heading');
1150+
});
1151+
1152+
it('should use explicit aria-labelledby prop over default from context', () => {
1153+
render(
1154+
<ComposedModal open>
1155+
<ModalHeader label="Test Label" title="Test Title" />
1156+
<ModalBody
1157+
hasScrollingContent
1158+
aria-labelledby="custom-label-id"
1159+
data-testid="modal-body">
1160+
Scrollable content
1161+
</ModalBody>
1162+
</ComposedModal>
1163+
);
1164+
1165+
const modalBody = screen.getByTestId('modal-body');
1166+
expect(modalBody).toHaveAttribute('role', 'region');
1167+
1168+
expect(modalBody).toHaveAttribute('aria-labelledby', 'custom-label-id');
1169+
});
1170+
1171+
it('should not apply region role or labelling when content is not scrollable', () => {
1172+
render(
1173+
<ComposedModal open>
1174+
<ModalHeader label="Test Label" title="Test Title" />
1175+
<ModalBody data-testid="modal-body">Non-scrollable content</ModalBody>
1176+
</ComposedModal>
1177+
);
1178+
1179+
const modalBody = screen.getByTestId('modal-body');
1180+
expect(modalBody).not.toHaveAttribute('role', 'region');
1181+
expect(modalBody).not.toHaveAttribute('aria-label');
1182+
expect(modalBody).not.toHaveAttribute('aria-labelledby');
1183+
});
1184+
});

packages/react/src/components/ComposedModal/ComposedModal.tsx

Lines changed: 59 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import React, {
99
Children,
1010
cloneElement,
11+
useState,
1112
useContext,
1213
useEffect,
1314
useRef,
@@ -26,7 +27,6 @@ import { ModalFooter } from './ModalFooter';
2627
import { mergeRefs } from '../../tools/mergeRefs';
2728
import cx from 'classnames';
2829
import { toggleClass } from '../../tools/toggleClass';
29-
import { requiredIfGivenPropIsTruthy } from '../../prop-types/requiredIfGivenPropIsTruthy';
3030
import {
3131
elementOrParentIsFloatingMenu,
3232
wrapFocus,
@@ -50,8 +50,19 @@ import {
5050
import { useId } from '../../internal/useId';
5151
import { useComposedModalState } from './useComposedModalState';
5252
import { isTopmostVisibleModal } from '../Modal/isTopmostVisibleModal';
53+
import { ComposedModalContext } from './ComposedModalContext';
5354

5455
export interface ModalBodyProps extends HTMLAttributes<HTMLDivElement> {
56+
/**
57+
* Specify the aria-label for the modal body when it is scrollable
58+
*/
59+
'aria-label'?: string;
60+
61+
/**
62+
* Specify the aria-labelledby for the modal body when it is scrollable
63+
*/
64+
'aria-labelledby'?: string;
65+
5566
/** Specify the content to be placed in the ModalBody. */
5667
children?: ReactNode;
5768

@@ -70,6 +81,8 @@ export interface ModalBodyProps extends HTMLAttributes<HTMLDivElement> {
7081
export const ModalBody = React.forwardRef<HTMLDivElement, ModalBodyProps>(
7182
function ModalBody(
7283
{
84+
['aria-label']: ariaLabelProp,
85+
['aria-labelledby']: ariaLabelledByProp,
7386
className: customClassName,
7487
children,
7588
hasForm,
@@ -80,6 +93,7 @@ export const ModalBody = React.forwardRef<HTMLDivElement, ModalBodyProps>(
8093
) {
8194
const prefix = usePrefix();
8295
const contentRef = useRef<HTMLDivElement>(null);
96+
const { labelId, titleId } = useContext(ComposedModalContext);
8397

8498
const { height } = useResizeObserver({ ref: contentRef });
8599

@@ -102,9 +116,16 @@ export const ModalBody = React.forwardRef<HTMLDivElement, ModalBodyProps>(
102116
customClassName
103117
);
104118

119+
const ariaLabelledBy = ariaLabelledByProp || labelId || titleId;
120+
105121
const hasScrollingContentProps =
106122
hasScrollingContent || isScrollable
107-
? { tabIndex: 0, role: 'region' }
123+
? {
124+
tabIndex: 0,
125+
role: 'region',
126+
'aria-label': ariaLabelProp,
127+
'aria-labelledby': ariaLabelledBy,
128+
}
108129
: {};
109130

110131
return (
@@ -121,12 +142,14 @@ export const ModalBody = React.forwardRef<HTMLDivElement, ModalBodyProps>(
121142

122143
ModalBody.propTypes = {
123144
/**
124-
* Required props for the accessibility label of the header
145+
* Specify the aria-label for the modal body when it is scrollable
125146
*/
126-
['aria-label']: requiredIfGivenPropIsTruthy(
127-
'hasScrollingContent',
128-
PropTypes.string
129-
),
147+
['aria-label']: PropTypes.string,
148+
149+
/**
150+
* Specify the aria-labelledby for the modal body when it is scrollable
151+
*/
152+
['aria-labelledby']: PropTypes.string,
130153

131154
/**
132155
* Specify the content to be placed in the ModalBody
@@ -290,6 +313,9 @@ const ComposedModalDialog = React.forwardRef<
290313
) {
291314
const prefix = usePrefix();
292315

316+
const [labelId, setLabelId] = useState<string | undefined>(undefined);
317+
const [titleId, setTitleId] = useState<string | undefined>(undefined);
318+
293319
const innerModal = useRef<HTMLDivElement>(null);
294320
const button = useRef<HTMLButtonElement>(null);
295321
const startSentinel = useRef<HTMLButtonElement>(null);
@@ -634,21 +660,33 @@ const ComposedModalDialog = React.forwardRef<
634660
</div>
635661
);
636662

663+
const contextValue = {
664+
labelId,
665+
titleId,
666+
setLabelId,
667+
setTitleId,
668+
};
669+
637670
return (
638-
<Layer
639-
{...rest}
640-
level={0}
641-
role="presentation"
642-
ref={mergedRefs}
643-
aria-hidden={!open}
644-
onBlur={handleBlur}
645-
onClick={composeEventHandlers([rest?.onClick, handleOnClick])}
646-
onMouseDown={composeEventHandlers([rest?.onMouseDown, handleOnMouseDown])}
647-
onKeyDown={handleKeyDown}
648-
className={modalClass}
649-
data-exiting={presenceContext?.isExiting || undefined}>
650-
{modalBody}
651-
</Layer>
671+
<ComposedModalContext.Provider value={contextValue}>
672+
<Layer
673+
{...rest}
674+
level={0}
675+
role="presentation"
676+
ref={mergedRefs}
677+
aria-hidden={!open}
678+
onBlur={handleBlur}
679+
onClick={composeEventHandlers([rest?.onClick, handleOnClick])}
680+
onMouseDown={composeEventHandlers([
681+
rest?.onMouseDown,
682+
handleOnMouseDown,
683+
])}
684+
onKeyDown={handleKeyDown}
685+
className={modalClass}
686+
data-exiting={presenceContext?.isExiting || undefined}>
687+
{modalBody}
688+
</Layer>
689+
</ComposedModalContext.Provider>
652690
);
653691
});
654692

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Copyright IBM Corp. 2026
3+
*
4+
* This source code is licensed under the Apache-2.0 license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import { createContext } from 'react';
9+
10+
export const ComposedModalContext = createContext<{
11+
labelId?: string;
12+
titleId?: string;
13+
setLabelId?: (id: string | undefined) => void;
14+
setTitleId?: (id: string | undefined) => void;
15+
}>({});

packages/react/src/components/ComposedModal/ModalHeader.tsx

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,16 @@ import React, {
99
type ReactNode,
1010
type MouseEvent,
1111
type HTMLAttributes,
12+
useContext,
13+
useEffect,
1214
} from 'react';
1315
import PropTypes from 'prop-types';
1416
import cx from 'classnames';
1517
import { Close } from '@carbon/icons-react';
1618
import { usePrefix } from '../../internal/usePrefix';
1719
import { IconButton } from '../IconButton';
20+
import { useId } from '../../internal/useId';
21+
import { ComposedModalContext } from './ComposedModalContext';
1822

1923
export type DivProps = Omit<HTMLAttributes<HTMLDivElement>, 'title'>;
2024
export interface ModalHeaderProps extends DivProps {
@@ -95,6 +99,25 @@ export const ModalHeader = React.forwardRef<HTMLDivElement, ModalHeaderProps>(
9599
ref
96100
) {
97101
const prefix = usePrefix();
102+
const modalId = useId();
103+
const { setLabelId, setTitleId } = useContext(ComposedModalContext);
104+
105+
const generatedLabelId = `${prefix}--modal-header__label--${modalId}`;
106+
const generatedTitleId = `${prefix}--modal-header__heading--${modalId}`;
107+
108+
useEffect(() => {
109+
if (label && setLabelId) {
110+
setLabelId(generatedLabelId);
111+
return () => setLabelId(undefined);
112+
}
113+
}, [label, generatedLabelId, setLabelId]);
114+
115+
useEffect(() => {
116+
if (title && setTitleId) {
117+
setTitleId(generatedTitleId);
118+
return () => setTitleId(undefined);
119+
}
120+
}, [title, generatedTitleId, setTitleId]);
98121

99122
function handleCloseButtonClick(evt: MouseEvent) {
100123
closeModal?.(evt);
@@ -122,9 +145,17 @@ export const ModalHeader = React.forwardRef<HTMLDivElement, ModalHeaderProps>(
122145

123146
return (
124147
<div className={headerClass} {...rest} ref={ref}>
125-
{label && <h2 className={labelClass}>{label}</h2>}
126-
127-
{title && <h2 className={titleClass}>{title}</h2>}
148+
{label && (
149+
<h2 id={generatedLabelId} className={labelClass}>
150+
{label}
151+
</h2>
152+
)}
153+
154+
{title && (
155+
<h2 id={generatedTitleId} className={titleClass}>
156+
{title}
157+
</h2>
158+
)}
128159

129160
{children}
130161

0 commit comments

Comments
 (0)