From 35fc72f0f52ab744036866d1b65506252c4c0f36 Mon Sep 17 00:00:00 2001 From: TJ Egan Date: Mon, 31 Jul 2023 18:50:48 -0400 Subject: [PATCH] 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 --- e2e/components/Modal/Modal-test.avt.e2e.js | 13 +++--- .../__snapshots__/PublicAPI-test.js.snap | 40 +++++++++++++++++++ .../ComposedModal/ComposedModal.stories.js | 10 ++--- .../ComposedModal/ComposedModal.tsx | 26 ++++++++++++ packages/react/src/components/Modal/Modal.js | 19 +++++++++ .../src/components/Modal/Modal.stories.js | 10 ++++- 6 files changed, 103 insertions(+), 15 deletions(-) diff --git a/e2e/components/Modal/Modal-test.avt.e2e.js b/e2e/components/Modal/Modal-test.avt.e2e.js index 106621e728a2..923800dcb83d 100644 --- a/e2e/components/Modal/Modal-test.avt.e2e.js +++ b/e2e/components/Modal/Modal-test.avt.e2e.js @@ -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( @@ -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 }) => { diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index aae39c9a5922..a86a1659e3ab 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -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", }, @@ -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", }, diff --git a/packages/react/src/components/ComposedModal/ComposedModal.stories.js b/packages/react/src/components/ComposedModal/ComposedModal.stories.js index 1c441f94ddea..20e0d793961d 100644 --- a/packages/react/src/components/ComposedModal/ComposedModal.stories.js +++ b/packages/react/src/components/ComposedModal/ComposedModal.stories.js @@ -136,7 +136,7 @@ export const PassiveModal = () => { }; export const WithStateManager = () => { - const closeButton = useRef(); + const button = useRef(); /** * Simple state manager for modals. @@ -161,7 +161,7 @@ export const WithStateManager = () => { return ( ( - )}> @@ -170,10 +170,8 @@ export const WithStateManager = () => { open={open} onClose={() => { setOpen(false); - setTimeout(() => { - closeButton.current.focus(); - }); - }}> + }} + launcherButtonRef={button}>

diff --git a/packages/react/src/components/ComposedModal/ComposedModal.tsx b/packages/react/src/components/ComposedModal/ComposedModal.tsx index 96a47deacbc4..ef7555510617 100644 --- a/packages/react/src/components/ComposedModal/ComposedModal.tsx +++ b/packages/react/src/components/ComposedModal/ComposedModal.tsx @@ -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'; @@ -147,6 +148,11 @@ export interface ComposedModalProps extends HTMLAttributes { */ isFullWidth?: boolean; + /** + * Provide a ref to return focus to once the modal is closed. + */ + launcherButtonRef?: RefObject; + /** * Specify an optional handler for closing modal. * Returning `false` here prevents closing modal. @@ -194,6 +200,7 @@ const ComposedModal = React.forwardRef( selectorPrimaryFocus, selectorsFloatingMenus, size, + launcherButtonRef, ...rest }, ref @@ -304,6 +311,14 @@ const ComposedModal = React.forwardRef( } }); + useEffect(() => { + if (!open && launcherButtonRef) { + setTimeout(() => { + launcherButtonRef?.current?.focus(); + }); + } + }, [open, launcherButtonRef]); + useEffect(() => { const initialFocus = (focusContainerElement) => { const containerElement = focusContainerElement || innerModal.current; @@ -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. diff --git a/packages/react/src/components/Modal/Modal.js b/packages/react/src/components/Modal/Modal.js index 79aa018ce9e1..1d04dd39a7b9 100644 --- a/packages/react/src/components/Modal/Modal.js +++ b/packages/react/src/components/Modal/Modal.js @@ -49,6 +49,7 @@ const Modal = React.forwardRef(function Modal( closeButtonLabel, preventCloseOnClickOutside, // eslint-disable-line isFullWidth, + launcherButtonRef, ...rest }, ref @@ -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; @@ -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 */ diff --git a/packages/react/src/components/Modal/Modal.stories.js b/packages/react/src/components/Modal/Modal.stories.js index 1ef8e03ff6c1..964bb8fe3da4 100644 --- a/packages/react/src/components/Modal/Modal.stories.js +++ b/packages/react/src/components/Modal/Modal.stories.js @@ -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'; @@ -379,13 +379,19 @@ export const WithStateManager = () => { ); }; + + const button = useRef(); + return ( ( - + )}> {({ open, setOpen }) => (