Skip to content

Commit 35fc72f

Browse files
authored
feat(Modal): add launcherButtonRef prop to handle focus on close (#14355)
* feat(Modal): add launcherButtonRef prop to handle focus on close * test(Modal): update AVT tests
1 parent 37e2732 commit 35fc72f

6 files changed

Lines changed: 103 additions & 15 deletions

File tree

e2e/components/Modal/Modal-test.avt.e2e.js

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,12 @@ test.describe('Modal @avt', () => {
3131
},
3232
});
3333

34+
const button = page.getByRole('button', { name: 'Launch modal' });
35+
3436
// Open the modal via keyboard navigation
3537
await page.keyboard.press('Tab');
36-
await expect(
37-
page.getByRole('button', { name: 'Launch modal' })
38-
).toBeFocused();
39-
page.getByRole('button', { name: 'Launch modal' }).press('Enter');
38+
await expect(button).toBeFocused();
39+
button.press('Enter');
4040

4141
// The first interactive item in the modal should be focused once the modal is open
4242
await expect(
@@ -67,9 +67,8 @@ test.describe('Modal @avt', () => {
6767

6868
// The modal should no longer be open/visisble
6969
await expect(page.getByRole('dialog')).not.toBeVisible();
70-
// Focus moves to the body
71-
// TODO: on close of the modal, focus should return to the element that opened the modal, see https://github.com/carbon-design-system/carbon/issues/13680
72-
await expect(page.locator('body')).toBeFocused();
70+
// Focus moves to the button that opened the Modal
71+
await expect(button).toBeFocused();
7372
});
7473

7574
test('danger modal - keyboard nav', async ({ page }) => {

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,6 +1406,26 @@ Map {
14061406
"isFullWidth": Object {
14071407
"type": "bool",
14081408
},
1409+
"launcherButtonRef": Object {
1410+
"args": Array [
1411+
Array [
1412+
Object {
1413+
"type": "func",
1414+
},
1415+
Object {
1416+
"args": Array [
1417+
Object {
1418+
"current": Object {
1419+
"type": "any",
1420+
},
1421+
},
1422+
],
1423+
"type": "shape",
1424+
},
1425+
],
1426+
],
1427+
"type": "oneOfType",
1428+
},
14091429
"onClose": Object {
14101430
"type": "func",
14111431
},
@@ -4710,6 +4730,26 @@ Map {
47104730
"isFullWidth": Object {
47114731
"type": "bool",
47124732
},
4733+
"launcherButtonRef": Object {
4734+
"args": Array [
4735+
Array [
4736+
Object {
4737+
"type": "func",
4738+
},
4739+
Object {
4740+
"args": Array [
4741+
Object {
4742+
"current": Object {
4743+
"type": "any",
4744+
},
4745+
},
4746+
],
4747+
"type": "shape",
4748+
},
4749+
],
4750+
],
4751+
"type": "oneOfType",
4752+
},
47134753
"modalAriaLabel": Object {
47144754
"type": "string",
47154755
},

packages/react/src/components/ComposedModal/ComposedModal.stories.js

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export const PassiveModal = () => {
136136
};
137137

138138
export const WithStateManager = () => {
139-
const closeButton = useRef();
139+
const button = useRef();
140140

141141
/**
142142
* Simple state manager for modals.
@@ -161,7 +161,7 @@ export const WithStateManager = () => {
161161
return (
162162
<ModalStateManager
163163
renderLauncher={({ setOpen }) => (
164-
<Button ref={closeButton} onClick={() => setOpen(true)}>
164+
<Button ref={button} onClick={() => setOpen(true)}>
165165
Launch composed modal
166166
</Button>
167167
)}>
@@ -170,10 +170,8 @@ export const WithStateManager = () => {
170170
open={open}
171171
onClose={() => {
172172
setOpen(false);
173-
setTimeout(() => {
174-
closeButton.current.focus();
175-
});
176-
}}>
173+
}}
174+
launcherButtonRef={button}>
177175
<ModalHeader label="Account resources" title="Add a custom domain" />
178176
<ModalBody>
179177
<p style={{ marginBottom: '1rem' }}>

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import React, {
77
type HTMLAttributes,
88
type ReactNode,
99
type ReactElement,
10+
type RefObject,
1011
} from 'react';
1112
import { isElement } from 'react-is';
1213
import PropTypes from 'prop-types';
@@ -147,6 +148,11 @@ export interface ComposedModalProps extends HTMLAttributes<HTMLDivElement> {
147148
*/
148149
isFullWidth?: boolean;
149150

151+
/**
152+
* Provide a ref to return focus to once the modal is closed.
153+
*/
154+
launcherButtonRef?: RefObject<HTMLButtonElement>;
155+
150156
/**
151157
* Specify an optional handler for closing modal.
152158
* Returning `false` here prevents closing modal.
@@ -194,6 +200,7 @@ const ComposedModal = React.forwardRef<HTMLDivElement, ComposedModalProps>(
194200
selectorPrimaryFocus,
195201
selectorsFloatingMenus,
196202
size,
203+
launcherButtonRef,
197204
...rest
198205
},
199206
ref
@@ -304,6 +311,14 @@ const ComposedModal = React.forwardRef<HTMLDivElement, ComposedModalProps>(
304311
}
305312
});
306313

314+
useEffect(() => {
315+
if (!open && launcherButtonRef) {
316+
setTimeout(() => {
317+
launcherButtonRef?.current?.focus();
318+
});
319+
}
320+
}, [open, launcherButtonRef]);
321+
307322
useEffect(() => {
308323
const initialFocus = (focusContainerElement) => {
309324
const containerElement = focusContainerElement || innerModal.current;
@@ -407,6 +422,17 @@ ComposedModal.propTypes = {
407422
*/
408423
isFullWidth: PropTypes.bool,
409424

425+
/**
426+
* Provide a ref to return focus to once the modal is closed.
427+
*/
428+
// @ts-expect-error: Invalid derived type
429+
launcherButtonRef: PropTypes.oneOfType([
430+
PropTypes.func,
431+
PropTypes.shape({
432+
current: PropTypes.any,
433+
}),
434+
]),
435+
410436
/**
411437
* Specify an optional handler for closing modal.
412438
* Returning `false` here prevents closing modal.

packages/react/src/components/Modal/Modal.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const Modal = React.forwardRef(function Modal(
4949
closeButtonLabel,
5050
preventCloseOnClickOutside, // eslint-disable-line
5151
isFullWidth,
52+
launcherButtonRef,
5253
...rest
5354
},
5455
ref
@@ -174,6 +175,14 @@ const Modal = React.forwardRef(function Modal(
174175
toggleClass(document.body, `${prefix}--body--with-modal-open`, open);
175176
}, [open, prefix]);
176177

178+
useEffect(() => {
179+
if (!open && launcherButtonRef) {
180+
setTimeout(() => {
181+
launcherButtonRef?.current?.focus();
182+
});
183+
}
184+
}, [open, launcherButtonRef]);
185+
177186
useEffect(() => {
178187
const initialFocus = (focusContainerElement) => {
179188
const containerElement = focusContainerElement || innerModal.current;
@@ -362,6 +371,16 @@ Modal.propTypes = {
362371
*/
363372
isFullWidth: PropTypes.bool,
364373

374+
/**
375+
* Provide a ref to return focus to once the modal is closed.
376+
*/
377+
launcherButtonRef: PropTypes.oneOfType([
378+
PropTypes.func,
379+
PropTypes.shape({
380+
current: PropTypes.any,
381+
}),
382+
]),
383+
365384
/**
366385
* Specify a label to be read by screen readers on the modal root node
367386
*/

packages/react/src/components/Modal/Modal.stories.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8-
import React, { useState } from 'react';
8+
import React, { useState, useRef } from 'react';
99
import ReactDOM from 'react-dom';
1010
import { action } from '@storybook/addon-actions';
1111
import Modal from './Modal';
@@ -379,13 +379,19 @@ export const WithStateManager = () => {
379379
</>
380380
);
381381
};
382+
383+
const button = useRef();
384+
382385
return (
383386
<ModalStateManager
384387
renderLauncher={({ setOpen }) => (
385-
<Button onClick={() => setOpen(true)}>Launch modal</Button>
388+
<Button ref={button} onClick={() => setOpen(true)}>
389+
Launch modal
390+
</Button>
386391
)}>
387392
{({ open, setOpen }) => (
388393
<Modal
394+
launcherButtonRef={button}
389395
modalHeading="Add a custom domain"
390396
modalLabel="Account resources"
391397
primaryButtonText="Add"

0 commit comments

Comments
 (0)