Skip to content

Commit 99f300a

Browse files
committed
feat: enhance accessibility in modal components on call view
1 parent 9dee47f commit 99f300a

File tree

9 files changed

+186
-47
lines changed

9 files changed

+186
-47
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: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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,21 +209,38 @@ export const PrimaryModalComponent: FC = () => {
207209

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

212+
// Auto-focus close button when modal opens
210213
useEffect(() => {
211-
const onKeyPress = (event: KeyboardEvent) => {
214+
if (!isModalVisible) {
215+
return undefined;
216+
}
217+
218+
// Use setTimeout to ensure the modal is fully rendered before focusing
219+
const timeoutId = setTimeout(() => {
220+
if (closeButtonRef.current) {
221+
closeButtonRef.current.focus();
222+
}
223+
}, 0);
224+
225+
return () => clearTimeout(timeoutId);
226+
}, [isModalVisible]);
227+
228+
useEffect(() => {
229+
const onKeyDown = (event: KeyboardEvent) => {
212230
if (isEscapeKey(event) && isModalVisible) {
213231
removeCurrentModal();
214232
closeAction();
215233
}
216234

217235
if (isEnterKey(event) && primaryAction?.runActionOnEnterClick) {
236+
event.preventDefault();
218237
primaryAction?.action?.();
219238
removeCurrentModal();
220239
}
221240
};
222241

223-
document.addEventListener('keypress', onKeyPress);
224-
return () => document.removeEventListener('keypress', onKeyPress);
242+
document.addEventListener('keydown', onKeyDown);
243+
return () => document.removeEventListener('keydown', onKeyDown);
225244
}, [primaryAction, isModalVisible]);
226245

227246
const closeAction = () => {
@@ -272,8 +291,10 @@ export const PrimaryModalComponent: FC = () => {
272291
onBgClick={onBgClick}
273292
dataUieName={modalUie}
274293
size={size}
294+
container={container}
275295
>
276296
<PrimaryModalHeader
297+
ref={closeButtonRef}
277298
titleText={titleText}
278299
closeBtnTitle={closeBtnTitle}
279300
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: 40 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,48 @@ 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+
const detachedWindowRoot = (container as HTMLElement).querySelector?.('#detached-window') as HTMLElement;
60+
61+
if (!detachedWindowRoot) {
62+
return undefined;
63+
}
64+
65+
const applyInertAttributes = (element: HTMLElement) => {
66+
element.setAttribute('aria-hidden', 'true');
67+
(element as any).inert = '';
68+
if (Runtime.isDesktopApp()) {
69+
element.setAttribute('tabIndex', '-1');
70+
element.style.pointerEvents = 'none';
71+
}
72+
};
73+
74+
const removeInertAttributes = (element: HTMLElement) => {
75+
element.removeAttribute('aria-hidden');
76+
delete (element as any).inert;
77+
if (Runtime.isDesktopApp()) {
78+
element.removeAttribute('tabIndex');
79+
element.style.pointerEvents = '';
80+
}
81+
};
82+
5083
if (isShown) {
51-
modalsRef.current?.focus();
84+
applyInertAttributes(detachedWindowRoot);
5285
}
53-
}, [isShown]);
86+
87+
return () => {
88+
removeInertAttributes(detachedWindowRoot);
89+
};
90+
}, [isShown, container]);
5491

5592
return (
5693
<div
@@ -68,6 +105,7 @@ export const PrimaryModalShell = ({
68105
onBgClick={onBgClick}
69106
data-uie-name={dataUieName}
70107
wrapperCSS={size === 'large' ? largeModalStyles : undefined}
108+
container={container}
71109
>
72110
{isShown && children}
73111
</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)