From ef2317f9bc89baee7d2a4e27ba38fd80e589b80d Mon Sep 17 00:00:00 2001 From: Christiaan Scheermeijer Date: Thu, 28 Mar 2024 16:06:53 +0100 Subject: [PATCH] refactor(project): add modal provider and hook --- packages/ui-react/package.json | 3 +- .../src/components/Alert/Alert.test.tsx | 5 +- .../ConfirmationDialog.test.tsx | 5 +- .../src/components/Dialog/Dialog.test.tsx | 7 +- .../src/components/Modal/Modal.test.tsx | 16 +- .../ui-react/src/components/Modal/Modal.tsx | 72 +----- .../src/components/Sidebar/Sidebar.test.tsx | 91 +++++++ .../src/components/Sidebar/Sidebar.tsx | 63 +---- .../containers/AccountModal/AccountModal.tsx | 18 +- .../ui-react/src/containers/Layout/Layout.tsx | 7 +- .../ModalProvider/ModalProvider.test.tsx | 142 +++++++++++ .../ModalProvider/ModalProvider.tsx | 225 ++++++++++++++++++ .../__snapshots__/ModalProvider.test.tsx.snap | 26 ++ .../src/containers/ModalProvider/useModal.ts | 45 ++++ packages/ui-react/test/utils.tsx | 7 +- packages/ui-react/vitest.setup.ts | 1 + platforms/web/src/App.tsx | 5 +- yarn.lock | 2 +- 18 files changed, 593 insertions(+), 147 deletions(-) create mode 100644 packages/ui-react/src/containers/ModalProvider/ModalProvider.test.tsx create mode 100644 packages/ui-react/src/containers/ModalProvider/ModalProvider.tsx create mode 100644 packages/ui-react/src/containers/ModalProvider/__snapshots__/ModalProvider.test.tsx.snap create mode 100644 packages/ui-react/src/containers/ModalProvider/useModal.ts diff --git a/packages/ui-react/package.json b/packages/ui-react/package.json index 85b12ee45..f77b23523 100644 --- a/packages/ui-react/package.json +++ b/packages/ui-react/package.json @@ -45,7 +45,8 @@ "typescript-plugin-css-modules": "^5.0.2", "vi-fetch": "^0.8.0", "vite-plugin-svgr": "^4.2.0", - "vitest": "^1.3.1" + "vitest": "^1.3.1", + "wicg-inert": "^3.1.2" }, "peerDependencies": { "@jwp/ott-common": "*", diff --git a/packages/ui-react/src/components/Alert/Alert.test.tsx b/packages/ui-react/src/components/Alert/Alert.test.tsx index f689c656d..e08c1842e 100644 --- a/packages/ui-react/src/components/Alert/Alert.test.tsx +++ b/packages/ui-react/src/components/Alert/Alert.test.tsx @@ -1,11 +1,12 @@ import React from 'react'; -import { render } from '@testing-library/react'; + +import { renderWithRouter } from '../../../test/utils'; import Alert from './Alert'; describe('', () => { test('renders and matches snapshot', () => { - const { container } = render(); + const { container } = renderWithRouter(); expect(container).toMatchSnapshot(); }); }); diff --git a/packages/ui-react/src/components/ConfirmationDialog/ConfirmationDialog.test.tsx b/packages/ui-react/src/components/ConfirmationDialog/ConfirmationDialog.test.tsx index 12f25aa0c..d8e91bd98 100644 --- a/packages/ui-react/src/components/ConfirmationDialog/ConfirmationDialog.test.tsx +++ b/packages/ui-react/src/components/ConfirmationDialog/ConfirmationDialog.test.tsx @@ -1,11 +1,12 @@ import React from 'react'; -import { render } from '@testing-library/react'; + +import { renderWithRouter } from '../../../test/utils'; import ConfirmationDialog from './ConfirmationDialog'; describe('', () => { test('renders and matches snapshot', () => { - const { container } = render(); + const { container } = renderWithRouter(); expect(container).toMatchSnapshot(); }); diff --git a/packages/ui-react/src/components/Dialog/Dialog.test.tsx b/packages/ui-react/src/components/Dialog/Dialog.test.tsx index 862221de8..4865b0f04 100644 --- a/packages/ui-react/src/components/Dialog/Dialog.test.tsx +++ b/packages/ui-react/src/components/Dialog/Dialog.test.tsx @@ -1,11 +1,12 @@ import React from 'react'; -import { render } from '@testing-library/react'; + +import { renderWithRouter } from '../../../test/utils'; import Dialog from './Dialog'; describe('', () => { test('renders and matches snapshot', () => { - const { baseElement } = render( + const { baseElement } = renderWithRouter( <> Some content @@ -19,7 +20,7 @@ describe('', () => { }); test('Should ensure Dialog is properly marked as a modal and has role "dialog"', () => { - const { getByTestId } = render( + const { getByTestId } = renderWithRouter( <> Some content diff --git a/packages/ui-react/src/components/Modal/Modal.test.tsx b/packages/ui-react/src/components/Modal/Modal.test.tsx index 43a04029e..aaa326054 100644 --- a/packages/ui-react/src/components/Modal/Modal.test.tsx +++ b/packages/ui-react/src/components/Modal/Modal.test.tsx @@ -1,11 +1,13 @@ import React from 'react'; -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent } from '@testing-library/react'; + +import { renderWithRouter } from '../../../test/utils'; import Modal from './Modal'; describe('', () => { test('renders and matches snapshot', () => { - const { container } = render( + const { container } = renderWithRouter(

Test modal

, @@ -16,16 +18,16 @@ describe('', () => { test('calls the onClose function when clicking the backdrop', () => { const onClose = vi.fn(); - const { getByTestId } = render(); + const { getByTestId } = renderWithRouter(); fireEvent.click(getByTestId('backdrop')); expect(onClose).toBeCalledTimes(1); }); - test('Should add inert attribute on the root div when open', () => { + test('add the inert attribute on the root div when open', () => { const onClose = vi.fn(); - const { getByTestId, rerender } = render( + const { getByTestId, rerender } = renderWithRouter(
, @@ -42,9 +44,9 @@ describe('', () => { expect(getByTestId('root')).toHaveProperty('inert', false); }); - test('should add overflowY hidden on the body element when open', () => { + test('add overflowY hidden on the body element when open', () => { const onClose = vi.fn(); - const { container, rerender } = render(); + const { container, rerender } = renderWithRouter(); expect(container.parentNode).toHaveStyle({ overflowY: 'hidden' }); diff --git a/packages/ui-react/src/components/Modal/Modal.tsx b/packages/ui-react/src/components/Modal/Modal.tsx index 2bb2c3000..fef15b1ce 100644 --- a/packages/ui-react/src/components/Modal/Modal.tsx +++ b/packages/ui-react/src/components/Modal/Modal.tsx @@ -1,10 +1,10 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useRef } from 'react'; import ReactDOM from 'react-dom'; import { testId } from '@jwp/ott-common/src/utils/common'; import Fade from '../Animation/Fade/Fade'; import Grow from '../Animation/Grow/Grow'; -import scrollbarSize from '../../utils/dom'; +import { useModal } from '../../containers/ModalProvider/useModal'; import styles from './Modal.module.scss'; @@ -16,74 +16,16 @@ type Props = { animationContainerClassName?: string; } & React.AriaAttributes; -const Modal: React.FC = ({ open, onClose, children, AnimationComponent = Grow, animationContainerClassName, ...ariaAtributes }: Props) => { - const [visible, setVisible] = useState(open); - const lastFocus = useRef() as React.MutableRefObject; +const Modal: React.FC = ({ open, onClose, children, AnimationComponent = Grow, animationContainerClassName, ...ariaAttributes }: Props) => { const modalRef = useRef() as React.MutableRefObject; - const keyDownEventHandler = (event: React.KeyboardEvent) => { - if (event.key === 'Escape' && onClose) { - onClose(); - } - }; - - // delay the transition state so the CSS transition kicks in after toggling the `open` prop - useEffect(() => { - const activeElement = document.activeElement as HTMLElement; - const appView = document.querySelector('#root') as HTMLDivElement; - - if (open) { - // store last focussed element - if (activeElement) { - lastFocus.current = activeElement; - } - - // reset the visible state - setVisible(true); - - // make sure main content is hidden for screen readers and inert - if (appView) { - appView.inert = true; - } - - // prevent scrolling under the modal - document.body.style.marginRight = `${scrollbarSize()}px`; - document.body.style.overflowY = 'hidden'; - } else { - if (appView) { - appView.inert = false; - } - - document.body.style.removeProperty('margin-right'); - document.body.style.removeProperty('overflow-y'); - } - }, [open]); - - useEffect(() => { - if (visible) { - // focus the first element in the modal - if (modalRef.current) { - const interactiveElement = modalRef.current.querySelectorAll( - 'div[role="dialog"] input, div[role="dialog"] a, div[role="dialog"] button, div[role="dialog"] [tabindex]', - )[0] as HTMLElement | null; - - if (interactiveElement) interactiveElement.focus(); - } - } else { - // restore last focussed element - if (lastFocus.current) { - lastFocus.current.focus(); - } - } - }, [visible]); - - if (!open && !visible) return null; + useModal({ open, onClose, modalRef }); return ReactDOM.createPortal( - setVisible(false)}> -
+ +
-
+
{children} diff --git a/packages/ui-react/src/components/Sidebar/Sidebar.test.tsx b/packages/ui-react/src/components/Sidebar/Sidebar.test.tsx index 018a9c66f..4a2c35254 100644 --- a/packages/ui-react/src/components/Sidebar/Sidebar.test.tsx +++ b/packages/ui-react/src/components/Sidebar/Sidebar.test.tsx @@ -8,6 +8,14 @@ import Sidebar from './Sidebar'; describe('', () => { const playlistMenuItems = [ + , + ); + + expect(document.activeElement).toBe(document.body); + + rerender( + + + , + ); + vi.runAllTimers(); + + expect(document.activeElement).toBe(getByLabelText('close_menu')); + }); + + test('should focus the last focused element when the sidebar is closed', () => { + const { getByText, rerender } = renderWithRouter( + <> + + + + + , + ); + getByText('open').focus(); + rerender( + <> + + + + + , + ); + vi.runAllTimers(); + + rerender( + <> + + + + + , + ); + vi.runAllTimers(); + + expect(document.activeElement).toBe(getByText('open')); + }); }); diff --git a/packages/ui-react/src/components/Sidebar/Sidebar.tsx b/packages/ui-react/src/components/Sidebar/Sidebar.tsx index be697c743..c07625be3 100644 --- a/packages/ui-react/src/components/Sidebar/Sidebar.tsx +++ b/packages/ui-react/src/components/Sidebar/Sidebar.tsx @@ -1,11 +1,11 @@ -import React, { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'; +import React, { Fragment, type ReactNode, type RefObject, useEffect, useRef } from 'react'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; import Close from '@jwp/ott-theme/assets/icons/close.svg?react'; import IconButton from '../IconButton/IconButton'; import Icon from '../Icon/Icon'; -import scrollbarSize from '../../utils/dom'; +import { useModal } from '../../containers/ModalProvider/useModal'; import styles from './Sidebar.module.scss'; @@ -13,69 +13,24 @@ type SidebarProps = { isOpen: boolean; onClose: () => void; children?: ReactNode; + containerRef?: RefObject; }; -const Sidebar: React.FC = ({ isOpen, onClose, children }) => { +const Sidebar: React.FC = ({ isOpen, onClose, containerRef, children }) => { const { t } = useTranslation('menu'); - const lastFocusedElementRef = useRef(null); const sidebarRef = useRef(null); - const [visible, setVisible] = useState(false); + + useModal({ open: isOpen, onClose, modalRef: sidebarRef, containerRef }); const htmlAttributes = { inert: !isOpen ? '' : undefined }; // inert is not yet officially supported in react. see: https://github.com/facebook/react/pull/24730 useEffect(() => { - if (isOpen) { - // Before inert on the body is applied in Layout, we need to set this ref - lastFocusedElementRef.current = document.activeElement as HTMLElement; - - // When opened, adjust the margin-right to accommodate for the scrollbar width to prevent UI shifts in background - document.body.style.marginRight = `${scrollbarSize()}px`; - document.body.style.overflowY = 'hidden'; - - // Scroll the sidebar to the top if the user has previously scrolled down in the sidebar - if (sidebarRef.current) { - sidebarRef.current.scrollTop = 0; - } - } else { - document.body.style.removeProperty('margin-right'); - document.body.style.removeProperty('overflow-y'); + // Scroll the sidebar to the top if the user has previously scrolled down in the sidebar + if (isOpen && sidebarRef.current) { + sidebarRef.current.scrollTop = 0; } }, [isOpen]); - useEffect(() => { - const handleEscKey = (event: KeyboardEvent) => { - if (isOpen && event.key === 'Escape') { - onClose(); - } - }; - document.addEventListener('keydown', handleEscKey); - - return () => { - document.removeEventListener('keydown', handleEscKey); - }; - }, [isOpen, onClose]); - - useEffect(() => { - const sidebarElement = sidebarRef.current; - const handleTransitionEnd = () => { - setVisible(isOpen); - }; - - sidebarElement?.addEventListener('transitionend', handleTransitionEnd); - - return () => { - sidebarElement?.removeEventListener('transitionend', handleTransitionEnd); - }; - }, [isOpen]); - - useEffect(() => { - if (visible) { - sidebarRef.current?.querySelectorAll('a')[0]?.focus({ preventScroll: true }); - } else { - lastFocusedElementRef.current?.focus({ preventScroll: true }); - } - }, [visible]); - return (
{ + const { focusActiveModal } = useContext(ModalContext); const navigate = useNavigate(); const location = useLocation(); const viewParam = useQueryParam('u'); - const [view, setView] = useState(viewParam); + const viewParamRef = useRef(viewParam); const message = useQueryParam('message'); const { loading, user } = useAccountStore(({ loading, user }) => ({ loading, user }), shallow); const config = useConfigStore((s) => s.config); @@ -81,9 +83,10 @@ const AccountModal = () => { navigate(modalURLFromLocation(location, 'login')); }); - useEffect(() => { - // make sure the last view is rendered even when the modal gets closed - if (viewParam) setView(viewParam); + // make sure the last view is rendered even when the modal gets closed + const view = useMemo(() => { + if (viewParam) viewParamRef.current = viewParam; + return viewParamRef.current; }, [viewParam]); useEffect(() => { @@ -92,6 +95,11 @@ const AccountModal = () => { } }, [viewParam, loading, isPublicView, user, toLogin]); + // focus the active modal because the content changes when the viewParam changes + useEffect(() => { + if (viewParam) focusActiveModal(); + }, [viewParam, focusActiveModal]); + const closeHandler = useEventCallback(() => { navigate(createURLFromLocation(location, { u: null, message: null })); }); diff --git a/packages/ui-react/src/containers/Layout/Layout.tsx b/packages/ui-react/src/containers/Layout/Layout.tsx index 845f79cc3..da9d2cfe7 100644 --- a/packages/ui-react/src/containers/Layout/Layout.tsx +++ b/packages/ui-react/src/containers/Layout/Layout.tsx @@ -32,6 +32,7 @@ const Layout = () => { const location = useLocation(); const navigate = useNavigate(); const { t, i18n } = useTranslation('common'); + const containerRef = useRef(null); const { config, accessModel, supportedLanguages } = useConfigStore( ({ config, accessModel, supportedLanguages }) => ({ config, accessModel, supportedLanguages }), @@ -147,8 +148,6 @@ const Layout = () => { ); }; - const containerProps = { inert: sideBarOpen ? '' : undefined }; // inert is not yet officially supported in react - return (
@@ -159,7 +158,7 @@ const Layout = () => { -
+
setSideBarOpen(true)} logoSrc={banner} @@ -207,7 +206,7 @@ const Layout = () => { {!!footerText &&
}
- setSideBarOpen(false)}> + setSideBarOpen(false)} containerRef={containerRef}>
  • diff --git a/packages/ui-react/src/containers/ModalProvider/ModalProvider.test.tsx b/packages/ui-react/src/containers/ModalProvider/ModalProvider.test.tsx new file mode 100644 index 000000000..bfae1b936 --- /dev/null +++ b/packages/ui-react/src/containers/ModalProvider/ModalProvider.test.tsx @@ -0,0 +1,142 @@ +import React, { type RefObject, useRef } from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import ModalProvider from './ModalProvider'; +import { useModal } from './useModal'; + +type Props = { + open: boolean; + onClose?: () => void; + modalRef: RefObject; + containerRef?: RefObject; +}; + +const TestModal = ({ open, onClose, containerRef, name }: Omit & { name: string }) => { + const modalRef = useRef(null); + useModal({ open, onClose, containerRef, modalRef }); + + if (!open) { + return null; + } + + return ( +
    + +
    + ); +}; + +const renderSingleModal = (open: boolean, onClose?: () => void) => { + return ( + + <> +
    + +
    + + +
    + ); +}; + +const renderMultiModal = (open: boolean, secondOpen: boolean, onClose?: () => void, onSecondClose?: () => void) => { + return ( + + <> +
    + +
    + + + +
    + ); +}; + +describe('', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + test('renders provider with test modal', () => { + const { container, getByTestId } = render(renderSingleModal(true)); + + expect(container).toMatchSnapshot(); + expect(getByTestId('root')).toHaveAttribute('inert'); + }); + + test('clears inert when all modals are closed', () => { + const onClose = vi.fn(); + + const { getByTestId, getByText, rerender } = render(renderSingleModal(true, onClose)); + + expect(getByTestId('root')).toHaveAttribute('inert'); + + fireEvent.click(getByText('close modal-1')); + + rerender(renderSingleModal(false, onClose)); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(getByTestId('root')).not.toHaveAttribute('inert'); + expect(document.activeElement).toBe(document.body); + }); + + test('focus the first interactive element', () => { + const onClose = vi.fn(); + + const { getByText, rerender } = render(renderSingleModal(false, onClose)); + + rerender(renderSingleModal(true, onClose)); + vi.runAllTimers(); + + expect(document.activeElement).toBe(getByText('close modal-1')); + }); + + test('restores focus to the last active element', () => { + const { getByText, rerender } = render(renderSingleModal(false)); + + // focus button + getByText('last focus').focus(); + + rerender(renderSingleModal(true)); + + vi.runAllTimers(); + expect(document.activeElement).toBe(getByText('close modal-1')); + + rerender(renderSingleModal(false)); + + vi.runAllTimers(); + expect(document.activeElement).toBe(getByText('last focus')); + }); + + test('restores focus to the last active element when multiple modals are opened and closed', () => { + const { getByText, rerender } = render(renderMultiModal(false, false)); + + // focus button + getByText('last focus').focus(); + + rerender(renderMultiModal(true, false)); + + vi.runAllTimers(); + expect(document.activeElement).toBe(getByText('close modal-1')); + + rerender(renderMultiModal(true, true)); + + vi.runAllTimers(); + expect(document.activeElement).toBe(getByText('close modal-2')); + + rerender(renderMultiModal(true, false)); + + vi.runAllTimers(); + expect(document.activeElement).toBe(getByText('close modal-1')); + + rerender(renderMultiModal(false, false)); + + vi.runAllTimers(); + expect(document.activeElement).toBe(getByText('last focus')); + }); +}); diff --git a/packages/ui-react/src/containers/ModalProvider/ModalProvider.tsx b/packages/ui-react/src/containers/ModalProvider/ModalProvider.tsx new file mode 100644 index 000000000..80cddde93 --- /dev/null +++ b/packages/ui-react/src/containers/ModalProvider/ModalProvider.tsx @@ -0,0 +1,225 @@ +import { createContext, type PropsWithChildren, type RefObject, useCallback, useEffect, useMemo, useState } from 'react'; +import useEventCallback from '@jwp/ott-hooks-react/src/useEventCallback'; +import { logDev } from '@jwp/ott-common/src/utils/common'; + +import scrollbarSize from '../../utils/dom'; + +type Modal = { + modalId: string; + modalRef: RefObject; + onClose?: () => void; + containerRef?: RefObject; + lastFocus: Element | null; +}; + +type ModalContextValue = { + openModal: (modalId: string, modalRef: RefObject, onClose?: () => void, containerRef?: RefObject) => void; + closeModal: (modalId: string, notify?: boolean) => void; + closeAllModals: (notify?: boolean) => void; + focusActiveModal: () => void; + modals: Modal[]; +}; + +export const ModalContext = createContext({ + openModal() { + throw new Error('Not implemented'); + }, + closeModal() { + throw new Error('Not implemented'); + }, + closeAllModals() { + throw new Error('Not implemented'); + }, + focusActiveModal() { + throw new Error('Not implemented'); + }, + modals: [], +}); + +const byId = (modalId: string) => (modal: Modal) => modal.modalId === modalId; +const notById = (modalId: string) => (modal: Modal) => modal.modalId !== modalId; + +const focusModal = (openedModal: Modal, targetElement?: Element | null) => { + const { modalRef } = openedModal; + + requestAnimationFrame(() => { + if (!modalRef.current) return; + + // prefer focusing the targetElement + if (targetElement && targetElement instanceof HTMLElement && modalRef.current.contains(targetElement)) { + return targetElement.focus(); + } + + // find the first interactive element + const interactiveElement = modalRef.current.querySelectorAll('input, a, button, [tabindex]')[0] as HTMLElement | null; + + if (!interactiveElement) { + logDev('Failed to focus modal contents', { openedModal, targetElement }); + } + + interactiveElement?.focus(); + }); +}; + +const elementIsFocusable = (element: Element | null): element is HTMLElement => { + const inertElements = document.querySelectorAll('[inert]'); + const parentHasInert = Array.from(inertElements).some((parent) => parent.contains(element)); + + return element instanceof HTMLElement && document.body.contains(element) && !parentHasInert; +}; + +const restoreFocus = (closedModal: Modal, activeModals: Modal[]) => { + const activeModal = activeModals[activeModals.length - 1]; + + // if there is still an active modal, focus that + if (activeModal) { + return focusModal(activeModal, closedModal.lastFocus); + } + + // temp variable because originFocusElement gets cleared + const originFocus = originFocusElement; + + // focus the last focussed or origin element with fallback to the body element + requestAnimationFrame(() => { + if (elementIsFocusable(closedModal.lastFocus)) { + closedModal.lastFocus.focus({ preventScroll: true }); + } else if (elementIsFocusable(originFocus)) { + originFocus.focus({ preventScroll: true }); + } else if (document.activeElement instanceof HTMLElement) { + logDev('Failed to restore focus', { closedModal, originFocus }); + document.activeElement.blur(); + window.focus(); + } + }); +}; + +let originFocusElement: HTMLElement | null = null; + +const getInertTarget = (modal: Modal) => { + const appView = document.querySelector('#root') as HTMLDivElement | null; + return modal.containerRef?.current || appView; +}; + +const handleInert = (targetModal: Modal, activeModals: Modal[]) => { + const inertTarget = getInertTarget(targetModal); + + if (inertTarget) { + inertTarget.inert = activeModals.some((modal) => getInertTarget(modal) === inertTarget); + } +}; + +const handleBodyScrolling = (activeModals: Modal[]) => { + if (activeModals.length > 0) { + document.body.style.marginRight = `${scrollbarSize()}px`; + document.body.style.overflowY = 'hidden'; + } else { + document.body.style.removeProperty('margin-right'); + document.body.style.removeProperty('overflow-y'); + } +}; + +const ModalProvider = ({ children }: PropsWithChildren) => { + const [modals, setModals] = useState([]); + + const keyDownEventHandler = useEventCallback((event: globalThis.KeyboardEvent) => { + if (event.key === 'Escape' && modals.length) { + closeModal(modals[modals.length - 1].modalId); + } + }); + + useEffect(() => { + document.addEventListener('keydown', keyDownEventHandler); + + return () => { + document.removeEventListener('keydown', keyDownEventHandler); + }; + }, [keyDownEventHandler]); + + useEffect(() => { + handleBodyScrolling(modals); + + // keep track of the origin focus element in case we stack multiple modals, for example, sidebar -> account modal + if (modals.length === 1 && !originFocusElement) { + originFocusElement = modals[0].lastFocus as HTMLElement; + } else if (modals.length === 0) { + originFocusElement = null; + } + }, [modals]); + + const openModal = useCallback((modalId: string, modalRef: RefObject, onClose?: () => void, containerRef?: RefObject) => { + const modal: Modal = { + modalId, + onClose, + modalRef, + containerRef, + lastFocus: document.activeElement, + }; + + setModals((current) => { + const newModals = [...current, modal]; + focusModal(modal); + handleInert(modal, newModals); + + return newModals; + }); + }, []); + + const closeModal = useCallback( + (modalId: string, notify = true) => { + const modal = modals.find(byId(modalId)); + + if (!modal) return; + if (notify) modal.onClose?.(); + + setModals((current) => { + const newModals = current.filter(notById(modalId)); + + restoreFocus(modal, newModals); + handleInert(modal, newModals); + + return newModals; + }); + }, + [modals], + ); + + const closeAllModals = useCallback( + (notify = true) => { + // the first modal opened has the correct lastFocus + const firstModal = modals[0]; + + modals.forEach((modal) => { + if (notify) modal.onClose?.(); + handleInert(modal, []); + }); + + if (firstModal) { + restoreFocus(firstModal, []); + } + + setModals([]); + }, + [modals], + ); + + const focusActiveModal = useCallback(() => { + const modal = modals[modals.length - 1]; + + if (modal) focusModal(modal); + }, [modals]); + + const value = useMemo( + () => ({ + openModal, + closeModal, + closeAllModals, + focusActiveModal, + modals, + }), + [closeAllModals, closeModal, focusActiveModal, modals, openModal], + ); + + return {children}; +}; + +export default ModalProvider; diff --git a/packages/ui-react/src/containers/ModalProvider/__snapshots__/ModalProvider.test.tsx.snap b/packages/ui-react/src/containers/ModalProvider/__snapshots__/ModalProvider.test.tsx.snap new file mode 100644 index 000000000..d9a613ea7 --- /dev/null +++ b/packages/ui-react/src/containers/ModalProvider/__snapshots__/ModalProvider.test.tsx.snap @@ -0,0 +1,26 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders provider with test modal 1`] = ` +
    + +
    + +
    +
    +`; diff --git a/packages/ui-react/src/containers/ModalProvider/useModal.ts b/packages/ui-react/src/containers/ModalProvider/useModal.ts new file mode 100644 index 000000000..6594956e2 --- /dev/null +++ b/packages/ui-react/src/containers/ModalProvider/useModal.ts @@ -0,0 +1,45 @@ +import { type RefObject, useContext, useEffect, useRef } from 'react'; +import useEventCallback from '@jwp/ott-hooks-react/src/useEventCallback'; + +import { ModalContext } from './ModalProvider'; + +type Params = { + open: boolean; + onClose?: () => void; + modalRef: RefObject; + containerRef?: RefObject; +}; + +export const useModal = ({ open, onClose, modalRef, containerRef }: Params) => { + const modalId = useRef(String(Math.round(Math.random() * 1_000_000))).current; + const { openModal, closeModal, modals } = useContext(ModalContext); + const isOpen = modals.some((modal) => modal.modalId === modalId); + + const onCloseCallback = useEventCallback(onClose); + + useEffect(() => { + if (open) { + openModal(modalId, modalRef, onCloseCallback, containerRef); + } else if (isOpen) { + closeModal(modalId, false); + } + // only react to `open` changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + useEffect(() => { + return () => { + isOpen && closeModal(modalId, false); + }; + // unmount only to unregister the modal when it was open + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const close = () => closeModal(modalId); + + return { + isOpen, + closeModal: close, + open, + }; +}; diff --git a/packages/ui-react/test/utils.tsx b/packages/ui-react/test/utils.tsx index ce732f7ad..3dcca53c5 100644 --- a/packages/ui-react/test/utils.tsx +++ b/packages/ui-react/test/utils.tsx @@ -1,10 +1,11 @@ import { createBrowserRouter, createRoutesFromElements, Route, RouterProvider } from 'react-router-dom'; import React, { type ReactElement, type ReactNode } from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; -import { render, act, type RenderOptions } from '@testing-library/react'; +import { act, render, type RenderOptions } from '@testing-library/react'; import QueryProvider from '../src/containers/QueryProvider/QueryProvider'; import { AriaAnnouncerProvider } from '../src/containers/AnnouncementProvider/AnnoucementProvider'; +import ModalProvider from '../src/containers/ModalProvider/ModalProvider'; interface WrapperProps { children?: ReactNode; @@ -29,7 +30,9 @@ export const createWrapper = () => { export const wrapper = ({ children }: WrapperProps) => ( - {children as ReactElement} + + {children as ReactElement} + ); diff --git a/packages/ui-react/vitest.setup.ts b/packages/ui-react/vitest.setup.ts index f97e711f3..729650b0f 100644 --- a/packages/ui-react/vitest.setup.ts +++ b/packages/ui-react/vitest.setup.ts @@ -2,6 +2,7 @@ import 'vi-fetch/setup'; import 'reflect-metadata'; import '@testing-library/jest-dom'; // Including this for the expect extensions import 'react-app-polyfill/stable'; +import 'wicg-inert'; import type { ComponentType } from 'react'; const country = { diff --git a/platforms/web/src/App.tsx b/platforms/web/src/App.tsx index 9a351909f..aef55da08 100644 --- a/platforms/web/src/App.tsx +++ b/platforms/web/src/App.tsx @@ -4,6 +4,7 @@ import QueryProvider from '@jwp/ott-ui-react/src/containers/QueryProvider/QueryP import { ErrorPageWithoutTranslation } from '@jwp/ott-ui-react/src/components/ErrorPage/ErrorPage'; import LoadingOverlay from '@jwp/ott-ui-react/src/components/LoadingOverlay/LoadingOverlay'; import { AriaAnnouncerProvider } from '@jwp/ott-ui-react/src/containers/AnnouncementProvider/AnnoucementProvider'; +import ModalProvider from '@jwp/ott-ui-react/src/containers/ModalProvider/ModalProvider'; import initI18n from './i18n/config'; import Root from './containers/Root/Root'; @@ -44,7 +45,9 @@ export default function App() { - + + + diff --git a/yarn.lock b/yarn.lock index e8d77714d..0cf5643b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11447,7 +11447,7 @@ why-is-node-running@^2.2.2: siginfo "^2.0.0" stackback "0.0.2" -wicg-inert@^3.1.1: +wicg-inert@^3.1.1, wicg-inert@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/wicg-inert/-/wicg-inert-3.1.2.tgz#df10cf756b773a96fce107c3ddcd43be5d1e3944" integrity sha512-Ba9tGNYxXwaqKEi9sJJvPMKuo063umUPsHN0JJsjrs2j8KDSzkWLMZGZ+MH1Jf1Fq4OWZ5HsESJID6nRza2ang==