Skip to content

Commit

Permalink
feat(Modal): add launcherButtonRef prop to handle focus on close (#14355
Browse files Browse the repository at this point in the history
)

* feat(Modal): add launcherButtonRef prop to handle focus on close

* test(Modal): update AVT tests
  • Loading branch information
tw15egan committed Jul 31, 2023
1 parent 37e2732 commit 35fc72f
Show file tree
Hide file tree
Showing 6 changed files with 103 additions and 15 deletions.
13 changes: 6 additions & 7 deletions e2e/components/Modal/Modal-test.avt.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ test.describe('Modal @avt', () => {
},
});

const button = page.getByRole('button', { name: 'Launch modal' });

// Open the modal via keyboard navigation
await page.keyboard.press('Tab');
await expect(
page.getByRole('button', { name: 'Launch modal' })
).toBeFocused();
page.getByRole('button', { name: 'Launch modal' }).press('Enter');
await expect(button).toBeFocused();
button.press('Enter');

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

// The modal should no longer be open/visisble
await expect(page.getByRole('dialog')).not.toBeVisible();
// Focus moves to the body
// 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
await expect(page.locator('body')).toBeFocused();
// Focus moves to the button that opened the Modal
await expect(button).toBeFocused();
});

test('danger modal - keyboard nav', async ({ page }) => {
Expand Down
40 changes: 40 additions & 0 deletions packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -1406,6 +1406,26 @@ Map {
"isFullWidth": Object {
"type": "bool",
},
"launcherButtonRef": Object {
"args": Array [
Array [
Object {
"type": "func",
},
Object {
"args": Array [
Object {
"current": Object {
"type": "any",
},
},
],
"type": "shape",
},
],
],
"type": "oneOfType",
},
"onClose": Object {
"type": "func",
},
Expand Down Expand Up @@ -4710,6 +4730,26 @@ Map {
"isFullWidth": Object {
"type": "bool",
},
"launcherButtonRef": Object {
"args": Array [
Array [
Object {
"type": "func",
},
Object {
"args": Array [
Object {
"current": Object {
"type": "any",
},
},
],
"type": "shape",
},
],
],
"type": "oneOfType",
},
"modalAriaLabel": Object {
"type": "string",
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export const PassiveModal = () => {
};

export const WithStateManager = () => {
const closeButton = useRef();
const button = useRef();

/**
* Simple state manager for modals.
Expand All @@ -161,7 +161,7 @@ export const WithStateManager = () => {
return (
<ModalStateManager
renderLauncher={({ setOpen }) => (
<Button ref={closeButton} onClick={() => setOpen(true)}>
<Button ref={button} onClick={() => setOpen(true)}>
Launch composed modal
</Button>
)}>
Expand All @@ -170,10 +170,8 @@ export const WithStateManager = () => {
open={open}
onClose={() => {
setOpen(false);
setTimeout(() => {
closeButton.current.focus();
});
}}>
}}
launcherButtonRef={button}>
<ModalHeader label="Account resources" title="Add a custom domain" />
<ModalBody>
<p style={{ marginBottom: '1rem' }}>
Expand Down
26 changes: 26 additions & 0 deletions packages/react/src/components/ComposedModal/ComposedModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import React, {
type HTMLAttributes,
type ReactNode,
type ReactElement,
type RefObject,
} from 'react';
import { isElement } from 'react-is';
import PropTypes from 'prop-types';
Expand Down Expand Up @@ -147,6 +148,11 @@ export interface ComposedModalProps extends HTMLAttributes<HTMLDivElement> {
*/
isFullWidth?: boolean;

/**
* Provide a ref to return focus to once the modal is closed.
*/
launcherButtonRef?: RefObject<HTMLButtonElement>;

/**
* Specify an optional handler for closing modal.
* Returning `false` here prevents closing modal.
Expand Down Expand Up @@ -194,6 +200,7 @@ const ComposedModal = React.forwardRef<HTMLDivElement, ComposedModalProps>(
selectorPrimaryFocus,
selectorsFloatingMenus,
size,
launcherButtonRef,
...rest
},
ref
Expand Down Expand Up @@ -304,6 +311,14 @@ const ComposedModal = React.forwardRef<HTMLDivElement, ComposedModalProps>(
}
});

useEffect(() => {
if (!open && launcherButtonRef) {
setTimeout(() => {
launcherButtonRef?.current?.focus();
});
}
}, [open, launcherButtonRef]);

useEffect(() => {
const initialFocus = (focusContainerElement) => {
const containerElement = focusContainerElement || innerModal.current;
Expand Down Expand Up @@ -407,6 +422,17 @@ ComposedModal.propTypes = {
*/
isFullWidth: PropTypes.bool,

/**
* Provide a ref to return focus to once the modal is closed.
*/
// @ts-expect-error: Invalid derived type
launcherButtonRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({
current: PropTypes.any,
}),
]),

/**
* Specify an optional handler for closing modal.
* Returning `false` here prevents closing modal.
Expand Down
19 changes: 19 additions & 0 deletions packages/react/src/components/Modal/Modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const Modal = React.forwardRef(function Modal(
closeButtonLabel,
preventCloseOnClickOutside, // eslint-disable-line
isFullWidth,
launcherButtonRef,
...rest
},
ref
Expand Down Expand Up @@ -174,6 +175,14 @@ const Modal = React.forwardRef(function Modal(
toggleClass(document.body, `${prefix}--body--with-modal-open`, open);
}, [open, prefix]);

useEffect(() => {
if (!open && launcherButtonRef) {
setTimeout(() => {
launcherButtonRef?.current?.focus();
});
}
}, [open, launcherButtonRef]);

useEffect(() => {
const initialFocus = (focusContainerElement) => {
const containerElement = focusContainerElement || innerModal.current;
Expand Down Expand Up @@ -362,6 +371,16 @@ Modal.propTypes = {
*/
isFullWidth: PropTypes.bool,

/**
* Provide a ref to return focus to once the modal is closed.
*/
launcherButtonRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({
current: PropTypes.any,
}),
]),

/**
* Specify a label to be read by screen readers on the modal root node
*/
Expand Down
10 changes: 8 additions & 2 deletions packages/react/src/components/Modal/Modal.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/

import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import ReactDOM from 'react-dom';
import { action } from '@storybook/addon-actions';
import Modal from './Modal';
Expand Down Expand Up @@ -379,13 +379,19 @@ export const WithStateManager = () => {
</>
);
};

const button = useRef();

return (
<ModalStateManager
renderLauncher={({ setOpen }) => (
<Button onClick={() => setOpen(true)}>Launch modal</Button>
<Button ref={button} onClick={() => setOpen(true)}>
Launch modal
</Button>
)}>
{({ open, setOpen }) => (
<Modal
launcherButtonRef={button}
modalHeading="Add a custom domain"
modalLabel="Account resources"
primaryButtonText="Add"
Expand Down

0 comments on commit 35fc72f

Please sign in to comment.