From fbfd75e8b7155346c6169728c287c4ce0551eeaf Mon Sep 17 00:00:00 2001 From: moathabuhamad-cengage Date: Mon, 30 Sep 2024 15:43:43 +0200 Subject: [PATCH] =?UTF-8?q?fix(Modal):=20refactor=20useFocusLock=20to=20fi?= =?UTF-8?q?x=20bug=20with=20dynamic=20content=20ins=E2=80=A6=20(#1435)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/fix-modal-focuslock.md | 5 ++ .../src/components/Modal/Modal.stories.tsx | 52 ++++++++++++++----- .../react-magma-dom/src/hooks/useFocusLock.ts | 37 +++++++------ .../react-magma-docs/src/pages/api/modal.mdx | 33 +++++++++--- 4 files changed, 89 insertions(+), 38 deletions(-) create mode 100644 .changeset/fix-modal-focuslock.md diff --git a/.changeset/fix-modal-focuslock.md b/.changeset/fix-modal-focuslock.md new file mode 100644 index 0000000000..bf6e3da23c --- /dev/null +++ b/.changeset/fix-modal-focuslock.md @@ -0,0 +1,5 @@ +--- +'react-magma-dom': patch +--- + +fix(Modal): Fix modal losing focus order with dynamic content. diff --git a/packages/react-magma-dom/src/components/Modal/Modal.stories.tsx b/packages/react-magma-dom/src/components/Modal/Modal.stories.tsx index 6ce616981f..fe5b1cb588 100644 --- a/packages/react-magma-dom/src/components/Modal/Modal.stories.tsx +++ b/packages/react-magma-dom/src/components/Modal/Modal.stories.tsx @@ -1,22 +1,21 @@ import React from 'react'; +import { Modal, ModalSize, NativeSelect, Toggle, VisuallyHidden } from '../..'; import { Button, ButtonColor } from '../Button'; import { ButtonGroup, ButtonGroupAlignment } from '../ButtonGroup'; import { Combobox } from '../Combobox'; import { Container } from '../Container'; import { DatePicker } from '../DatePicker'; -import { Modal, ModalSize, NativeSelect, Toggle, VisuallyHidden } from '../..'; -import { Paragraph } from '../Paragraph'; -import { Radio } from '../Radio'; -import { RadioGroup } from '../RadioGroup'; -import { Spacer } from '../Spacer'; import { Dropdown, DropdownButton, DropdownContent, DropdownMenuItem, } from '../Dropdown'; +import { Paragraph } from '../Paragraph'; +import { Radio } from '../Radio'; +import { RadioGroup } from '../RadioGroup'; import { Select } from '../Select'; -import { useFocusLock } from '../..'; +import { Spacer } from '../Spacer'; const info = { component: Modal, @@ -180,6 +179,11 @@ export const ModalContentUpdate = () => { const [showHidden, setShowHidden] = React.useState(false); const [goToNextPageEnabled, setGoToNextPageEnabled] = React.useState(true); const buttonRef = React.useRef(); + const [mainHeaderRef, setmainHeaderRef] = React.useState(React.useRef()); + + const handleGetHeaderRef = ref => { + setmainHeaderRef(ref); + }; const onModalShow = () => { setShowModal(true); @@ -191,10 +195,12 @@ export const ModalContentUpdate = () => { }; const goToPage1 = () => { + mainHeaderRef?.current?.focus(); setPage(1); }; const goToPage2 = () => { + mainHeaderRef?.current?.focus(); setPage(2); }; @@ -208,7 +214,7 @@ export const ModalContentUpdate = () => { return ( <> - +
{page === 1 && ( <> @@ -297,8 +303,19 @@ export const NoHeaderOrFocusableContent = () => { export const ModalInAModal = () => { const [showModal, setShowModal] = React.useState(false); const [showModal2, setShowModal2] = React.useState(false); + const [mainHeaderRef, setmainHeaderRef] = React.useState(React.useRef()); + const buttonRef = React.useRef(); + const handleGetHeaderRef = ref => { + setmainHeaderRef(ref); + }; + + const closeModal2 = () => { + mainHeaderRef?.current?.focus(); + setShowModal2(false); + }; + return ( <> { buttonRef.current.focus(); }} isOpen={showModal} + headerRef={handleGetHeaderRef} > This is a modal, doing modal things. @@ -354,7 +372,7 @@ export const ModalInAModal = () => { setShowModal2(false)} + onClose={() => closeModal2()} isOpen={showModal2} > This is modal 2 @@ -440,19 +458,27 @@ export const CloseModalWithConfirmation = () => { const [showModal, setShowModal] = React.useState(false); const [showConfirmationModal, setShowConfirmationModal] = React.useState(false); - const buttonRef = React.useRef(); - const focusTrapElement = useFocusLock(!showConfirmationModal && showModal); + const buttonRef = React.useRef(null); + const mainModalRef = React.useRef(null); + const confirmationModalRef = React.useRef(null); + const [mainHeaderRef, setmainHeaderRef] = React.useState(React.useRef()); + + const handleGetHeaderRef = ref => { + setmainHeaderRef(ref); + }; const closeTheModal = () => { setShowConfirmationModal(true); + confirmationModalRef.current?.focus(); }; const closeTheConfirmationModal = () => { + mainHeaderRef?.current?.focus(); setShowConfirmationModal(false); }; const closeBothModals = () => { - buttonRef.current.focus(); + buttonRef.current?.focus(); setShowConfirmationModal(false); setShowModal(false); }; @@ -467,7 +493,8 @@ export const CloseModalWithConfirmation = () => { isModalClosingControlledManually onClose={closeTheModal} isOpen={showModal} - ref={focusTrapElement} + ref={mainModalRef} + headerRef={handleGetHeaderRef} > This is a modal, doing modal things. @@ -491,6 +518,7 @@ export const CloseModalWithConfirmation = () => { isModalClosingControlledManually onClose={closeTheConfirmationModal} isOpen={showConfirmationModal} + ref={confirmationModalRef} > Close the modal? diff --git a/packages/react-magma-dom/src/hooks/useFocusLock.ts b/packages/react-magma-dom/src/hooks/useFocusLock.ts index ec845bb767..4169afe256 100644 --- a/packages/react-magma-dom/src/hooks/useFocusLock.ts +++ b/packages/react-magma-dom/src/hooks/useFocusLock.ts @@ -13,15 +13,25 @@ export function useFocusLock( const focusableItems = React.useRef>([]); const updateFocusableItems = () => { - focusableItems.current = rootNode.current?.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]), video' - ) as unknown as Array; + focusableItems.current = Array.from( + rootNode.current?.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]), video' + ) || [] + ); }; React.useEffect(() => { if (active) { updateFocusableItems(); + const observer: MutationObserver = new MutationObserver(() => { + updateFocusableItems(); + }); + + if (rootNode.current) { + observer.observe(rootNode.current, { childList: true, subtree: true }); + } + if (header && header.current) { header.current.focus(); } else if (focusableItems.current && focusableItems.current.length > 0) { @@ -32,22 +42,11 @@ export function useFocusLock( (body.current.firstChild as HTMLElement).setAttribute('tabIndex', '-1'); (body.current.firstChild as HTMLElement).focus(); } + return () => { + observer.disconnect(); + }; } - }, [active]); - - React.useEffect(() => { - const observer: MutationObserver = new MutationObserver(() => { - updateFocusableItems(); - }); - - updateFocusableItems(); - - rootNode.current && - observer.observe(rootNode.current, { childList: true, subtree: true }); - return () => { - observer.disconnect(); - }; - }, [rootNode]); + }, [active, header, body]); React.useEffect(() => { const handleKeyPress = (event: KeyboardEvent) => { @@ -102,7 +101,7 @@ export function useFocusLock( return () => { window.removeEventListener('keydown', handleKeyPress); }; - }, [active, focusableItems]); + }, [active, focusableItems, header]); return rootNode; } diff --git a/website/react-magma-docs/src/pages/api/modal.mdx b/website/react-magma-docs/src/pages/api/modal.mdx index 574ae35612..e6d66ffce5 100644 --- a/website/react-magma-docs/src/pages/api/modal.mdx +++ b/website/react-magma-docs/src/pages/api/modal.mdx @@ -392,6 +392,16 @@ import { export function Example() { const [showModal, setShowModal] = React.useState(false); const [showModal2, setShowModal2] = React.useState(false); + const [mainHeaderRef, setmainHeaderRef] = React.useState(React.useRef()); + + const handleGetHeaderRef = ref => { + setmainHeaderRef(ref); + }; + + const closeModal2 = () => { + mainHeaderRef.current.focus(); + setShowModal2(false); + }; return ( <> @@ -413,7 +423,7 @@ export function Example() { setShowModal2(false)} + onClose={() => closeModal2()} isOpen={showModal2} > This is modal 2 @@ -435,26 +445,33 @@ import { Combobox, Modal, ModalSize, - Paragraph, - useFocusLock, + Paragraph } from 'react-magma-dom'; export function Example() { const [showModal, setShowModal] = React.useState(false); const [showConfirmationModal, setShowConfirmationModal] = React.useState(false); - const buttonRef = React.useRef(); - const focusTrapElement = useFocusLock(!showConfirmationModal && showModal); + const buttonRef = React.useRef(null); + const mainModalRef = React.useRef(null); + const confirmationModalRef = React.useRef(null); + const [mainHeaderRef, setmainHeaderRef] = React.useState(React.useRef()); + + const handleGetHeaderRef = ref => { + setmainHeaderRef(ref); + }; const closeTheModal = () => { setShowConfirmationModal(true); + confirmationModalRef.current && confirmationModalRef.current.focus(); }; const closeTheConfirmationModal = () => { + mainHeaderRef.current && mainHeaderRef.current.focus(); setShowConfirmationModal(false); }; const closeBothModals = () => { - buttonRef.current.focus(); + buttonRef.current && buttonRef.current.focus(); setShowConfirmationModal(false); setShowModal(false); }; @@ -469,7 +486,8 @@ export function Example() { isModalClosingControlledManually onClose={closeTheModal} isOpen={showModal} - ref={focusTrapElement} + ref={mainModalRef} + headerRef={handleGetHeaderRef} > This is a modal, doing modal things. @@ -493,6 +511,7 @@ export function Example() { isModalClosingControlledManually onClose={closeTheConfirmationModal} isOpen={showConfirmationModal} + ref={confirmationModalRef} > Close the modal?