Skip to content

Commit be148ef

Browse files
authored
feat: enhance accessibility in modal components on call view [WPB-21193] (#19955)
* feat: enhance accessibility in modal components on call view * fix: add safety checks for container and detached window element * fix: review compliance, move captureModalFocusContext into utils file.
1 parent 5a6a60b commit be148ef

File tree

10 files changed

+299
-57
lines changed

10 files changed

+299
-57
lines changed

src/script/components/Modals/ModalComponent/ModalComponent.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
*
1818
*/
1919

20-
import React, {useEffect, useId, useRef, useState, useCallback, HTMLProps} from 'react';
20+
import React, {useEffect, useId, useRef, useState, HTMLProps} from 'react';
2121

2222
import {CSSObject} from '@emotion/react';
2323
import {createPortal} from 'react-dom';
@@ -67,18 +67,21 @@ const ModalComponent = ({
6767
const isMounting = useRef<boolean>(true);
6868
const trapId = useId();
6969

70-
const trapFocus = useCallback((event: KeyboardEvent) => preventFocusOutside(event, trapId), [trapId]);
71-
7270
useEffect(() => {
71+
// Get the correct document based on the container
72+
const targetDocument = container ? (container as HTMLElement).ownerDocument || document : document;
73+
74+
const trapFocus = (event: KeyboardEvent) => preventFocusOutside(event, trapId, targetDocument);
75+
7376
if (isShown) {
74-
document.addEventListener('keydown', trapFocus);
77+
targetDocument.addEventListener('keydown', trapFocus);
7578
} else {
76-
document.removeEventListener('keydown', trapFocus);
79+
targetDocument.removeEventListener('keydown', trapFocus);
7780
}
7881
return () => {
79-
document.removeEventListener('keydown', trapFocus);
82+
targetDocument.removeEventListener('keydown', trapFocus);
8083
};
81-
}, [isShown, onkeydown]);
84+
}, [isShown, onkeydown, trapId, container]);
8285

8386
useEffect(() => {
8487
let timeoutId = 0;

src/script/components/Modals/PrimaryModal/PrimaryModal.tsx

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
*
1818
*/
1919

20-
import {FC, FormEvent, MouseEvent, useState, useRef, ChangeEvent, useEffect, useMemo} from 'react';
20+
import {FC, FormEvent, MouseEvent, useState, useRef, ChangeEvent, useEffect, useMemo, useCallback} from 'react';
2121

2222
import {ValidationUtil} from '@wireapp/commons';
2323
import {ErrorMessage} from '@wireapp/react-ui-kit';
@@ -58,6 +58,7 @@ export const PrimaryModalComponent: FC = () => {
5858
const updateCurrentModalContent = usePrimaryModalState(state => state.updateCurrentModalContent);
5959
const currentId = usePrimaryModalState(state => state.currentModalId);
6060
const primaryActionButtonRef = useRef<HTMLButtonElement>(null);
61+
const closeButtonRef = useRef<HTMLButtonElement>(null);
6162
const isModalVisible = currentId !== null;
6263
const passwordValueRef = useRef<HTMLInputElement>(null);
6364
const [isFormSubmitted, setIsFormSubmitted] = useState(false);
@@ -83,6 +84,7 @@ export const PrimaryModalComponent: FC = () => {
8384
allButtonsFullWidth = false,
8485
primaryBtnFirst = false,
8586
size = 'small',
87+
container,
8688
} = content;
8789

8890
const isPassword = currentType === PrimaryModalType.PASSWORD;
@@ -207,29 +209,59 @@ export const PrimaryModalComponent: FC = () => {
207209

208210
const secondaryActions = Array.isArray(secondaryAction) ? secondaryAction : [secondaryAction];
209211

212+
const closeAction = useCallback(() => {
213+
if (hasPasswordWithRules) {
214+
const [closeActionItem] = secondaryActions;
215+
closeActionItem?.action?.();
216+
}
217+
}, [hasPasswordWithRules, secondaryActions]);
218+
219+
// Auto-focus close button when modal opens
210220
useEffect(() => {
211-
const onKeyPress = (event: KeyboardEvent) => {
212-
if (isEscapeKey(event) && isModalVisible) {
221+
if (!isModalVisible) {
222+
return undefined;
223+
}
224+
225+
// Use setTimeout to ensure the modal is fully rendered before focusing
226+
const timeoutId = setTimeout(() => {
227+
// Focus primary button if it should come first, otherwise focus close button
228+
const targetElement = primaryBtnFirst ? primaryActionButtonRef.current : closeButtonRef.current;
229+
const fallbackElement = primaryBtnFirst ? closeButtonRef.current : primaryActionButtonRef.current;
230+
231+
if (targetElement) {
232+
targetElement.focus();
233+
} else if (fallbackElement) {
234+
fallbackElement.focus();
235+
}
236+
}, 0);
237+
238+
return () => clearTimeout(timeoutId);
239+
}, [isModalVisible, primaryBtnFirst]);
240+
241+
const onKeyDown = useCallback(
242+
(event: KeyboardEvent) => {
243+
if (isEscapeKey(event)) {
213244
removeCurrentModal();
214245
closeAction();
215246
}
216247

217248
if (isEnterKey(event) && primaryAction?.runActionOnEnterClick) {
249+
event.preventDefault();
218250
primaryAction?.action?.();
219251
removeCurrentModal();
220252
}
221-
};
222-
223-
document.addEventListener('keypress', onKeyPress);
224-
return () => document.removeEventListener('keypress', onKeyPress);
225-
}, [primaryAction, isModalVisible]);
253+
},
254+
[closeAction, primaryAction],
255+
);
226256

227-
const closeAction = () => {
228-
if (hasPasswordWithRules) {
229-
const [closeAction] = secondaryActions;
230-
closeAction?.action?.();
257+
useEffect(() => {
258+
if (!isModalVisible) {
259+
return undefined;
231260
}
232-
};
261+
262+
document.addEventListener('keydown', onKeyDown);
263+
return () => document.removeEventListener('keydown', onKeyDown);
264+
}, [isModalVisible, primaryAction, closeAction, onKeyDown]);
233265

234266
const secondaryButtons = secondaryActions
235267
.filter((action): action is ButtonAction => action !== null && !!action.text)
@@ -272,8 +304,10 @@ export const PrimaryModalComponent: FC = () => {
272304
onBgClick={onBgClick}
273305
dataUieName={modalUie}
274306
size={size}
307+
container={container}
275308
>
276309
<PrimaryModalHeader
310+
ref={closeButtonRef}
277311
titleText={titleText}
278312
closeBtnTitle={closeBtnTitle}
279313
hideCloseBtn={hideCloseBtn}

src/script/components/Modals/PrimaryModal/PrimaryModalHeader/PrimaryModalHeader.tsx

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
*
1818
*/
1919

20+
import {forwardRef} from 'react';
21+
2022
import * as Icon from 'Components/Icon';
2123

2224
import {removeCurrentModal} from '../PrimaryModalState';
@@ -28,26 +30,31 @@ interface ModalHeaderProps {
2830
closeAction: () => void;
2931
}
3032

31-
export const PrimaryModalHeader = ({titleText, closeAction, closeBtnTitle, hideCloseBtn}: ModalHeaderProps) => {
32-
return (
33-
<div className="modal__header" data-uie-name="status-modal-title">
34-
<h2 className="modal__header__title" id="modal-title">
35-
{titleText}
36-
</h2>
37-
{!hideCloseBtn && (
38-
<button
39-
type="button"
40-
className="modal__header__button"
41-
onClick={() => {
42-
removeCurrentModal();
43-
closeAction();
44-
}}
45-
aria-label={closeBtnTitle}
46-
data-uie-name="do-close"
47-
>
48-
<Icon.CloseIcon className="modal__header__icon" aria-hidden="true" />
49-
</button>
50-
)}
51-
</div>
52-
);
53-
};
33+
export const PrimaryModalHeader = forwardRef<HTMLButtonElement, ModalHeaderProps>(
34+
({titleText, closeAction, closeBtnTitle, hideCloseBtn}, ref) => {
35+
return (
36+
<div className="modal__header" data-uie-name="status-modal-title">
37+
<h2 className="modal__header__title" id="modal-title">
38+
{titleText}
39+
</h2>
40+
{!hideCloseBtn && (
41+
<button
42+
ref={ref}
43+
type="button"
44+
className="modal__header__button"
45+
onClick={() => {
46+
removeCurrentModal();
47+
closeAction();
48+
}}
49+
aria-label={closeBtnTitle}
50+
data-uie-name="do-close"
51+
>
52+
<Icon.CloseIcon className="modal__header__icon" aria-hidden="true" />
53+
</button>
54+
)}
55+
</div>
56+
);
57+
},
58+
);
59+
60+
PrimaryModalHeader.displayName = 'PrimaryModalHeader';

src/script/components/Modals/PrimaryModal/PrimaryModalShell/PrimaryModalShell.tsx

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919

2020
import {ReactNode, useEffect, useRef} from 'react';
2121

22+
import {Runtime} from '@wireapp/commons';
23+
2224
import {ModalComponent} from 'Components/Modals/ModalComponent';
2325

2426
import {largeModalStyles} from './PrimaryModalShell.styles';
@@ -33,6 +35,7 @@ interface PrimaryModalShellProps {
3335
onClose: () => void;
3436
onBgClick: () => void;
3537
size?: ModalSize;
38+
container?: Element | DocumentFragment;
3639
}
3740

3841
export const PrimaryModalShell = ({
@@ -43,14 +46,53 @@ export const PrimaryModalShell = ({
4346
onClose,
4447
onBgClick,
4548
size,
49+
container,
4650
}: PrimaryModalShellProps) => {
4751
const modalsRef = useRef<HTMLDivElement | null>(null);
4852

53+
// Make detached window background inert when modal is shown
4954
useEffect(() => {
55+
if (!container) {
56+
return undefined;
57+
}
58+
59+
// Safety check
60+
const element = container instanceof HTMLElement ? container : null;
61+
if (!element?.querySelector) {
62+
return undefined;
63+
}
64+
65+
const detachedWindowRoot = element.querySelector('#detached-window');
66+
if (!detachedWindowRoot || !(detachedWindowRoot instanceof HTMLElement)) {
67+
return undefined;
68+
}
69+
70+
const applyInertAttributes = (element: HTMLElement) => {
71+
element.setAttribute('aria-hidden', 'true');
72+
(element as any).inert = '';
73+
if (Runtime.isDesktopApp()) {
74+
element.setAttribute('tabIndex', '-1');
75+
element.style.pointerEvents = 'none';
76+
}
77+
};
78+
79+
const removeInertAttributes = (element: HTMLElement) => {
80+
element.removeAttribute('aria-hidden');
81+
delete (element as any).inert;
82+
if (Runtime.isDesktopApp()) {
83+
element.removeAttribute('tabIndex');
84+
element.style.pointerEvents = '';
85+
}
86+
};
87+
5088
if (isShown) {
51-
modalsRef.current?.focus();
89+
applyInertAttributes(detachedWindowRoot);
5290
}
53-
}, [isShown]);
91+
92+
return () => {
93+
removeInertAttributes(detachedWindowRoot);
94+
};
95+
}, [isShown, container]);
5496

5597
return (
5698
<div
@@ -68,6 +110,7 @@ export const PrimaryModalShell = ({
68110
onBgClick={onBgClick}
69111
data-uie-name={dataUieName}
70112
wrapperCSS={size === 'large' ? largeModalStyles : undefined}
113+
container={container}
71114
>
72115
{isShown && children}
73116
</ModalComponent>

src/script/components/Modals/PrimaryModal/PrimaryModalState.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ const updateCurrentModalContent = (type: PrimaryModalType, options: ModalOptions
147147
primaryBtnFirst = false,
148148
closeOnSecondaryAction = true,
149149
size = 'small',
150+
container,
150151
} = options;
151152

152153
const content = {
@@ -171,6 +172,7 @@ const updateCurrentModalContent = (type: PrimaryModalType, options: ModalOptions
171172
primaryBtnFirst,
172173
closeOnSecondaryAction,
173174
size,
175+
container,
174176
};
175177

176178
switch (type) {

src/script/components/Modals/PrimaryModal/PrimaryModalTypes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export interface ModalOptions {
6262
allButtonsFullWidth?: boolean;
6363
primaryBtnFirst?: boolean;
6464
size?: ModalSize;
65+
/** DOM element where modal should be rendered for detached windows */
66+
container?: Element | DocumentFragment;
6567
}
6668

6769
export enum PrimaryModalType {
@@ -103,6 +105,7 @@ export interface ModalContent {
103105
allButtonsFullWidth?: boolean;
104106
primaryBtnFirst?: boolean;
105107
size?: ModalSize;
108+
container?: Element | DocumentFragment;
106109
}
107110

108111
export type ModalItem = {id: string; options: ModalOptions; type: PrimaryModalType};

src/script/components/calling/FullscreenVideoCall.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -222,28 +222,35 @@ const FullscreenVideoCall = ({
222222
}
223223

224224
useEffect(() => {
225+
const isFullScreen = viewMode === CallingViewMode.FULL_SCREEN || viewMode === CallingViewMode.DETACHED_WINDOW;
226+
227+
if (!isFullScreen) {
228+
return undefined;
229+
}
230+
231+
const targetDocument =
232+
viewMode === CallingViewMode.DETACHED_WINDOW && detachedWindow ? detachedWindow.document : document;
233+
225234
const onKeyDown = (event: KeyboardEvent): void => {
226235
const target = event.target as HTMLElement;
227236

228-
if (
229-
viewMode !== CallingViewMode.FULL_SCREEN ||
230-
target?.getAttribute('aria-controls') === 'epr-search-id' // Exclude emoji search input
231-
) {
237+
if (target?.getAttribute('aria-controls') === 'epr-search-id') {
238+
// Exclude emoji search input
232239
return;
233240
}
234241

235242
event.preventDefault();
236243
event.stopPropagation();
237244

238-
preventFocusOutside(event, 'video-calling');
245+
preventFocusOutside(event, 'video-calling', targetDocument);
239246
};
240247

241-
document.addEventListener('keydown', onKeyDown);
248+
targetDocument.addEventListener('keydown', onKeyDown);
242249

243250
return () => {
244-
document.removeEventListener('keydown', onKeyDown);
251+
targetDocument.removeEventListener('keydown', onKeyDown);
245252
};
246-
}, [viewMode]);
253+
}, [viewMode, detachedWindow]);
247254

248255
const {showAlert, isGroupCall, clearShowAlert} = useCallAlertState();
249256

0 commit comments

Comments
 (0)