Skip to content

Commit eb3f38b

Browse files
[FIX] Keyboard navigation for modals (#2127)
Co-authored-by: Manish Sharma <[email protected]>
1 parent 1e68f43 commit eb3f38b

File tree

5 files changed

+87
-20
lines changed

5 files changed

+87
-20
lines changed
Loading

client/app/components/Header/HeaderProfile.jsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export type Props = {
1515
};
1616

1717
const notificationsElement = (notifications) => (
18-
<button type="button" className="buttonGhostXS" aria-label={notifications}>
18+
<button type="button" className="buttonGhostXS" aria-label={notifications} tabIndex={-1}>
1919
<FontAwesomeIcon icon={faBell} />
2020
</button>
2121
);

client/app/components/Modal/__tests__/Modal.spec.jsx

+36-9
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,12 @@ describe('Modal', () => {
8484
expect(screen.queryByRole('dialog')).toBeNull();
8585

8686
userEvent.click(screen.getByRole('button', { name: 'Hello' }));
87-
expect(window.alert).toHaveBeenCalled();
87+
expect(window.alert).toHaveBeenCalledWith("Hey look it's listening");
8888
expect(container.querySelector('.modalBackdrop')).toBeInTheDocument();
8989
expect(screen.getByRole('dialog')).toBeInTheDocument();
9090

9191
userEvent.click(screen.getByRole('button', { name: 'close' }));
92-
expect(window.alert).toHaveBeenCalled();
92+
expect(window.alert).toHaveBeenCalledWith("Hey look it's listening");
9393
expect(
9494
container.querySelector('.modalBackdrop'),
9595
).not.toBeInTheDocument();
@@ -141,12 +141,12 @@ describe('Modal', () => {
141141
expect(screen.queryByRole('dialog')).toBeNull();
142142

143143
userEvent.click(screen.getAllByRole('button', { name: 'Hello' })[1]);
144-
expect(window.alert).toHaveBeenCalled();
144+
expect(window.alert).toHaveBeenCalledWith("Hey look it's listening");
145145
expect(container.querySelector('.modalBackdrop')).toBeInTheDocument();
146146
expect(screen.getByRole('dialog')).toBeInTheDocument();
147147

148148
userEvent.click(screen.getByRole('button', { name: 'close' }));
149-
expect(window.alert).toHaveBeenCalled();
149+
expect(window.alert).toHaveBeenCalledWith("Hey look it's listening");
150150
expect(
151151
container.querySelector('.modalBackdrop'),
152152
).not.toBeInTheDocument();
@@ -172,7 +172,7 @@ describe('Modal', () => {
172172
expect(screen.queryByRole('dialog')).toBeNull();
173173

174174
userEvent.click(screen.getByRole('button', { name: 'Hello' }));
175-
expect(window.alert).toHaveBeenCalled();
175+
expect(window.alert).toHaveBeenCalledWith("Hey look it's listening");
176176
expect(container.querySelector('.modalBackdrop')).toBeInTheDocument();
177177
expect(screen.getByRole('dialog')).toBeInTheDocument();
178178

@@ -205,7 +205,7 @@ describe('Modal', () => {
205205
expect(screen.queryByRole('dialog')).toBeNull();
206206

207207
userEvent.click(screen.getByRole('button', { name: 'Hello' }));
208-
expect(window.alert).toHaveBeenCalled();
208+
expect(window.alert).toHaveBeenCalledWith("Hey look it's listening");
209209
expect(container.querySelector('.modalBackdrop')).toBeInTheDocument();
210210
expect(screen.getByRole('dialog')).toBeInTheDocument();
211211

@@ -239,7 +239,7 @@ describe('Modal', () => {
239239
expect(screen.queryByRole('dialog')).toBeNull();
240240

241241
userEvent.click(screen.getByRole('button', { name: 'Hello' }));
242-
expect(window.alert).toHaveBeenCalled();
242+
expect(window.alert).toHaveBeenCalledWith("Hey look it's listening");
243243
expect(container.querySelector('.modalBackdrop')).toBeInTheDocument();
244244
expect(screen.getByRole('dialog')).toBeInTheDocument();
245245

@@ -294,7 +294,7 @@ describe('Modal', () => {
294294
expect(screen.queryByRole('dialog')).toBeNull();
295295

296296
userEvent.click(screen.getByRole('button', { name: 'Hello' }));
297-
expect(window.alert).toHaveBeenCalled();
297+
expect(window.alert).toHaveBeenCalledWith("Hey look it's listening");
298298
expect(container.querySelector('.modalBackdrop')).toBeInTheDocument();
299299
expect(screen.getByRole('dialog')).toBeInTheDocument();
300300
});
@@ -348,7 +348,7 @@ describe('Modal', () => {
348348
expect(screen.queryByRole('dialog')).toBeNull();
349349

350350
userEvent.click(screen.getAllByRole('button', { name: 'Hello' })[1]);
351-
expect(window.alert).toHaveBeenCalled();
351+
expect(window.alert).toHaveBeenCalledWith("Hey look it's listening");
352352
expect(container.querySelector('.modalBackdrop')).toBeInTheDocument();
353353
expect(screen.getByRole('dialog')).toBeInTheDocument();
354354
});
@@ -454,5 +454,32 @@ describe('Modal', () => {
454454
expect(screen.getByRole('dialog')).toBeInTheDocument();
455455
});
456456
});
457+
458+
describe('opens when enter key is pressed', () => {
459+
const component = (
460+
<Modal
461+
element="Hello"
462+
body={bodyText}
463+
title={title}
464+
openListener={openListener}
465+
onKeyPress={handleKeyPress}
466+
/>
467+
);
468+
it('toggles correctly', () => {
469+
const { container } = render(component);
470+
expect(
471+
container.querySelector('.modalBackdrop'),
472+
).not.toBeInTheDocument();
473+
expect(screen.queryByRole('dialog')).toBeNull();
474+
475+
fireEvent.keyDown(container.querySelector('.modalElement'), {
476+
key: 'Enter',
477+
});
478+
expect(
479+
container.querySelector('.modalBackdrop'),
480+
).toBeInTheDocument();
481+
expect(screen.getByRole('dialog')).toBeInTheDocument();
482+
});
483+
});
457484
});
458485
});

client/app/components/Modal/index.jsx

+41-10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// @flow
2-
import React, { useState, type Element, type Node } from 'react';
2+
import React, {
3+
useState, useEffect, useRef, type Element, type Node,
4+
} from 'react';
35
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
46
import { faTimes } from '@fortawesome/free-solid-svg-icons';
57
import { I18n } from 'libs/i18n';
@@ -44,6 +46,7 @@ export const Modal = (props: Props): Node => {
4446

4547
const [open, setOpen] = useState(!!openProps);
4648
const [modalHasFocus, setModalHasFocus] = useState(true);
49+
const modalEl = useRef(null);
4750

4851
const toggleOpen = () => {
4952
const documentBody = ((document.body: any): HTMLBodyElement);
@@ -58,6 +61,38 @@ export const Modal = (props: Props): Node => {
5861
setOpen(!open);
5962
};
6063

64+
useEffect(() => {
65+
const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
66+
const modal = modalEl.current;
67+
68+
if (!modal) return;
69+
70+
const firstFocusableElement = modal.querySelectorAll(focusableElements)[0];
71+
const focusableContent = modal.querySelectorAll(focusableElements);
72+
const lastFocusableElement = focusableContent[focusableContent.length - 1];
73+
74+
document.addEventListener('keydown', (e: any) => {
75+
const isTabPressed = e.key === 'Tab' || e.keyCode === 9;
76+
77+
if (!isTabPressed) {
78+
return;
79+
}
80+
81+
if (e.shiftKey && document.activeElement === firstFocusableElement) {
82+
lastFocusableElement.focus();
83+
e.preventDefault();
84+
} else if (document.activeElement === lastFocusableElement) {
85+
firstFocusableElement.focus();
86+
e.preventDefault();
87+
}
88+
});
89+
}, [open]);
90+
91+
const handleKeyPress = (event: SyntheticKeyboardEvent<HTMLDivElement>, keyName: string) => {
92+
if (event.key !== keyName) return;
93+
toggleOpen();
94+
};
95+
6196
const displayModalHeader = () => (
6297
<div className={css.modalBoxHeader}>
6398
{title && (
@@ -72,7 +107,7 @@ export const Modal = (props: Props): Node => {
72107
<div
73108
className={`modalClose ${css.modalBoxHeaderClose}`}
74109
onClick={toggleOpen}
75-
onKeyDown={toggleOpen}
110+
onKeyDown={(event) => handleKeyPress(event, 'Enter')}
76111
role="button"
77112
tabIndex={0}
78113
aria-label={I18n.t('close')}
@@ -88,11 +123,6 @@ export const Modal = (props: Props): Node => {
88123
</div>
89124
);
90125

91-
const handleKeyPress = (event: SyntheticKeyboardEvent<HTMLDivElement>) => {
92-
if (event.key !== 'Escape') return;
93-
toggleOpen();
94-
};
95-
96126
const handleClick = () => {
97127
if (modalHasFocus) return;
98128
toggleOpen();
@@ -102,8 +132,8 @@ export const Modal = (props: Props): Node => {
102132
<div
103133
className={`modalBackdrop ${css.modalBackdrop}`}
104134
onClick={handleClick}
105-
onKeyDown={handleKeyPress}
106-
tabIndex="0"
135+
onKeyDown={(event) => handleKeyPress(event, 'Escape')}
136+
tabIndex={-1}
107137
role="button"
108138
>
109139
<div
@@ -115,6 +145,7 @@ export const Modal = (props: Props): Node => {
115145
onMouseLeave={() => setModalHasFocus(false)}
116146
onFocus={() => setModalHasFocus(true)}
117147
onBlur={() => setModalHasFocus(false)}
148+
ref={modalEl}
118149
>
119150
{displayModalHeader()}
120151
{displayModalBody()}
@@ -145,7 +176,7 @@ export const Modal = (props: Props): Node => {
145176
id={elementId}
146177
className={`modalElement ${css.modalElement} ${className || ''}`}
147178
onClick={toggleOpen}
148-
onKeyDown={toggleOpen}
179+
onKeyDown={(event) => handleKeyPress(event, 'Enter')}
149180
role="button"
150181
tabIndex={0}
151182
>

doc/pages/blurbs.json

+9
Original file line numberDiff line numberDiff line change
@@ -673,5 +673,14 @@
673673
"link_name": "rizkaluthfiani",
674674
"link": "https://github.com/rizkaluthfiani",
675675
"social": "github"
676+
},
677+
{
678+
"name": "Manish Sharma",
679+
"image": "/assets/contributors/manish_sharma.jpeg",
680+
"profile": "I'm a self-taught full-stack developer who enjoys working with Ruby on Rails and React. I want to leverage my skills to help people live better lives by addressing their mental health.",
681+
"location": "Bangalore, India",
682+
"link_name": "manishsharma.ml",
683+
"link": "https://manishsharma.ml",
684+
"social": "globe"
676685
}
677686
]

0 commit comments

Comments
 (0)