Skip to content

Commit

Permalink
fix(Modal): refactor useFocusLock to fix bug with dynamic content ins… (
Browse files Browse the repository at this point in the history
  • Loading branch information
moathabuhamad-cengage authored Sep 30, 2024
1 parent 1a3e7c5 commit fbfd75e
Show file tree
Hide file tree
Showing 4 changed files with 89 additions and 38 deletions.
5 changes: 5 additions & 0 deletions .changeset/fix-modal-focuslock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'react-magma-dom': patch
---

fix(Modal): Fix modal losing focus order with dynamic content.
52 changes: 40 additions & 12 deletions packages/react-magma-dom/src/components/Modal/Modal.stories.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -180,6 +179,11 @@ export const ModalContentUpdate = () => {
const [showHidden, setShowHidden] = React.useState(false);
const [goToNextPageEnabled, setGoToNextPageEnabled] = React.useState(true);
const buttonRef = React.useRef<HTMLButtonElement>();
const [mainHeaderRef, setmainHeaderRef] = React.useState(React.useRef<any>());

const handleGetHeaderRef = ref => {
setmainHeaderRef(ref);
};

const onModalShow = () => {
setShowModal(true);
Expand All @@ -191,10 +195,12 @@ export const ModalContentUpdate = () => {
};

const goToPage1 = () => {
mainHeaderRef?.current?.focus();
setPage(1);
};

const goToPage2 = () => {
mainHeaderRef?.current?.focus();
setPage(2);
};

Expand All @@ -208,7 +214,7 @@ export const ModalContentUpdate = () => {

return (
<>
<Modal header="Modal Title" onClose={onModalClose} isOpen={showModal}>
<Modal header="Modal Title" onClose={onModalClose} isOpen={showModal} headerRef={handleGetHeaderRef}>
<div id="attachToMe">
{page === 1 && (
<>
Expand Down Expand Up @@ -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<any>());

const buttonRef = React.useRef<HTMLButtonElement>();

const handleGetHeaderRef = ref => {
setmainHeaderRef(ref);
};

const closeModal2 = () => {
mainHeaderRef?.current?.focus();
setShowModal2(false);
};

return (
<>
<Modal
Expand All @@ -308,6 +325,7 @@ export const ModalInAModal = () => {
buttonRef.current.focus();
}}
isOpen={showModal}
headerRef={handleGetHeaderRef}
>
<Paragraph noTopMargin>This is a modal, doing modal things.</Paragraph>
<Paragraph>
Expand Down Expand Up @@ -354,7 +372,7 @@ export const ModalInAModal = () => {
<Modal
size={ModalSize.small}
header="Modal 2 Title"
onClose={() => setShowModal2(false)}
onClose={() => closeModal2()}
isOpen={showModal2}
>
<Paragraph noTopMargin>This is modal 2</Paragraph>
Expand Down Expand Up @@ -440,19 +458,27 @@ export const CloseModalWithConfirmation = () => {
const [showModal, setShowModal] = React.useState(false);
const [showConfirmationModal, setShowConfirmationModal] =
React.useState(false);
const buttonRef = React.useRef<HTMLButtonElement>();
const focusTrapElement = useFocusLock(!showConfirmationModal && showModal);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const mainModalRef = React.useRef<HTMLDivElement>(null);
const confirmationModalRef = React.useRef<HTMLDivElement>(null);
const [mainHeaderRef, setmainHeaderRef] = React.useState(React.useRef<any>());

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);
};
Expand All @@ -467,7 +493,8 @@ export const CloseModalWithConfirmation = () => {
isModalClosingControlledManually
onClose={closeTheModal}
isOpen={showModal}
ref={focusTrapElement}
ref={mainModalRef}
headerRef={handleGetHeaderRef}
>
<Paragraph noTopMargin>This is a modal, doing modal things.</Paragraph>
<Paragraph>
Expand All @@ -491,6 +518,7 @@ export const CloseModalWithConfirmation = () => {
isModalClosingControlledManually
onClose={closeTheConfirmationModal}
isOpen={showConfirmationModal}
ref={confirmationModalRef}
>
<Paragraph noTopMargin>Close the modal?</Paragraph>
<ButtonGroup>
Expand Down
37 changes: 18 additions & 19 deletions packages/react-magma-dom/src/hooks/useFocusLock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,25 @@ export function useFocusLock(
const focusableItems = React.useRef<Array<HTMLElement>>([]);

const updateFocusableItems = () => {
focusableItems.current = rootNode.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]), video'
) as unknown as Array<HTMLElement>;
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) {
Expand All @@ -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) => {
Expand Down Expand Up @@ -102,7 +101,7 @@ export function useFocusLock(
return () => {
window.removeEventListener('keydown', handleKeyPress);
};
}, [active, focusableItems]);
}, [active, focusableItems, header]);

return rootNode;
}
33 changes: 26 additions & 7 deletions website/react-magma-docs/src/pages/api/modal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>());

const handleGetHeaderRef = ref => {
setmainHeaderRef(ref);
};

const closeModal2 = () => {
mainHeaderRef.current.focus();
setShowModal2(false);
};

return (
<>
Expand All @@ -413,7 +423,7 @@ export function Example() {
<Modal
size={ModalSize.small}
header="Modal 2 Title"
onClose={() => setShowModal2(false)}
onClose={() => closeModal2()}
isOpen={showModal2}
>
<Paragraph noMargins>This is modal 2</Paragraph>
Expand All @@ -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<HTMLButtonElement>();
const focusTrapElement = useFocusLock(!showConfirmationModal && showModal);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const mainModalRef = React.useRef<HTMLDivElement>(null);
const confirmationModalRef = React.useRef<HTMLDivElement>(null);
const [mainHeaderRef, setmainHeaderRef] = React.useState(React.useRef<any>());

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);
};
Expand All @@ -469,7 +486,8 @@ export function Example() {
isModalClosingControlledManually
onClose={closeTheModal}
isOpen={showModal}
ref={focusTrapElement}
ref={mainModalRef}
headerRef={handleGetHeaderRef}
>
<Paragraph noTopMargin>This is a modal, doing modal things.</Paragraph>
<Paragraph>
Expand All @@ -493,6 +511,7 @@ export function Example() {
isModalClosingControlledManually
onClose={closeTheConfirmationModal}
isOpen={showConfirmationModal}
ref={confirmationModalRef}
>
<Paragraph noTopMargin>Close the modal?</Paragraph>
<ButtonGroup>
Expand Down

2 comments on commit fbfd75e

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.