From db84f6f8f223d9f22d2f5ca3927b8b4b35364611 Mon Sep 17 00:00:00 2001 From: Stephen Watkins Date: Mon, 29 Jun 2026 17:50:10 +0200 Subject: [PATCH 1/4] initial commit --- easy-ui-react/package.json | 1 + easy-ui-react/src/Modal/Modal.test.tsx | 9 + easy-ui-react/src/Modal/Modal.tsx | 6 + .../src/Modal/ThirdPartyOverlayBoundary.tsx | 27 +++ easy-ui-react/src/Modal/topLayer.test.ts | 81 ++++++++ easy-ui-react/src/Modal/topLayer.ts | 61 ++++++ .../src/Modal/useThirdPartyOverlays.test.tsx | 68 +++++++ .../src/Modal/useThirdPartyOverlays.ts | 184 ++++++++++++++++++ package-lock.json | 86 +++++++- 9 files changed, 513 insertions(+), 10 deletions(-) create mode 100644 easy-ui-react/src/Modal/ThirdPartyOverlayBoundary.tsx create mode 100644 easy-ui-react/src/Modal/topLayer.test.ts create mode 100644 easy-ui-react/src/Modal/topLayer.ts create mode 100644 easy-ui-react/src/Modal/useThirdPartyOverlays.test.tsx create mode 100644 easy-ui-react/src/Modal/useThirdPartyOverlays.ts diff --git a/easy-ui-react/package.json b/easy-ui-react/package.json index 21d852fe..f036c22e 100644 --- a/easy-ui-react/package.json +++ b/easy-ui-react/package.json @@ -35,6 +35,7 @@ "@react-aria/utils": "^3.32.0", "@react-stately/toast": "^3.1.2", "@react-types/shared": "^3.32.1", + "@testing-library/react-hooks": "^8.0.1", "lodash": "^4.17.21", "overlayscrollbars": "^2.3.0", "overlayscrollbars-react": "^0.5.6", diff --git a/easy-ui-react/src/Modal/Modal.test.tsx b/easy-ui-react/src/Modal/Modal.test.tsx index e11a12ba..9c385ced 100644 --- a/easy-ui-react/src/Modal/Modal.test.tsx +++ b/easy-ui-react/src/Modal/Modal.test.tsx @@ -212,6 +212,15 @@ describe("", () => { renderModalWithOutsideContent({ allowsThirdPartyOverlays: true }); expect(isElementHidden(screen.getByTestId("outside-content"))).toBe(false); }); + + it("should render Modal.ThirdPartyOverlayBoundary's children", () => { + render( + + card form + , + ); + expect(screen.getByText("card form")).toBeInTheDocument(); + }); }); const CustomSymbol = (props: object) => ; diff --git a/easy-ui-react/src/Modal/Modal.tsx b/easy-ui-react/src/Modal/Modal.tsx index 643dfd44..ce349613 100644 --- a/easy-ui-react/src/Modal/Modal.tsx +++ b/easy-ui-react/src/Modal/Modal.tsx @@ -9,6 +9,7 @@ import { ModalContext } from "./context"; import { useIntersectionDetection } from "./useIntersectionDetection"; import { ModalContainer } from "./ModalContainer"; import { useModalTrigger } from "./context"; +import { ThirdPartyOverlayBoundary } from "./ThirdPartyOverlayBoundary"; import styles from "./Modal.module.scss"; @@ -121,4 +122,9 @@ Modal.Body = ModalBody; */ Modal.Footer = ModalFooter; +/** + * Represents a boundary for third-party overlays within a ``. + */ +Modal.ThirdPartyOverlayBoundary = ThirdPartyOverlayBoundary; + export { ModalContainer, useModalTrigger }; diff --git a/easy-ui-react/src/Modal/ThirdPartyOverlayBoundary.tsx b/easy-ui-react/src/Modal/ThirdPartyOverlayBoundary.tsx new file mode 100644 index 00000000..1ebcd918 --- /dev/null +++ b/easy-ui-react/src/Modal/ThirdPartyOverlayBoundary.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import { + UseThirdPartyOverlaysOptions, + useThirdPartyOverlays, +} from "./useThirdPartyOverlays"; + +export type ThirdPartyOverlayBoundaryProps = { + /** The `Modal` (or any subtree) hosting the third-party widget. */ + children: React.ReactNode; +} & Omit; + +/** + * Declarative wrapper around {@link useThirdPartyOverlays}. Wrap a `Modal` with + * it so a widget's body-level overlays stay interactive. Because `Modal` portals + * its content out of this subtree, the boundary doesn't rely on a ref — the hook + * tells inline widgets (inside the dialog) apart from escaped overlays + * automatically, so it works wherever it sits relative to the modal. + */ +export const ThirdPartyOverlayBoundary = ({ + children, + selector, + filter, + minVisibleSizePx, +}: ThirdPartyOverlayBoundaryProps) => { + useThirdPartyOverlays({ selector, filter, minVisibleSizePx }); + return <>{children}; +}; diff --git a/easy-ui-react/src/Modal/topLayer.test.ts b/easy-ui-react/src/Modal/topLayer.test.ts new file mode 100644 index 00000000..7bf5477c --- /dev/null +++ b/easy-ui-react/src/Modal/topLayer.test.ts @@ -0,0 +1,81 @@ +import { + TOP_LAYER_ATTR, + bodyLevelAncestor, + markElementAsTopLayer, + unmarkElementAsTopLayer, +} from "./topLayer"; + +describe("topLayer", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + describe("markElementAsTopLayer", () => { + test("tags the node and reports a change", () => { + const el = document.createElement("div"); + expect(markElementAsTopLayer(el)).toBe(true); + expect(el.getAttribute(TOP_LAYER_ATTR)).toBe("true"); + }); + + test("clears react-aria's aria-hidden and inert", () => { + const el = document.createElement("div"); + el.setAttribute("aria-hidden", "true"); + el.inert = true; + + expect(markElementAsTopLayer(el)).toBe(true); + expect(el.hasAttribute("aria-hidden")).toBe(false); + expect(el.inert).toBe(false); + }); + + test("is a no-op once the node is already exempt", () => { + const el = document.createElement("div"); + markElementAsTopLayer(el); + // a second pass must report no change so observers don't loop + expect(markElementAsTopLayer(el)).toBe(false); + }); + }); + + describe("unmarkElementAsTopLayer", () => { + test("removes the tag and reports a change", () => { + const el = document.createElement("div"); + markElementAsTopLayer(el); + + expect(unmarkElementAsTopLayer(el)).toBe(true); + expect(el.hasAttribute(TOP_LAYER_ATTR)).toBe(false); + }); + + test("is a no-op on an untagged node", () => { + const el = document.createElement("div"); + expect(unmarkElementAsTopLayer(el)).toBe(false); + }); + + test("does not re-add inert / aria-hidden (react-aria owns those)", () => { + const el = document.createElement("div"); + markElementAsTopLayer(el); + unmarkElementAsTopLayer(el); + + expect(el.hasAttribute("aria-hidden")).toBe(false); + expect(el.inert).toBeFalsy(); + }); + }); + + describe("bodyLevelAncestor", () => { + test("returns the direct child of containing the element", () => { + const container = document.createElement("div"); + const middle = document.createElement("div"); + const leaf = document.createElement("span"); + middle.appendChild(leaf); + container.appendChild(middle); + document.body.appendChild(container); + + expect(bodyLevelAncestor(leaf)).toBe(container); + }); + + test("returns the element itself when it is already a body child", () => { + const el = document.createElement("div"); + document.body.appendChild(el); + + expect(bodyLevelAncestor(el)).toBe(el); + }); + }); +}); diff --git a/easy-ui-react/src/Modal/topLayer.ts b/easy-ui-react/src/Modal/topLayer.ts new file mode 100644 index 00000000..8eb22d5d --- /dev/null +++ b/easy-ui-react/src/Modal/topLayer.ts @@ -0,0 +1,61 @@ +/** + * Top-layer DOM primitives + * ------------------------ + * Low-level helpers for exempting a body-level node from react-aria's modal + * hiding/containment via the `data-react-aria-top-layer` opt-out. See the module + * overview in `index.ts` for the why. + */ + +export const TOP_LAYER_ATTR = "data-react-aria-top-layer"; + +/** + * Exempt a node from react-aria's modal hiding/containment. Returns whether + * anything actually changed — the no-op-when-clean result lets observers + * re-apply it on every mutation without looping. + */ +export const markElementAsTopLayer = (node: HTMLElement): boolean => { + let changed = false; + if (node.getAttribute(TOP_LAYER_ATTR) !== "true") { + node.setAttribute(TOP_LAYER_ATTR, "true"); + changed = true; + } + if (node.hasAttribute("aria-hidden")) { + node.removeAttribute("aria-hidden"); + changed = true; + } + // react-aria sets `inert` as a property (not only an attribute) when + // `shouldUseInert` is on, so release the property too. + if (node.inert) { + node.inert = false; + changed = true; + } + return changed; +}; + +/** + * Revert {@link markElementAsTopLayer}. Returns whether anything changed. + * + * We only strip the top-layer tag — we deliberately do NOT re-add `inert` / + * `aria-hidden`. This runs as the modal unmounts, when the whole page is + * becoming interactive again; react-aria owns those attributes and clears them + * on its own teardown. The tag, however, is ours, and if it lingers on a + * body-level node after the modal is gone, react-aria's `isElementInChildScope` + * keeps treating that subtree as part of a (now-destroyed) focus scope — which + * derails focus restore on close and corrupts the next modal that opens. + */ +export const unmarkElementAsTopLayer = (node: HTMLElement): boolean => { + if (node.getAttribute(TOP_LAYER_ATTR) === "true") { + node.removeAttribute(TOP_LAYER_ATTR); + return true; + } + return false; +}; + +/** Climb to the element that is a direct child of ``. */ +export const bodyLevelAncestor = (el: HTMLElement): HTMLElement => { + let node = el; + while (node.parentElement && node.parentElement !== document.body) { + node = node.parentElement; + } + return node; +}; diff --git a/easy-ui-react/src/Modal/useThirdPartyOverlays.test.tsx b/easy-ui-react/src/Modal/useThirdPartyOverlays.test.tsx new file mode 100644 index 00000000..b7937e3d --- /dev/null +++ b/easy-ui-react/src/Modal/useThirdPartyOverlays.test.tsx @@ -0,0 +1,68 @@ +import { renderHook } from "@testing-library/react-hooks"; +import { TOP_LAYER_ATTR } from "./topLayer"; +import { useThirdPartyOverlays } from "./useThirdPartyOverlays"; + +const STRIPE_SELECTOR = 'iframe[name^="__privateStripe"]'; + +describe("useThirdPartyOverlays lifecycle", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + const mountStripeOverlay = () => { + const container = document.createElement("div"); + const iframe = document.createElement("iframe"); + iframe.setAttribute("name", "__privateStripeFrame123"); + container.appendChild(iframe); + document.body.appendChild(container); + return { container, iframe }; + }; + + test("tags an escaped overlay on mount and reverts it on unmount", () => { + const { container } = mountStripeOverlay(); + + const { unmount } = renderHook(() => + useThirdPartyOverlays({ selector: STRIPE_SELECTOR }), + ); + + // the body-level ancestor of the Stripe iframe is exempted while mounted + expect(container.getAttribute(TOP_LAYER_ATTR)).toBe("true"); + + unmount(); + + // and the tag must not linger once the modal is gone + expect(container.hasAttribute(TOP_LAYER_ATTR)).toBe(false); + }); + + test("blurs focus stuck inside a tagged overlay on unmount", () => { + const { container } = mountStripeOverlay(); + const input = document.createElement("input"); + container.appendChild(input); + + const { unmount } = renderHook(() => + useThirdPartyOverlays({ selector: STRIPE_SELECTOR }), + ); + + input.focus(); + expect(document.activeElement).toBe(input); + + unmount(); + + // focus is released to so react-aria can restore it cleanly + expect(document.activeElement).not.toBe(input); + }); + + test("leaves overlays inside a dialog alone", () => { + const dialog = document.createElement("div"); + dialog.setAttribute("role", "dialog"); + const iframe = document.createElement("iframe"); + iframe.setAttribute("name", "__privateStripeFrame123"); + dialog.appendChild(iframe); + document.body.appendChild(dialog); + + renderHook(() => useThirdPartyOverlays({ selector: STRIPE_SELECTOR })); + + // widgets inside the dialog are already interactive; nothing to tag + expect(dialog.hasAttribute(TOP_LAYER_ATTR)).toBe(false); + }); +}); diff --git a/easy-ui-react/src/Modal/useThirdPartyOverlays.ts b/easy-ui-react/src/Modal/useThirdPartyOverlays.ts new file mode 100644 index 00000000..e8cd570c --- /dev/null +++ b/easy-ui-react/src/Modal/useThirdPartyOverlays.ts @@ -0,0 +1,184 @@ +import * as React from "react"; +import { + bodyLevelAncestor, + markElementAsTopLayer, + unmarkElementAsTopLayer, +} from "./topLayer"; + +type IgnoreRefs = React.RefObject | React.RefObject[]; + +export type UseThirdPartyOverlaysOptions = { + /** + * CSS selector matching the overlay node(s) the widget appends to + * `document.body` (e.g. Stripe iframes, reCAPTCHA challenge iframes). + */ + selector: string; + /** + * Optional ref(s) whose subtrees should be left untouched. Nodes inside a + * `[role="dialog"]` are already excluded automatically, so this is only needed + * for extra precision (e.g. an inline widget rendered outside any dialog). + */ + ignoreWithin?: IgnoreRefs; + /** + * Optional extra predicate applied to each matched element, for cases a CSS + * selector can't express (size, attributes, etc.). + */ + filter?: (element: HTMLElement) => boolean; + /** + * Minimum width/height (px) a matched node (or its body-level container) must + * have to count as a visible/open overlay for `isOverlayOpen`. Defaults to + * 100, which filters out the 0x0 controller iframes some widgets keep mounted. + */ + minVisibleSizePx?: number; +}; + +const DEFAULT_MIN_VISIBLE_SIZE_PX = 100; + +// A matched node inside a dialog is already interactive — only nodes that +// escaped the modal into `document.body` need rescuing. This lets the hook work +// without a ref even when the modal's content is portalled away. +const DIALOG_SELECTOR = '[role="dialog"], [role="alertdialog"]'; + +const toArray = (refs?: IgnoreRefs): React.RefObject[] => { + if (!refs) { + return []; + } + return Array.isArray(refs) ? refs : [refs]; +}; + +const hasSize = (el: HTMLElement, min: number) => { + const rect = el.getBoundingClientRect(); + return rect.width >= min && rect.height >= min; +}; + +/** + * Watches `document.body` for third-party overlay nodes matching `selector` and + * tags them so they escape an Easy UI / react-aria modal's focus trap, `inert`, + * and `aria-hidden`. The modal stays mounted — nothing is closed or re-opened. + * + * @returns `isOverlayOpen` — true while a visibly-sized matched overlay exists, + * for callers that want to react (e.g. dim the modal). Tagging happens + * regardless of this value. + */ +export const useThirdPartyOverlays = ({ + selector, + ignoreWithin, + filter, + minVisibleSizePx = DEFAULT_MIN_VISIBLE_SIZE_PX, +}: UseThirdPartyOverlaysOptions) => { + const [isOverlayOpen, setIsOverlayOpen] = React.useState(false); + + // Read the latest non-primitive options at scan time so the observer doesn't + // need to re-subscribe when callers pass inline refs/arrays/closures. + const optionsRef = React.useRef({ ignoreWithin, filter }); + optionsRef.current = { ignoreWithin, filter }; + + // Every body-level node we've tagged, so we can revert them when the modal + // unmounts. Without this, stale `data-react-aria-top-layer` nodes linger in + // the body and react-aria keeps treating them as part of the destroyed focus + // scope — which is what locked the page up when the modal closed. + const taggedNodesRef = React.useRef>(new Set()); + + React.useEffect(() => { + if (typeof document === "undefined") { + return undefined; + } + + // Capture the (stable) Set so the cleanup closure references the same + // instance the effect used, not whatever the ref points to at teardown. + const taggedNodes = taggedNodesRef.current; + + const scan = () => { + const ignoreRefs = toArray(optionsRef.current.ignoreWithin); + const { filter: filterFn } = optionsRef.current; + const matches = Array.from( + document.body.querySelectorAll(selector), + ); + + const containers = new Set(); + let overlayVisible = false; + + matches.forEach((element) => { + // widgets rendered inside the dialog are already interactive; only + // overlays that escaped into the body need rescuing + if (element.closest(DIALOG_SELECTOR)) { + return; + } + // leave inline widgets that live inside an ignored subtree alone + if (ignoreRefs.some((ref) => ref.current?.contains(element))) { + return; + } + if (filterFn && !filterFn(element)) { + return; + } + const container = bodyLevelAncestor(element); + // never tag a subtree we were told to ignore (e.g. the modal's portal) + if ( + ignoreRefs.some( + (ref) => ref.current && container.contains(ref.current), + ) + ) { + return; + } + containers.add(container); + if ( + hasSize(element, minVisibleSizePx) || + hasSize(container, minVisibleSizePx) + ) { + overlayVisible = true; + } + }); + + containers.forEach((container) => { + markElementAsTopLayer(container); + taggedNodes.add(container); + }); + setIsOverlayOpen(overlayVisible); + }; + + scan(); + + // Coalesce mutation bursts into a single scan per frame. react-aria toggles + // `inert`/`aria-hidden` across the whole page when a modal opens/closes, so + // we deliberately do NOT observe attributes (that storm caused a hang) — we + // only watch childList. react-aria re-applies `inert` from its own childList + // observer, so a node addition is the only event we need to react to. + let frame = 0; + const schedule = () => { + if (frame) { + return; + } + frame = window.requestAnimationFrame(() => { + frame = 0; + scan(); + }); + }; + + const observer = new MutationObserver(schedule); + observer.observe(document.body, { childList: true, subtree: true }); + + return () => { + if (frame) { + window.cancelAnimationFrame(frame); + } + observer.disconnect(); + + // Revert every node we tagged. If focus is currently inside one (e.g. the + // user closed the modal while a Stripe Link/OTP iframe was focused), blur + // it first: leaving focus in a top-layer subtree as the scope unmounts is + // exactly what sends react-aria's focus restore into a loop. Blurring + // drops focus to , from which react-aria restores to the trigger + // cleanly. + const active = document.activeElement; + taggedNodes.forEach((node) => { + if (active instanceof HTMLElement && node.contains(active)) { + active.blur(); + } + unmarkElementAsTopLayer(node); + }); + taggedNodes.clear(); + }; + }, [selector, minVisibleSizePx]); + + return { isOverlayOpen }; +}; diff --git a/package-lock.json b/package-lock.json index 8f345e36..47fc1835 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,7 @@ }, "easy-ui-icons": { "name": "@easypost/easy-ui-icons", - "version": "1.0.0-alpha.54", + "version": "1.0.0-alpha.55", "devDependencies": { "@material-symbols/svg-300": "^0.40.2", "@material-symbols/svg-400": "^0.40.2", @@ -127,14 +127,15 @@ }, "easy-ui-react": { "name": "@easypost/easy-ui", - "version": "1.0.0-alpha.117", + "version": "1.0.0-alpha.121", "dependencies": { - "@easypost/easy-ui-icons": "1.0.0-alpha.54", + "@easypost/easy-ui-icons": "1.0.0-alpha.55", "@easypost/easy-ui-tokens": "1.0.0-alpha.17", "@react-aria/toast": "^3.0.9", "@react-aria/utils": "^3.32.0", "@react-stately/toast": "^3.1.2", "@react-types/shared": "^3.32.1", + "@testing-library/react-hooks": "^8.0.1", "lodash": "^4.17.21", "overlayscrollbars": "^2.3.0", "overlayscrollbars-react": "^0.5.6", @@ -681,10 +682,11 @@ } }, "node_modules/@bundled-es-modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-Rk453EklPUPC3NRWc3VUNI/SSUjdBaFoaQvFRmNBNtMHVtOFD5AntiWg5kEE1hqcPqedYFDzxE3ZcMYPcA195w==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/deepmerge/-/deepmerge-4.3.2.tgz", + "integrity": "sha512-q8doe7ndrY2IolUOFIn0A0++JBX38pMhN7kFhTF4cnjIcILf6X6H2yWczInyv8ZFdR0lrE8088X8XS5efxXz8A==", "dev": true, + "license": "MIT", "dependencies": { "deepmerge": "^4.3.1" } @@ -5839,6 +5841,36 @@ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true }, + "node_modules/@testing-library/react-hooks": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz", + "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "react-error-boundary": "^3.1.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "@types/react": "^16.9.0 || ^17.0.0", + "react": "^16.9.0 || ^17.0.0", + "react-dom": "^16.9.0 || ^17.0.0", + "react-test-renderer": "^16.9.0 || ^17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-test-renderer": { + "optional": true + } + } + }, "node_modules/@testing-library/user-event": { "version": "14.6.1", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", @@ -11683,6 +11715,22 @@ "react": "^19.2.4" } }, + "node_modules/react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -14662,9 +14710,9 @@ } }, "@bundled-es-modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-Rk453EklPUPC3NRWc3VUNI/SSUjdBaFoaQvFRmNBNtMHVtOFD5AntiWg5kEE1hqcPqedYFDzxE3ZcMYPcA195w==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/deepmerge/-/deepmerge-4.3.2.tgz", + "integrity": "sha512-q8doe7ndrY2IolUOFIn0A0++JBX38pMhN7kFhTF4cnjIcILf6X6H2yWczInyv8ZFdR0lrE8088X8XS5efxXz8A==", "dev": true, "requires": { "deepmerge": "^4.3.1" @@ -15306,7 +15354,7 @@ "@easypost/easy-ui": { "version": "file:easy-ui-react", "requires": { - "@easypost/easy-ui-icons": "1.0.0-alpha.54", + "@easypost/easy-ui-icons": "1.0.0-alpha.55", "@easypost/easy-ui-tokens": "1.0.0-alpha.17", "@react-aria/toast": "^3.0.9", "@react-aria/utils": "^3.32.0", @@ -15315,6 +15363,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.2", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.6.1", "@types/lodash": "^4.17.18", "@types/react": "^19.2.14", @@ -18196,6 +18245,15 @@ } } }, + "@testing-library/react-hooks": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz", + "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", + "requires": { + "@babel/runtime": "^7.12.5", + "react-error-boundary": "^3.1.0" + } + }, "@testing-library/user-event": { "version": "14.6.1", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", @@ -22083,6 +22141,14 @@ "scheduler": "^0.27.0" } }, + "react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "requires": { + "@babel/runtime": "^7.12.5" + } + }, "react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", From fde365150ba109aa32dd9012ddaa91e432709a41 Mon Sep 17 00:00:00 2001 From: Stephen Watkins Date: Mon, 29 Jun 2026 18:01:36 +0200 Subject: [PATCH 2/4] remove old prop --- .changeset/stripe-third-party-overlays.md | 2 +- easy-ui-react/package.json | 1 - easy-ui-react/src/Modal/Modal.mdx | 20 +-- easy-ui-react/src/Modal/Modal.test.tsx | 38 ------ easy-ui-react/src/Modal/ModalContainer.tsx | 22 +--- easy-ui-react/src/Modal/ModalTrigger.tsx | 11 -- easy-ui-react/src/Modal/ModalUnderlay.tsx | 115 ++---------------- .../src/Modal/useThirdPartyOverlays.test.tsx | 2 +- 8 files changed, 23 insertions(+), 188 deletions(-) diff --git a/.changeset/stripe-third-party-overlays.md b/.changeset/stripe-third-party-overlays.md index 929ba392..5becaf78 100644 --- a/.changeset/stripe-third-party-overlays.md +++ b/.changeset/stripe-third-party-overlays.md @@ -2,4 +2,4 @@ "@easypost/easy-ui": minor --- -Add `allowsThirdPartyOverlays` to `Modal.Trigger` and `ModalContainer`. When enabled, the modal stops trapping focus and stops applying `aria-hidden`/`inert` to the rest of the page, so third-party overlays that render outside the modal (e.g. Stripe Link/autofill, reCAPTCHA) remain focusable and clickable instead of locking up. +Add `Modal.ThirdPartyOverlayBoundary` for hosting third-party overlays that render outside the modal (e.g. Stripe Link/autofill, reCAPTCHA). Wrap a `Modal` with it and pass a `selector` matching the overlay nodes the widget appends to `document.body`; the boundary exempts those nodes from the modal's focus trap and `aria-hidden`/`inert` so they stay focusable and clickable instead of locking up. The modal stays mounted and keeps its focus containment for everything else. diff --git a/easy-ui-react/package.json b/easy-ui-react/package.json index f036c22e..21d852fe 100644 --- a/easy-ui-react/package.json +++ b/easy-ui-react/package.json @@ -35,7 +35,6 @@ "@react-aria/utils": "^3.32.0", "@react-stately/toast": "^3.1.2", "@react-types/shared": "^3.32.1", - "@testing-library/react-hooks": "^8.0.1", "lodash": "^4.17.21", "overlayscrollbars": "^2.3.0", "overlayscrollbars-react": "^0.5.6", diff --git a/easy-ui-react/src/Modal/Modal.mdx b/easy-ui-react/src/Modal/Modal.mdx index dc88af15..616765b0 100644 --- a/easy-ui-react/src/Modal/Modal.mdx +++ b/easy-ui-react/src/Modal/Modal.mdx @@ -119,18 +119,20 @@ A `` accepts a single `` as a child. If no child is p By default, a `` traps focus and hides the rest of the page from assistive technologies (via `aria-hidden`/`inert`) while it's open. This is the correct behavior for most modals, but it breaks third-party widgets that inject their own overlays into the document _outside_ the modal—e.g. Stripe Link/autofill or reCAPTCHA. Those overlays get `inert`'d (making them unclickable) and lose focus back to the modal. -Set `allowsThirdPartyOverlays` on `` or `` to opt out of focus trapping and background aria-hiding so those overlays stay usable. Closing on interaction outside, scroll locking, and restoring focus to the trigger on close are all preserved. +Wrap the `` in a `` and pass a `selector` matching the nodes the widget appends to `document.body`. The boundary exempts those nodes from the modal's focus trap and background hiding so they stay usable, while the modal stays mounted and keeps its focus containment for everything else. Closing on interaction outside, scroll locking, and restoring focus to the trigger on close are all preserved. -Use this sparingly. It trades away the modal's focus containment and background hiding, so reach for it only when a modal must host a third-party overlay that requires it. +Use this sparingly. Reach for it only when a modal must host a third-party overlay that requires it. ```tsx - - - - New Credit Card - {/* Stripe CardElement, etc. */} - - + + + + + New Credit Card + {/* Stripe CardElement, etc. */} + + + ``` ## Properties diff --git a/easy-ui-react/src/Modal/Modal.test.tsx b/easy-ui-react/src/Modal/Modal.test.tsx index 9c385ced..78567e15 100644 --- a/easy-ui-react/src/Modal/Modal.test.tsx +++ b/easy-ui-react/src/Modal/Modal.test.tsx @@ -203,16 +203,6 @@ describe("", () => { expect(handleSecondaryAction).toBeCalled(); }); - it("should hide outside content while open by default", () => { - renderModalWithOutsideContent(); - expect(isElementHidden(screen.getByTestId("outside-content"))).toBe(true); - }); - - it("should not hide outside content when allowsThirdPartyOverlays is set", () => { - renderModalWithOutsideContent({ allowsThirdPartyOverlays: true }); - expect(isElementHidden(screen.getByTestId("outside-content"))).toBe(false); - }); - it("should render Modal.ThirdPartyOverlayBoundary's children", () => { render( @@ -289,31 +279,3 @@ async function renderAndOpenModal(args = {}) { expect(screen.queryByRole("dialog")).toBeInTheDocument(); return response; } - -// react-aria's `ariaHideOutside` hides outside content via `inert` (or -// `aria-hidden` where `inert` is unsupported), applied to an ancestor. -function isElementHidden(element: Element) { - return Boolean( - element.closest("[inert]") || element.closest('[aria-hidden="true"]'), - ); -} - -function renderModalWithOutsideContent({ - allowsThirdPartyOverlays, -}: Partial = {}) { - return render( - <> -
Outside content
- - - - Header - Content - - - , - ); -} diff --git a/easy-ui-react/src/Modal/ModalContainer.tsx b/easy-ui-react/src/Modal/ModalContainer.tsx index 502a417f..741c4032 100644 --- a/easy-ui-react/src/Modal/ModalContainer.tsx +++ b/easy-ui-react/src/Modal/ModalContainer.tsx @@ -15,15 +15,6 @@ type ModalContainerProps = { */ isDismissable?: boolean; - /** - * Disables focus trapping and background aria-hiding so the modal can host - * third-party overlays (e.g. Stripe Link/autofill, reCAPTCHA) that render - * outside the modal. Use sparingly — see `ModalUnderlay` for the tradeoffs. - * - * @default false - */ - allowsThirdPartyOverlays?: boolean; - /** * Handler that is called when the overlay is closed. */ @@ -39,12 +30,7 @@ type ModalContainerProps = { * element or when the trigger unmounts while the modal is open. */ export function ModalContainer(props: ModalContainerProps) { - const { - children, - isDismissable = true, - allowsThirdPartyOverlays = false, - onDismiss = () => {}, - } = props; + const { children, isDismissable = true, onDismiss = () => {} } = props; const childArray = React.Children.toArray(children); if (childArray.length > 1) { @@ -79,11 +65,7 @@ export function ModalContainer(props: ModalContainerProps) { return ( {state.isOpen && ( - + {lastChild ? cloneElement(lastChild, overlayProps) : null} )} diff --git a/easy-ui-react/src/Modal/ModalTrigger.tsx b/easy-ui-react/src/Modal/ModalTrigger.tsx index d3256abd..d603584f 100644 --- a/easy-ui-react/src/Modal/ModalTrigger.tsx +++ b/easy-ui-react/src/Modal/ModalTrigger.tsx @@ -19,20 +19,9 @@ export type ModalTriggerProps = { /** * Whether or not the modal can be dismissed. - * - * @default true */ isDismissable?: boolean; - /** - * Disables focus trapping and background aria-hiding so the modal can host - * third-party overlays (e.g. Stripe Link/autofill, reCAPTCHA) that render - * outside the modal. Use sparingly — see `ModalUnderlay` for the tradeoffs. - * - * @default false - */ - allowsThirdPartyOverlays?: boolean; - /** * Whether the modal is open by default (controlled). */ diff --git a/easy-ui-react/src/Modal/ModalUnderlay.tsx b/easy-ui-react/src/Modal/ModalUnderlay.tsx index 912b7dfe..ce65c4c8 100644 --- a/easy-ui-react/src/Modal/ModalUnderlay.tsx +++ b/easy-ui-react/src/Modal/ModalUnderlay.tsx @@ -1,11 +1,5 @@ -import React, { ReactNode, RefObject, useRef } from "react"; -import { - Overlay, - useModalOverlay, - useOverlay, - usePreventScroll, -} from "react-aria"; -import { DOMAttributes } from "@react-types/shared"; +import React, { ReactNode } from "react"; +import { Overlay, useModalOverlay } from "react-aria"; import { OverlayTriggerState } from "react-stately"; import { classNames } from "../utilities/css"; import { useModalTriggerContext } from "./context"; @@ -27,115 +21,22 @@ type ModalUnderlayProps = { * Whether or not the modal is dismissable. */ isDismissable?: boolean; - - /** - * When `true`, the modal stops trapping focus and stops hiding the rest of - * the page from assistive technologies (`aria-hidden`/`inert`). Use this only - * for modals that intentionally host third-party overlays — e.g. Stripe - * Link/autofill or reCAPTCHA — which render themselves into the document - * *outside* the modal. react-aria's focus trap and `inert` would otherwise - * blur and lock up those overlays. This trades away the modal's focus - * containment and background hiding, so reach for it only when a third-party - * overlay requires it. - * - * @default false - */ - allowsThirdPartyOverlays?: boolean; -}; - -type ModalUnderlayContentProps = { - modalProps: DOMAttributes; - underlayProps: DOMAttributes; - modalRef: RefObject; - children: ReactNode; }; export function ModalUnderlay(props: ModalUnderlayProps) { - // Branch into sibling components so each calls its hooks unconditionally. - return props.allowsThirdPartyOverlays ? ( - - ) : ( - - ); -} - -/** - * Standard modal behavior: traps focus and hides the rest of the page via - * react-aria's `useModalOverlay` (which applies `aria-hidden`/`inert` outside - * the modal). - */ -function FocusTrappingUnderlay(props: ModalUnderlayProps) { - const { state, children, isDismissable = true } = props; + const { state, children, ...overlayProps } = props; + const { isDismissable = true } = overlayProps; - const ref = useRef(null); + const ref = React.useRef(null); const { modalProps, underlayProps } = useModalOverlay( { - isDismissable, + ...overlayProps, + isDismissable: isDismissable, isKeyboardDismissDisabled: !isDismissable, }, state, ref, ); - - return ( - - {children} - - ); -} - -/** - * Like `FocusTrappingUnderlay`, but built from the lower-level overlay hooks so - * it can omit the two behaviors that fight third-party overlays: - * - no `ariaHideOutside`, so overlays injected outside the modal aren't - * `inert`'d (which would make them unclickable), and - * - no forced focus containment, so focus can't be stolen back from them. - * - * `Overlay` still restores focus to the trigger when the modal closes. - * close-on-interact-outside is preserved; react-aria already ignores clicks on - * `[data-react-aria-top-layer]` elements, so a properly tagged third-party - * overlay won't dismiss the modal. - */ -function ThirdPartyOverlayUnderlay(props: ModalUnderlayProps) { - const { state, children, isDismissable = true } = props; - - const ref = useRef(null); - const { overlayProps, underlayProps } = useOverlay( - { - isOpen: state.isOpen, - onClose: state.close, - isDismissable, - isKeyboardDismissDisabled: !isDismissable, - }, - ref, - ); - usePreventScroll({ isDisabled: !state.isOpen }); - - return ( - - {children} - - ); -} - -/** - * Shared underlay markup for both underlay variants. The variants differ only - * in which react-aria hooks produce `modalProps`/`underlayProps`. - */ -function ModalUnderlayContent({ - modalProps, - underlayProps, - modalRef, - children, -}: ModalUnderlayContentProps) { const { hasOpenNestedModal } = useModalTriggerContext(); const className = classNames( @@ -146,7 +47,7 @@ function ModalUnderlayContent({ return (
-
+
{children}
diff --git a/easy-ui-react/src/Modal/useThirdPartyOverlays.test.tsx b/easy-ui-react/src/Modal/useThirdPartyOverlays.test.tsx index b7937e3d..9f63c2a7 100644 --- a/easy-ui-react/src/Modal/useThirdPartyOverlays.test.tsx +++ b/easy-ui-react/src/Modal/useThirdPartyOverlays.test.tsx @@ -1,4 +1,4 @@ -import { renderHook } from "@testing-library/react-hooks"; +import { renderHook } from "@testing-library/react"; import { TOP_LAYER_ATTR } from "./topLayer"; import { useThirdPartyOverlays } from "./useThirdPartyOverlays"; From 7eff3b21c44b666f74dbb8f0f34d513e65a8cc82 Mon Sep 17 00:00:00 2001 From: Stephen Watkins Date: Mon, 29 Jun 2026 18:54:24 +0200 Subject: [PATCH 3/4] support wrapping --- easy-ui-react/src/Modal/Modal.mdx | 14 ++++---- easy-ui-react/src/Modal/Modal.test.tsx | 33 +++++++++++++++++++ .../src/Modal/ThirdPartyOverlayBoundary.tsx | 14 ++++++++ 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/easy-ui-react/src/Modal/Modal.mdx b/easy-ui-react/src/Modal/Modal.mdx index 616765b0..06a30074 100644 --- a/easy-ui-react/src/Modal/Modal.mdx +++ b/easy-ui-react/src/Modal/Modal.mdx @@ -121,18 +121,20 @@ By default, a `` traps focus and hides the rest of the page from assist Wrap the `` in a `` and pass a `selector` matching the nodes the widget appends to `document.body`. The boundary exempts those nodes from the modal's focus trap and background hiding so they stay usable, while the modal stays mounted and keeps its focus containment for everything else. Closing on interaction outside, scroll locking, and restoring focus to the trigger on close are all preserved. -Use this sparingly. Reach for it only when a modal must host a third-party overlay that requires it. +The boundary doesn't rely on a ref or its position in the tree—it watches `document.body` for matching nodes directly—so you can wrap the `` itself or wrap the surrounding `` / ``. Wrapping the `` directly keeps the boundary scoped to that one modal. + +Use this sparingly. Reach for it only when a modal must host a third-party overlay that requires it, and give each boundary a selector specific to the widget that modal hosts. ```tsx - - - + + + New Credit Card {/* Stripe CardElement, etc. */} - - + + ``` ## Properties diff --git a/easy-ui-react/src/Modal/Modal.test.tsx b/easy-ui-react/src/Modal/Modal.test.tsx index 78567e15..0c57bf83 100644 --- a/easy-ui-react/src/Modal/Modal.test.tsx +++ b/easy-ui-react/src/Modal/Modal.test.tsx @@ -211,6 +211,39 @@ describe("", () => { ); expect(screen.getByText("card form")).toBeInTheDocument(); }); + + it("should render a Modal wrapped directly by Modal.ThirdPartyOverlayBoundary", () => { + render( + + + + + Header + Direct wrap content + + + , + ); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Direct wrap content")).toBeInTheDocument(); + }); + + it("should forward parent-injected props onto a directly-wrapped child", () => { + // Mirrors how Modal.Trigger / ModalContainer clone their modal child to + // inject overlay props: the boundary must pass them through transparently. + const element = ( + +
+ + ); + render( + React.cloneElement( + element as React.ReactElement>, + { id: "injected" }, + ), + ); + expect(screen.getByTestId("wrapped")).toHaveAttribute("id", "injected"); + }); }); const CustomSymbol = (props: object) => ; diff --git a/easy-ui-react/src/Modal/ThirdPartyOverlayBoundary.tsx b/easy-ui-react/src/Modal/ThirdPartyOverlayBoundary.tsx index 1ebcd918..ff69b7de 100644 --- a/easy-ui-react/src/Modal/ThirdPartyOverlayBoundary.tsx +++ b/easy-ui-react/src/Modal/ThirdPartyOverlayBoundary.tsx @@ -15,13 +15,27 @@ export type ThirdPartyOverlayBoundaryProps = { * its content out of this subtree, the boundary doesn't rely on a ref — the hook * tells inline widgets (inside the dialog) apart from escaped overlays * automatically, so it works wherever it sits relative to the modal. + * + * It can wrap a `Modal` directly (as the modal child of `Modal.Trigger` / + * `ModalContainer`) or wrap the trigger/container itself — both work. */ export const ThirdPartyOverlayBoundary = ({ children, selector, filter, minVisibleSizePx, + ...injectedProps }: ThirdPartyOverlayBoundaryProps) => { useThirdPartyOverlays({ selector, filter, minVisibleSizePx }); + + // `Modal.Trigger` and `ModalContainer` clone their modal child to inject + // overlay props onto it. When the boundary *is* that child — i.e. it wraps a + // `Modal` directly — forward those injected props to the wrapped element so + // the boundary stays transparent and the `Modal` receives exactly what it + // would have without the wrapper. + if (React.isValidElement(children) && Object.keys(injectedProps).length > 0) { + return React.cloneElement(children, injectedProps); + } + return <>{children}; }; From 5607e4da047fcdeff1fed7c76fb015140b06f406 Mon Sep 17 00:00:00 2001 From: Stephen Watkins Date: Mon, 29 Jun 2026 19:26:41 +0200 Subject: [PATCH 4/4] guard multiple boundaries --- easy-ui-react/src/Modal/topLayer.test.ts | 35 +++++++++++++++ easy-ui-react/src/Modal/topLayer.ts | 43 +++++++++++++++++++ .../src/Modal/useThirdPartyOverlays.test.tsx | 22 ++++++++++ .../src/Modal/useThirdPartyOverlays.ts | 38 +++++++++++----- 4 files changed, 127 insertions(+), 11 deletions(-) diff --git a/easy-ui-react/src/Modal/topLayer.test.ts b/easy-ui-react/src/Modal/topLayer.test.ts index 7bf5477c..e6cbbc3d 100644 --- a/easy-ui-react/src/Modal/topLayer.test.ts +++ b/easy-ui-react/src/Modal/topLayer.test.ts @@ -1,7 +1,10 @@ import { TOP_LAYER_ATTR, bodyLevelAncestor, + claimTopLayer, markElementAsTopLayer, + releaseTopLayer, + topLayerClaimCount, unmarkElementAsTopLayer, } from "./topLayer"; @@ -59,6 +62,38 @@ describe("topLayer", () => { }); }); + describe("claimTopLayer / releaseTopLayer", () => { + test("tags on the first claim", () => { + const el = document.createElement("div"); + claimTopLayer(el); + expect(el.getAttribute(TOP_LAYER_ATTR)).toBe("true"); + expect(topLayerClaimCount(el)).toBe(1); + }); + + test("reverts only when the last claimant releases", () => { + const el = document.createElement("div"); + claimTopLayer(el); + claimTopLayer(el); + expect(topLayerClaimCount(el)).toBe(2); + + // first release: another claimant remains, so the tag stays + expect(releaseTopLayer(el)).toBe(false); + expect(el.getAttribute(TOP_LAYER_ATTR)).toBe("true"); + expect(topLayerClaimCount(el)).toBe(1); + + // last release: now it reverts + expect(releaseTopLayer(el)).toBe(true); + expect(el.hasAttribute(TOP_LAYER_ATTR)).toBe(false); + expect(topLayerClaimCount(el)).toBe(0); + }); + + test("releasing an unclaimed node is a no-op", () => { + const el = document.createElement("div"); + expect(releaseTopLayer(el)).toBe(false); + expect(topLayerClaimCount(el)).toBe(0); + }); + }); + describe("bodyLevelAncestor", () => { test("returns the direct child of containing the element", () => { const container = document.createElement("div"); diff --git a/easy-ui-react/src/Modal/topLayer.ts b/easy-ui-react/src/Modal/topLayer.ts index 8eb22d5d..7ded44d3 100644 --- a/easy-ui-react/src/Modal/topLayer.ts +++ b/easy-ui-react/src/Modal/topLayer.ts @@ -51,6 +51,49 @@ export const unmarkElementAsTopLayer = (node: HTMLElement): boolean => { return false; }; +/** + * Refcounted ownership of the top-layer tag. + * + * The overlay scan is page-wide (`document.body.querySelectorAll`), so two + * boundaries with the same selector both match — and both tag — the same + * body-level node. Each tracks the node in its own set, so without coordination + * the first one to unmount would strip a tag the other still needs, letting + * react-aria re-`inert` the node and re-lock the overlay. Refcounting fixes + * that: the tag is applied on the first claim and reverted only when the last + * claimant releases. A `WeakMap` keeps no detached nodes alive — entries vanish + * with the node. + */ +const topLayerClaims = new WeakMap(); + +/** + * Claim the top-layer exemption for a node, applying the tag on the first claim. + * Re-asserts the tag on every claim (react-aria may have re-applied `inert` + * since); returns whether the tag actually changed. + */ +export const claimTopLayer = (node: HTMLElement): boolean => { + topLayerClaims.set(node, (topLayerClaims.get(node) ?? 0) + 1); + return markElementAsTopLayer(node); +}; + +/** + * Release one claim on a node. Reverts the node (and returns `true`) only when + * the last claimant releases; while other claimants remain, the tag is left in + * place and this returns `false`. + */ +export const releaseTopLayer = (node: HTMLElement): boolean => { + const count = topLayerClaims.get(node) ?? 0; + if (count <= 1) { + topLayerClaims.delete(node); + return unmarkElementAsTopLayer(node); + } + topLayerClaims.set(node, count - 1); + return false; +}; + +/** Current number of live claims on a node (`0` if none). */ +export const topLayerClaimCount = (node: HTMLElement): number => + topLayerClaims.get(node) ?? 0; + /** Climb to the element that is a direct child of ``. */ export const bodyLevelAncestor = (el: HTMLElement): HTMLElement => { let node = el; diff --git a/easy-ui-react/src/Modal/useThirdPartyOverlays.test.tsx b/easy-ui-react/src/Modal/useThirdPartyOverlays.test.tsx index 9f63c2a7..a9883c34 100644 --- a/easy-ui-react/src/Modal/useThirdPartyOverlays.test.tsx +++ b/easy-ui-react/src/Modal/useThirdPartyOverlays.test.tsx @@ -52,6 +52,28 @@ describe("useThirdPartyOverlays lifecycle", () => { expect(document.activeElement).not.toBe(input); }); + test("keeps a shared overlay tagged until the last boundary releases it", () => { + const { container } = mountStripeOverlay(); + + // two boundaries with the same selector both match the same escaped node + const a = renderHook(() => + useThirdPartyOverlays({ selector: STRIPE_SELECTOR }), + ); + const b = renderHook(() => + useThirdPartyOverlays({ selector: STRIPE_SELECTOR }), + ); + + expect(container.getAttribute(TOP_LAYER_ATTR)).toBe("true"); + + // one unmounts — the other still needs the node exempted, so the tag stays + a.unmount(); + expect(container.getAttribute(TOP_LAYER_ATTR)).toBe("true"); + + // the last one unmounts — only now is the tag reverted + b.unmount(); + expect(container.hasAttribute(TOP_LAYER_ATTR)).toBe(false); + }); + test("leaves overlays inside a dialog alone", () => { const dialog = document.createElement("div"); dialog.setAttribute("role", "dialog"); diff --git a/easy-ui-react/src/Modal/useThirdPartyOverlays.ts b/easy-ui-react/src/Modal/useThirdPartyOverlays.ts index e8cd570c..5d99ec2d 100644 --- a/easy-ui-react/src/Modal/useThirdPartyOverlays.ts +++ b/easy-ui-react/src/Modal/useThirdPartyOverlays.ts @@ -1,8 +1,10 @@ import * as React from "react"; import { bodyLevelAncestor, + claimTopLayer, markElementAsTopLayer, - unmarkElementAsTopLayer, + releaseTopLayer, + topLayerClaimCount, } from "./topLayer"; type IgnoreRefs = React.RefObject | React.RefObject[]; @@ -130,8 +132,14 @@ export const useThirdPartyOverlays = ({ }); containers.forEach((container) => { - markElementAsTopLayer(container); - taggedNodes.add(container); + if (taggedNodes.has(container)) { + // already our claim — re-assert the tag in case react-aria re-applied + // inert/aria-hidden since the last scan + markElementAsTopLayer(container); + } else { + taggedNodes.add(container); + claimTopLayer(container); + } }); setIsOverlayOpen(overlayVisible); }; @@ -163,18 +171,26 @@ export const useThirdPartyOverlays = ({ } observer.disconnect(); - // Revert every node we tagged. If focus is currently inside one (e.g. the - // user closed the modal while a Stripe Link/OTP iframe was focused), blur - // it first: leaving focus in a top-layer subtree as the scope unmounts is - // exactly what sends react-aria's focus restore into a loop. Blurring - // drops focus to , from which react-aria restores to the trigger - // cleanly. + // Release every node we claimed. Refcounting means a node shared with + // another mounted boundary keeps its tag until that boundary releases too + // — only the final claimant actually reverts it. If focus is inside a node + // we're truly reverting (e.g. the user closed the modal while a Stripe + // Link/OTP iframe was focused), blur it first: leaving focus in a + // top-layer subtree as the scope unmounts is exactly what sends + // react-aria's focus restore into a loop. Blurring drops focus to , + // from which react-aria restores to the trigger cleanly. We skip the blur + // while another boundary still claims the node, since it stays live. const active = document.activeElement; taggedNodes.forEach((node) => { - if (active instanceof HTMLElement && node.contains(active)) { + const isLastClaim = topLayerClaimCount(node) <= 1; + if ( + isLastClaim && + active instanceof HTMLElement && + node.contains(active) + ) { active.blur(); } - unmarkElementAsTopLayer(node); + releaseTopLayer(node); }); taggedNodes.clear(); };