diff --git a/packages/unity-bootstrap-theme/src/js/modals.js b/packages/unity-bootstrap-theme/src/js/modals.js index 622eb6d204..a6048ff60a 100644 --- a/packages/unity-bootstrap-theme/src/js/modals.js +++ b/packages/unity-bootstrap-theme/src/js/modals.js @@ -1,23 +1,61 @@ import { EventHandler } from "./bootstrap-helper"; function initModals() { - document - .getElementById("openModalButton") - ?.addEventListener("click", function () { - document.getElementById("uds-modal")?.classList.add("open"); - document.getElementById("closeModalButton")?.focus(); - }); + const modal = document.getElementById("uds-modal"); + if (!modal) return null; + const modalBackdrop = document.getElementById("uds-modal-backdrop"); + if (!modalBackdrop) return null; + const openModalButton = document.getElementById("openModalButton"); + if (!openModalButton) return null; + const closeModalButton = document.getElementById("closeModalButton"); + if (!closeModalButton) return null; + const firstFocusable = modal.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); - document - .getElementById("closeModalButton") - ?.addEventListener("click", function () { - document.getElementById("uds-modal").classList.remove("open"); - }); + let previousFocus = null; + function focusTrap(e) { + // If relatedTarget is outside, move focus back inside + if (!modal.contains(e.relatedTarget)) { + firstFocusable?.focus(); + } + } - document?.addEventListener("keydown", function (event) { - event.key === "Escape" && - document.getElementById("uds-modal")?.classList.remove("open"); - }); + function openModal() { + // When opening: save current focus and move into modal + previousFocus = document.activeElement; + modal.classList.add("open"); + modalBackdrop.classList.add("open"); + // attach event listeners to trap focus and close modal + modalBackdrop.addEventListener("focusout", focusTrap); + modalBackdrop.addEventListener("click", closeModal, true); + document.addEventListener("keydown", closeModal); + + // Focus the first interactive element inside (or body if none) + setTimeout(() => { + firstFocusable?.focus(); + }, 200); + } + + function closeModal({ type, target, key } = {}) { + if ( + // escape key pressed + (type === "keydown" && key === "Escape") || + // click on close button + (type === "click" && target === closeModalButton) || + // click on backdrop + (type === "click" && target === modalBackdrop) + ) { + modal.classList.remove("open"); + modalBackdrop.classList.remove("open"); + modalBackdrop.removeEventListener("focusout", focusTrap); + modalBackdrop.removeEventListener("click", closeModal, true); + document.removeEventListener("keydown", closeModal); + // When closing: restore original focus + previousFocus?.focus(); + } + } + openModalButton.addEventListener("click", openModal); } EventHandler.on(window, "load.uds.modals", initModals); diff --git a/packages/unity-bootstrap-theme/src/scss/extends/_modals.scss b/packages/unity-bootstrap-theme/src/scss/extends/_modals.scss index 6bcace67f7..d949ca3b6a 100644 --- a/packages/unity-bootstrap-theme/src/scss/extends/_modals.scss +++ b/packages/unity-bootstrap-theme/src/scss/extends/_modals.scss @@ -7,7 +7,7 @@ left: 0; opacity: 0; padding: 4rem 2rem; - position: absolute; + position: fixed; right: 0; top: 0; z-index: 1030; @@ -84,3 +84,12 @@ } } } + +.uds-modal-main { + background-color: #0000; + pointer-events: none; +} + +.uds-modal-container { + pointer-events: all; +} diff --git a/packages/unity-react-core/src/components/ButtonIconOnly/ButtonIconOnly.jsx b/packages/unity-react-core/src/components/ButtonIconOnly/ButtonIconOnly.jsx index 86ed0abd8e..6a2a904eae 100644 --- a/packages/unity-react-core/src/components/ButtonIconOnly/ButtonIconOnly.jsx +++ b/packages/unity-react-core/src/components/ButtonIconOnly/ButtonIconOnly.jsx @@ -23,6 +23,7 @@ const gaDefaultObject = { */ export const ButtonIconOnly = ({ color = "gray", + autoFocus = undefined, icon = undefined, innerRef = undefined, onClick = undefined, @@ -46,6 +47,7 @@ export const ButtonIconOnly = ({ }} > + // ), + openModalButtonClassName: "btn-dark", + openModalButtonText: "Show modal", + children: ( + <> +

Content

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod incididuntåç ut labore et dolore magna aliqua eiusmod tempo. +

+ + + ), }, }; +//@ts-ignore const modalTemplate = args => ; export const Overview = { diff --git a/packages/unity-react-core/src/components/Modal/Modal.tsx b/packages/unity-react-core/src/components/Modal/Modal.tsx index 8c945d703d..3da6a77d1f 100644 --- a/packages/unity-react-core/src/components/Modal/Modal.tsx +++ b/packages/unity-react-core/src/components/Modal/Modal.tsx @@ -4,10 +4,6 @@ import { ButtonIconOnly } from "../ButtonIconOnly/ButtonIconOnly"; import { GaEventWrapper } from "../GaEventWrapper/GaEventWrapper"; import { useBaseSpecificFramework } from "../GaEventWrapper/useBaseSpecificFramework"; import classNames from "classnames"; -/** - * - * TODO: Should we be using bootstrap's built in modal functionality? - */ const defaultGaData = { name: "onclick", @@ -20,7 +16,14 @@ const defaultGaData = { }; export interface ModalProps { + /** + * Modal open/closed state + */ open?: boolean; + /** + * React useState custom setter + */ + setOpen?: React.Dispatch> | undefined; gaData?: { name: string; event: string; @@ -30,36 +33,85 @@ export interface ModalProps { section: string; ga: string; }; + /** + * Custom JSX to replace the default open modal button + */ + openModalInput?: JSX.Element; + /** + * Style class for the default open modal button + */ + openModalButtonClassName?: string; + /** + * Display text for the default open modal button + */ + openModalButtonText?: string; + /** + * JSX for the content displayed within the modal + */ + children?: JSX.Element; } -export const Modal: React.FC = ({ open, gaData }) => { +export const Modal: React.FC = ({ + children, + open, + setOpen, + openModalInput, + openModalButtonClassName, + openModalButtonText, + gaData, +}) => { const { isReact, isBootstrap } = useBaseSpecificFramework(); - const [openState, setOpen] = React.useState(open); + const [defaultOpenState, defaultSetOpen] = React.useState(open ?? false); + + const handleSetOpen = (e: boolean) => { + if (setOpen) { + setOpen(e); // custom set open prop + } else { + defaultSetOpen(e); // default set open function + } + }; + + const getOpenState = () => { + if (setOpen) { + return open; // custom open state value + } else { + return defaultOpenState; // default open state value + } + }; const handleOpen = () => { - setOpen(true); + handleSetOpen(true); }; const handleClose = () => { - setOpen(false); + handleSetOpen(false); }; useEffect(() => { const handleKeyDown = (event: any) => { - if (event.key === "Escape") setOpen(false); // Close on Esc key + if (event.key === "Escape") handleSetOpen(false); // Close on Esc key }; - if (openState) { + if (getOpenState()) { document.addEventListener("keydown", handleKeyDown); } return () => document.removeEventListener("keydown", handleKeyDown); - }, [openState, setOpen]); + }, [getOpenState(), handleSetOpen]); useEffect(() => { - if (!openState) return; - - //source: https://stackoverflow.com/questions/4195616/how-to-set-the-focus-on-a-javascript-modal-window + if (!getOpenState()) { + let openModalButton = document.getElementById("openModalButtonR"); + setTimeout(() => { + if (openModalButton) { + // Wait for dom to update before setting focus + openModalButton?.focus(); + } + }, 200); + return; + } + // Disable navigation to everything accept for the modal content + // Source: https://stackoverflow.com/questions/4195616/how-to-set-the-focus-on-a-javascript-modal-window const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; const modal = document.getElementsByClassName("uds-modal-container")[0]; @@ -94,57 +146,135 @@ export const Modal: React.FC = ({ open, gaData }) => { if (lastFocusableElement && firstFocusableElement) { document.addEventListener("keydown", handleTabKey); - (firstFocusableElement as HTMLElement)?.focus(); + setTimeout(() => { + if (firstFocusableElement) { + // Wait for dom to update before setting focus + (firstFocusableElement as HTMLElement)?.focus(); + } + }, 200); return () => document.removeEventListener("keydown", handleTabKey); } - }, [openState]); - - const modalTitle = "Content"; - - return ( -
- - - {(openState || isBootstrap) && ( + }, [getOpenState()]); + + let modalHeaderText = "Modal"; // default aria-label value + + if (children && children.props && children.props.children) { + for (let i = 0; i < children.props.children.length; i++) { + if (children.props.children[i].type === "h1") { + if ( + children.props.children[i].props && + children.props.children[i].props.children && + typeof children.props.children[i].props.children === "string" + ) { + modalHeaderText = children.props.children[i].props.children; + } + } + } + } + + if (isBootstrap) { + return ( +
+ {/* Disable main content on modal open */} +
+ {openModalInput ? ( + openModalInput + ) : ( + + )} +
+ - )} -
- ); +
+
+ ); + } else { + return ( +
+ {/* Disable main content on modal open */} +
+ {openModalInput ? ( + openModalInput + ) : ( + + )} +
+ + {getOpenState() && ( + <> +
+ + + )} +
+ ); + } }; diff --git a/packages/unity-react-core/src/core/types/shared-types.js b/packages/unity-react-core/src/core/types/shared-types.js index 8a31446f6a..b9c02e27e4 100644 --- a/packages/unity-react-core/src/core/types/shared-types.js +++ b/packages/unity-react-core/src/core/types/shared-types.js @@ -24,6 +24,7 @@ * @typedef {Object} ButtonIconOnlyProps * @property {Array.} icon * @property {string} [color] + * @property {boolean} [autoFocus] * @property {React.RefObject} [innerRef] * @property {function():void} [onClick] * @property {"large"|"small"} [size]