From 3bfbf181275fbcc1bbd5a7f2ff5d24f2afc07604 Mon Sep 17 00:00:00 2001 From: Michael Webber Date: Thu, 20 Aug 2026 13:47:49 -0700 Subject: [PATCH 1/5] feat(unity-react-core): modal backdrop close update and custom button, state, and children props --- .../unity-bootstrap-theme/src/js/modals.js | 81 ++++++- .../src/scss/extends/_modals.scss | 9 + .../ButtonIconOnly/ButtonIconOnly.jsx | 2 + .../src/components/Modal/Modal.stories.tsx | 28 +++ .../src/components/Modal/Modal.tsx | 227 ++++++++++++++---- .../src/core/types/shared-types.js | 1 + 6 files changed, 294 insertions(+), 54 deletions(-) diff --git a/packages/unity-bootstrap-theme/src/js/modals.js b/packages/unity-bootstrap-theme/src/js/modals.js index 622eb6d204..49dc869e03 100644 --- a/packages/unity-bootstrap-theme/src/js/modals.js +++ b/packages/unity-bootstrap-theme/src/js/modals.js @@ -1,22 +1,93 @@ import { EventHandler } from "./bootstrap-helper"; +function openModal() { + document.getElementById("uds-modal")?.classList.add("open"); + document.getElementById("uds-modal-backdrop")?.classList.add("open"); + let closeModalButton = document.getElementById("closeModalButton"); + setTimeout(() => { + if (closeModalButton) { + // Wait for dom to update before setting focus + closeModalButton?.focus(); + } + }, 200); + + // 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]; + const firstFocusableElement = modal?.querySelectorAll(focusableElements)[0]; + const focusableContent = modal?.querySelectorAll(focusableElements); + const lastFocusableElement = focusableContent + ? focusableContent[focusableContent?.length - 1] + : undefined; + + const handleTabKey = e => { + let isTabPressed = e.key === "Tab"; // || e.keyCode === 9; + + if (!isTabPressed) { + return; + } + + if (e.shiftKey) { + // if shift key pressed for shift + tab combination + if (document.activeElement === firstFocusableElement) { + lastFocusableElement?.focus(); // add focus for the last focusable element + e.preventDefault(); + } + } else { + // if tab key is pressed + if (document.activeElement === lastFocusableElement) { + // if focused has reached to last focusable element then focus first focusable element after pressing tab + firstFocusableElement?.focus(); // add focus for the first focusable element + e.preventDefault(); + } + } + }; + + if (lastFocusableElement && firstFocusableElement) { + document.addEventListener("keydown", handleTabKey); + // firstFocusableElement?.focus(); + return () => document.removeEventListener("keydown", handleTabKey); + } +} + +function closeModal() { + document.getElementById("uds-modal").classList.remove("open"); + document.getElementById("uds-modal-backdrop").classList.remove("open"); + + let openModalButton = document.getElementById("openModalButton"); + setTimeout(() => { + if (openModalButton) { + // Wait for dom to update before setting focus + openModalButton?.focus(); + } + }, 200); +} + function initModals() { document .getElementById("openModalButton") ?.addEventListener("click", function () { - document.getElementById("uds-modal")?.classList.add("open"); - document.getElementById("closeModalButton")?.focus(); + openModal(); }); document .getElementById("closeModalButton") ?.addEventListener("click", function () { - document.getElementById("uds-modal").classList.remove("open"); + closeModal(); + }); + + document + .getElementById("uds-modal-backdrop") + ?.addEventListener("click", function () { + closeModal(); }); document?.addEventListener("keydown", function (event) { - event.key === "Escape" && - document.getElementById("uds-modal")?.classList.remove("open"); + if (event.key === "Escape") { + closeModal(); + } }); } diff --git a/packages/unity-bootstrap-theme/src/scss/extends/_modals.scss b/packages/unity-bootstrap-theme/src/scss/extends/_modals.scss index 6bcace67f7..64bb6464f4 100644 --- a/packages/unity-bootstrap-theme/src/scss/extends/_modals.scss +++ b/packages/unity-bootstrap-theme/src/scss/extends/_modals.scss @@ -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..bccf0eca0f 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,134 @@ 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] From 6c2e47581ed2902097bbcbaf26b18e0be53345b9 Mon Sep 17 00:00:00 2001 From: Michael Webber Date: Fri, 21 Aug 2026 12:28:47 -0700 Subject: [PATCH 2/5] feat(unity-bootstrap-theme): focus trap update for unity bootstrap theme --- .../unity-bootstrap-theme/src/js/modals.js | 131 +++++++----------- 1 file changed, 47 insertions(+), 84 deletions(-) diff --git a/packages/unity-bootstrap-theme/src/js/modals.js b/packages/unity-bootstrap-theme/src/js/modals.js index 49dc869e03..3db0ef9d65 100644 --- a/packages/unity-bootstrap-theme/src/js/modals.js +++ b/packages/unity-bootstrap-theme/src/js/modals.js @@ -1,94 +1,57 @@ import { EventHandler } from "./bootstrap-helper"; -function openModal() { - document.getElementById("uds-modal")?.classList.add("open"); - document.getElementById("uds-modal-backdrop")?.classList.add("open"); - let closeModalButton = document.getElementById("closeModalButton"); - setTimeout(() => { - if (closeModalButton) { - // Wait for dom to update before setting focus - closeModalButton?.focus(); - } - }, 200); - - // 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]; - const firstFocusableElement = modal?.querySelectorAll(focusableElements)[0]; - const focusableContent = modal?.querySelectorAll(focusableElements); - const lastFocusableElement = focusableContent - ? focusableContent[focusableContent?.length - 1] - : undefined; - - const handleTabKey = e => { - let isTabPressed = e.key === "Tab"; // || e.keyCode === 9; - - if (!isTabPressed) { - return; - } - - if (e.shiftKey) { - // if shift key pressed for shift + tab combination - if (document.activeElement === firstFocusableElement) { - lastFocusableElement?.focus(); // add focus for the last focusable element - e.preventDefault(); - } - } else { - // if tab key is pressed - if (document.activeElement === lastFocusableElement) { - // if focused has reached to last focusable element then focus first focusable element after pressing tab - firstFocusableElement?.focus(); // add focus for the first focusable element - e.preventDefault(); - } +function initModals() { + const modal = document.getElementById("uds-modal"); + const modalBackdrop = document.getElementById("uds-modal-backdrop"); + const openModalButton = document.getElementById("openModalButton"); + const closeModalButton = document.getElementById("closeModalButton"); + const firstFocusable = modal.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + let previousFocus = null; + function focusTrap(e) { + // If relatedTarget is outside, move focus back inside + if (!modal.contains(e.relatedTarget)) { + firstFocusable?.focus(); } - }; - - if (lastFocusableElement && firstFocusableElement) { - document.addEventListener("keydown", handleTabKey); - // firstFocusableElement?.focus(); - return () => document.removeEventListener("keydown", handleTabKey); } -} -function closeModal() { - document.getElementById("uds-modal").classList.remove("open"); - document.getElementById("uds-modal-backdrop").classList.remove("open"); - - let openModalButton = document.getElementById("openModalButton"); - setTimeout(() => { - if (openModalButton) { - // Wait for dom to update before setting focus - openModalButton?.focus(); - } - }, 200); -} - -function initModals() { - document - .getElementById("openModalButton") - ?.addEventListener("click", function () { - openModal(); - }); - - document - .getElementById("closeModalButton") - ?.addEventListener("click", function () { - closeModal(); - }); - - document - .getElementById("uds-modal-backdrop") - ?.addEventListener("click", function () { - closeModal(); - }); + 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); + } - document?.addEventListener("keydown", function (event) { - if (event.key === "Escape") { - closeModal(); + 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); From 74d68a6fc3b2bf460c0d0977471b05457ec8f74c Mon Sep 17 00:00:00 2001 From: Michael Webber Date: Fri, 21 Aug 2026 12:30:04 -0700 Subject: [PATCH 3/5] feat(unity-react-core): backdrop parent update for isBootstrap condition --- packages/unity-react-core/src/components/Modal/Modal.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/unity-react-core/src/components/Modal/Modal.tsx b/packages/unity-react-core/src/components/Modal/Modal.tsx index bccf0eca0f..3da6a77d1f 100644 --- a/packages/unity-react-core/src/components/Modal/Modal.tsx +++ b/packages/unity-react-core/src/components/Modal/Modal.tsx @@ -196,8 +196,8 @@ export const Modal: React.FC = ({ id="uds-modal-backdrop" onClick={handleClose} className={classNames("uds-modal", { open: getOpenState() })} - > -
+
+ ); } else { From f2ece71d9e0e6bf88ae655eb715dc43158879f69 Mon Sep 17 00:00:00 2001 From: Michael Webber Date: Thu, 27 Aug 2026 11:34:20 -0700 Subject: [PATCH 4/5] fix(unity-bootstrap-theme): fixed initModals() error when modal components aren't on the page --- packages/unity-bootstrap-theme/src/js/modals.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/unity-bootstrap-theme/src/js/modals.js b/packages/unity-bootstrap-theme/src/js/modals.js index 3db0ef9d65..a6048ff60a 100644 --- a/packages/unity-bootstrap-theme/src/js/modals.js +++ b/packages/unity-bootstrap-theme/src/js/modals.js @@ -2,9 +2,13 @@ import { EventHandler } from "./bootstrap-helper"; function initModals() { 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"])' ); From b9a313ec807d3abd1028f9435370463e4e592ea6 Mon Sep 17 00:00:00 2001 From: Michael Webber Date: Thu, 27 Aug 2026 15:54:34 -0700 Subject: [PATCH 5/5] fix(unity-bootstrap-theme): fixed positioning update for UDS-2259 --- packages/unity-bootstrap-theme/src/scss/extends/_modals.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/unity-bootstrap-theme/src/scss/extends/_modals.scss b/packages/unity-bootstrap-theme/src/scss/extends/_modals.scss index 64bb6464f4..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;