Skip to content

Commit c5d8481

Browse files
kamalgill-oktaandrewberg-okta
authored andcommitted
fix(odyssey-react): improve keyboard focus behavior for Modal component
1 parent 0f9a3e3 commit c5d8481

File tree

7 files changed

+3979
-5738
lines changed

7 files changed

+3979
-5738
lines changed

packages/odyssey-react/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
},
2121
"dependencies": {
2222
"@okta/odyssey-react-theme": "^0.11.0",
23+
"@react-aria/focus": "3.5.0",
2324
"choices.js": "^9.0.1"
2425
},
2526
"devDependencies": {

packages/odyssey-react/src/components/Modal/Modal.test.tsx

+47-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import React from "react";
14-
import { render, fireEvent, screen } from "@testing-library/react";
14+
import { render, fireEvent, screen, waitFor } from "@testing-library/react";
1515
import { Modal } from ".";
1616

1717
const role = "dialog";
@@ -130,6 +130,52 @@ describe("Modal", () => {
130130
expect(handleClose).toHaveBeenCalledTimes(1);
131131
});
132132

133+
it("should initially focus on modal's dismiss icon when open", () => {
134+
const handleClose = jest.fn();
135+
render(
136+
<div>
137+
<button data-testid="modal-trigger">Open Modal</button>
138+
<Modal open={false} onClose={handleClose} closeMessage={message}>
139+
<Modal.Header>{modalHeading}</Modal.Header>
140+
</Modal>
141+
</div>
142+
);
143+
const triggerBtn = screen.getByTestId("modal-trigger");
144+
triggerBtn && triggerBtn.click();
145+
const dismissIcon = screen.getByTitle(message).closest("button");
146+
waitFor(
147+
() => {
148+
expect(document.activeElement).toBe(dismissIcon);
149+
},
150+
{ timeout: 200 }
151+
);
152+
});
153+
154+
it("should restore focus to original focused element when modal is closed", () => {
155+
const handleClose = jest.fn();
156+
render(
157+
<div>
158+
<button data-testid="modal-trigger">Open Modal</button>
159+
<Modal open={false} onClose={handleClose} closeMessage={message}>
160+
<Modal.Header>{modalHeading}</Modal.Header>
161+
</Modal>
162+
</div>
163+
);
164+
const triggerBtn = screen.getByTestId("modal-trigger");
165+
triggerBtn && triggerBtn.focus();
166+
triggerBtn.click();
167+
expect(document.activeElement).toBe(triggerBtn);
168+
const dismissIcon = screen.getByTitle(message).closest("button");
169+
waitFor(
170+
() => {
171+
expect(document.activeElement).toBe(dismissIcon);
172+
dismissIcon?.click();
173+
expect(document.activeElement).toBe(triggerBtn);
174+
},
175+
{ timeout: 200 }
176+
);
177+
});
178+
133179
a11yCheck(() =>
134180
render(
135181
<Modal

packages/odyssey-react/src/components/Modal/Modal.tsx

+31-13
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,26 @@ import type {
1818
ReactText,
1919
} from "react";
2020
import { createPortal } from "react-dom";
21+
import { FocusScope } from "@react-aria/focus";
2122
import { withTheme } from "@okta/odyssey-react-theme";
23+
2224
import { Box } from "../Box";
2325
import { Button as CoreButton } from "../Button";
2426
import type { ButtonProps as CoreButtonProps } from "../Button";
2527
import { Heading } from "../Heading";
26-
import { forwardRefWithStatics, useOid, useCx, useOmit } from "../../utils";
28+
import {
29+
forwardRefWithStatics,
30+
useCx,
31+
useFocus,
32+
useOid,
33+
useOmit,
34+
} from "../../utils";
2735
import { CloseIcon } from "../Icon";
2836
import { theme } from "./Modal.theme";
2937
import styles from "./Modal.module.scss";
3038

39+
type OptionalHTMLElement = HTMLElement | null;
40+
3141
export type ModalProps = {
3242
/**
3343
* The modal content, should use the Static components provided by Modal (Modal.Header, Modal.Body and Modal.Footer)
@@ -81,6 +91,7 @@ interface ModalContext {
8191
modalHeadingId: string;
8292
closeMessage: string;
8393
}
94+
8495
const ModalContext = createContext({} as ModalContext);
8596

8697
/**
@@ -111,9 +122,14 @@ export const Modal = withTheme(
111122
const oid = useOid(id);
112123
const modalDialog = useRef<HTMLDivElement>(null);
113124
const componentClass = useCx(styles.root, { [styles.openState]: open });
114-
115-
if (open && onOpen) {
116-
onOpen();
125+
const { restoreFocus, setFocus } = useFocus();
126+
const lastFocusedElemRef = useRef<OptionalHTMLElement>(null);
127+
128+
if (open) {
129+
lastFocusedElemRef.current = setFocus(modalDialog.current);
130+
onOpen && onOpen();
131+
} else {
132+
lastFocusedElemRef.current && restoreFocus(lastFocusedElemRef.current);
117133
}
118134

119135
return createPortal(
@@ -126,15 +142,17 @@ export const Modal = withTheme(
126142
hidden={!open}
127143
>
128144
<div className={styles.overlay} tabIndex={-1}>
129-
<div
130-
className={styles.dialog}
131-
role="dialog"
132-
aria-modal="true"
133-
aria-labelledby={modalHeadingId}
134-
ref={modalDialog}
135-
>
136-
{children}
137-
</div>
145+
<FocusScope contain>
146+
<div
147+
className={styles.dialog}
148+
role="dialog"
149+
aria-modal="true"
150+
aria-labelledby={modalHeadingId}
151+
ref={modalDialog}
152+
>
153+
{children}
154+
</div>
155+
</FocusScope>
138156
</div>
139157
</Box>
140158
</ModalContext.Provider>,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*!
2+
* Copyright (c) 2021-present, Okta, Inc. and/or its affiliates. All rights reserved.
3+
* The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
4+
*
5+
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
6+
* Unless required by applicable law or agreed to in writing, software
7+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
8+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
*
10+
* See the License for the specific language governing permissions and limitations under the License.
11+
*/
12+
13+
type OptionalHTMLElement = HTMLElement | null;
14+
15+
interface UseFocusHook {
16+
restoreFocus: (current: OptionalHTMLElement) => void;
17+
setFocus: (elem: OptionalHTMLElement) => OptionalHTMLElement;
18+
}
19+
20+
const FOCUSABLE_ITEMS = [
21+
"button",
22+
"[href]",
23+
"input",
24+
"select",
25+
"textarea",
26+
'[tabindex]:not([tabindex="-1"])',
27+
];
28+
29+
const FOCUSABLE_ITEMS_SELECTOR = FOCUSABLE_ITEMS.join(",");
30+
31+
/**
32+
* Set focus on first focusable element inside node tree
33+
* @param {HTMLElement} elem - parent element that contains focusable child elements
34+
* @returns {void}
35+
*/
36+
function setFocus(elem: OptionalHTMLElement): OptionalHTMLElement {
37+
if (!elem) {
38+
return null;
39+
}
40+
const focusableItems: NodeListOf<HTMLElement> = elem.querySelectorAll(
41+
FOCUSABLE_ITEMS_SELECTOR
42+
);
43+
// Capture original focused element before setting focus inside modal dialog
44+
const lastFocusedElement = document.activeElement;
45+
if (focusableItems.length > 0) {
46+
requestAnimationFrame(() => {
47+
// Focus on first focusable element inside dialog
48+
focusableItems[0].focus();
49+
});
50+
}
51+
return lastFocusedElement as OptionalHTMLElement;
52+
}
53+
54+
/**
55+
* Restore focus to element with original focus prior to opening modal dialog
56+
* @param {OptionalHTMLElement} elem
57+
*/
58+
function restoreFocus(elem: OptionalHTMLElement): void {
59+
if (elem && document.contains(elem)) {
60+
requestAnimationFrame(() => {
61+
elem.focus();
62+
});
63+
}
64+
}
65+
66+
/**
67+
* Custom React Hook to provide set/restore focus helper methods
68+
* @returns {UseFocusHook}
69+
*/
70+
export function useFocus(): UseFocusHook {
71+
return {
72+
restoreFocus,
73+
setFocus,
74+
};
75+
}

packages/odyssey-react/src/utils/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ export { forwardRefWithStatics } from "./forwardRefWithStatics";
1515
export { oid, useOid } from "./oid";
1616
export { omit, useOmit } from "./omit";
1717
export { toCamelCase, toPascalCase } from "./convertCase";
18+
export { useFocus } from "./focusHandling";
1819
export type { PolymorphicForwardRef } from "./polymorphic";

packages/odyssey-storybook/src/components/Modal/Modal.stories.tsx

+11
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ const Template: Story<ModalProps> = () => {
7575
);
7676
};
7777

78+
// Default state (modal is initially open)
7879
export const Default = Template.bind({});
7980
Default.args = {
8081
open: true,
@@ -83,3 +84,13 @@ Default.argTypes = {
8384
onOpen: { action: "modal/onOpen" },
8485
onClose: { action: "modal/onClose" },
8586
};
87+
88+
// Unopened state (modal is initially closed)
89+
export const Unopened = Template.bind({});
90+
Unopened.args = {
91+
open: false,
92+
};
93+
Unopened.argTypes = {
94+
onOpen: { action: "modal/onOpen" },
95+
onClose: { action: "modal/onClose" },
96+
};

0 commit comments

Comments
 (0)