From e5b8e805324ff35cdbb0a2eca40cb228dbf7218c Mon Sep 17 00:00:00 2001 From: Stephen Watkins Date: Mon, 22 Jun 2026 17:38:23 +0200 Subject: [PATCH 01/10] wip --- easy-ui-react/src/Modal/Modal.stories.tsx | 245 +++++++++++++++++++++- easy-ui-react/src/Modal/ModalTrigger.tsx | 2 + 2 files changed, 246 insertions(+), 1 deletion(-) diff --git a/easy-ui-react/src/Modal/Modal.stories.tsx b/easy-ui-react/src/Modal/Modal.stories.tsx index a58c08fc..90f9ea45 100644 --- a/easy-ui-react/src/Modal/Modal.stories.tsx +++ b/easy-ui-react/src/Modal/Modal.stories.tsx @@ -1,6 +1,7 @@ import { action } from "storybook/actions"; import { Meta, StoryObj } from "@storybook/react-vite"; -import React, { Key, useState } from "react"; +import React, { Key, useRef, useState } from "react"; +import ReactDOM from "react-dom"; import { Button } from "../Button"; import { EasyPostLogo, @@ -377,6 +378,248 @@ export const WithFooterSlot: ModalStory = { ), }; +export const WithThirdPartyModal: ModalStory = { + render: () => ( + + + + Easy UI Modal + + + Use the button below to open a third-party modal on top of this one. + Then try tabbing through its form fields to observe how focus + trapping behaves when a non–Easy UI modal is layered on top. + + + + + + + ), + parameters: { + docs: { + description: { + story: + "Demonstrates a third-party modal (a native `` opened via " + + "`showModal()`, which manages its own top-layer and focus) rendered " + + "on top of an Easy UI Modal. Useful for testing how Easy UI's focus " + + "trapping interacts with a third-party modal layered above it.", + }, + }, + }, +}; + +/** + * A stand-in for a third-party modal that is not built with Easy UI. It uses + * the native `` element's `showModal()`, which renders into the + * browser's top-layer and applies its own focus trapping — mirroring how many + * third-party libraries behave when they open on top of our Modal. + */ +function ThirdPartyModal() { + const dialogRef = useRef(null); + return ( + <> + + +
+

+ Third-party modal +

+ + +
+ + +
+
+
+ + ); +} + +export const WithCustomThirdPartyModal: ModalStory = { + render: () => ( + + + + Easy UI Modal + + + Use the button below to open a custom (non-native) third-party modal + on top of this one. Then try tabbing through its form fields, or + clicking into an input, to observe whether the underlying Easy UI + modal steals focus back. + + + + + + + ), + parameters: { + docs: { + description: { + story: + "Demonstrates a custom third-party modal — a conventional " + + "`position: fixed` overlay portaled to `document.body` with its own " + + "JS focus management, rather than the native `` top-layer. " + + "Because its focusable elements live outside Easy UI's `FocusScope`, " + + "this reproduces the reported bug where the underlying Easy UI modal " + + "steals focus back from the third-party modal's inputs.", + }, + }, + }, +}; + +/** + * A stand-in for a third-party modal that does NOT use the native `` + * element. Instead it follows the conventional pattern many libraries use: a + * `position: fixed` overlay rendered through a React portal into + * `document.body`, with focus moved to the dialog on open via a ref. + * + * Because the overlay is portaled outside the Easy UI Modal's DOM subtree, its + * focusable elements fall outside react-aria's `FocusScope`. This is the case + * that can surface the focus-stealing bug, where the underlying Easy UI modal's + * focus containment pulls focus back out of this modal. + */ +function CustomThirdPartyModal() { + const [isOpen, setIsOpen] = useState(false); + const headingRef = useRef(null); + + React.useEffect(() => { + if (isOpen) { + headingRef.current?.focus(); + } + }, [isOpen]); + + return ( + <> + + {isOpen && + ReactDOM.createPortal( +
setIsOpen(false)} + > +
e.stopPropagation()} + style={{ + background: "#fff", + border: "1px solid #ccc", + borderRadius: 8, + padding: 24, + minWidth: 320, + }} + > +
{ + e.preventDefault(); + action("Custom third-party form submitted!")(); + setIsOpen(false); + }} + style={{ display: "flex", flexDirection: "column", gap: 12 }} + > +

+ Custom third-party modal +

+ + +
+ + +
+
+
+
, + document.body, + )} + + ); +} + function ManageAccountModel({ title }: { title: string }) { const modalTriggerState = useModalTrigger(); return ( diff --git a/easy-ui-react/src/Modal/ModalTrigger.tsx b/easy-ui-react/src/Modal/ModalTrigger.tsx index d603584f..3fc47fb3 100644 --- a/easy-ui-react/src/Modal/ModalTrigger.tsx +++ b/easy-ui-react/src/Modal/ModalTrigger.tsx @@ -19,6 +19,8 @@ export type ModalTriggerProps = { /** * Whether or not the modal can be dismissed. + * + * @default true */ isDismissable?: boolean; From 93edbd9643f00176e14b996f4297482d14ee1e7d Mon Sep 17 00:00:00 2001 From: Stephen Watkins Date: Tue, 23 Jun 2026 12:40:22 +0200 Subject: [PATCH 02/10] wip --- easy-ui-react/package.json | 2 + easy-ui-react/src/Modal/Modal.stories.tsx | 409 +++++++++++++++++++++- package-lock.json | 65 +++- 3 files changed, 464 insertions(+), 12 deletions(-) diff --git a/easy-ui-react/package.json b/easy-ui-react/package.json index 85bdea9b..49fff815 100644 --- a/easy-ui-react/package.json +++ b/easy-ui-react/package.json @@ -51,6 +51,8 @@ "react-is": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "devDependencies": { + "@stripe/react-stripe-js": "^6.6.0", + "@stripe/stripe-js": "^9.8.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.2", diff --git a/easy-ui-react/src/Modal/Modal.stories.tsx b/easy-ui-react/src/Modal/Modal.stories.tsx index 90f9ea45..5fedd137 100644 --- a/easy-ui-react/src/Modal/Modal.stories.tsx +++ b/easy-ui-react/src/Modal/Modal.stories.tsx @@ -1,7 +1,9 @@ import { action } from "storybook/actions"; import { Meta, StoryObj } from "@storybook/react-vite"; -import React, { Key, useRef, useState } from "react"; +import React, { Key, useMemo, useRef, useState } from "react"; import ReactDOM from "react-dom"; +import { CardElement, Elements } from "@stripe/react-stripe-js"; +import { loadStripe } from "@stripe/stripe-js"; import { Button } from "../Button"; import { EasyPostLogo, @@ -510,6 +512,255 @@ export const WithCustomThirdPartyModal: ModalStory = { }, }; +export const WithCustomThirdPartyModalFixed: ModalStory = { + render: () => ( + + + + Easy UI Modal + + + Same custom (non-native) third-party modal as the previous story, + but its overlay is marked with `data-react-aria-top-layer`. Tabbing + and clicking into its inputs now works — Easy UI no longer steals + focus back. + + + + + + + ), + parameters: { + docs: { + description: { + story: + "The fix for the previous story. The custom overlay is marked with " + + "`data-react-aria-top-layer`, the escape hatch react-aria's " + + "`FocusScope` checks to always allow focus to move into an element. " + + "With it, the underlying Easy UI Modal's focus containment leaves the " + + "third-party modal's inputs alone.", + }, + }, + }, +}; + +export const WithIframeThirdPartyModal: ModalStory = { + render: () => ( + + + + Easy UI Modal + + + Faithful repro of the real Stripe Link/autofill bug. This overlay is + a cross-origin iframe portaled to `document.body` while the Easy UI + modal is open. Opening it triggers Easy UI's `ariaHideOutside` + (from `useModalOverlay`), which sets `inert` on everything outside + the modal — including this overlay. `inert` blurs the iframe, and + the modal's focus containment then pulls focus back. Watch the + status line under the button: it reports `inert=true` and the + iframe's field can't be used. This is the actual + mechanism; the FocusScope `data-react-aria-top-layer` check is a red + herring. + + + + + + + ), + parameters: { + docs: { + description: { + story: + "Faithful repro of the real cause: Easy UI's `ariaHideOutside` (via " + + "`useModalOverlay`) sets `inert` on third-party overlays injected into " + + "`document.body` after the modal opens. `inert` blurs the iframe, then " + + "focus containment restores focus to the Easy UI modal. The status " + + "line surfaces the live `inert`/`aria-hidden` state so the mechanism " + + "is visible without devtools. See the Fixed story for the remedy.", + }, + }, + }, +}; + +export const WithIframeThirdPartyModalFixed: ModalStory = { + render: () => ( + + + + Easy UI Modal + + + The fix: `data-react-aria-top-layer="true"` is set on the{" "} + portal root — the node appended to `document.body`, + which is exactly the node Easy UI's `ariaHideOutside` observer + evaluates. With it, the overlay is kept visible (never `inert`), so + the iframe holds focus. The status line reports `inert=false` and + the field is usable. In a real app you can't edit Stripe's + injected node, so the equivalent remedy is `keepVisible()` from + `@react-aria/overlays` (or an Easy UI Modal opt-out). + + + + + + + ), + parameters: { + docs: { + description: { + story: + "Remedy: tagging the portal root (the node added to `document.body`) " + + 'with `data-react-aria-top-layer="true"` makes `ariaHideOutside` keep ' + + "the overlay visible, so it is never `inert`'d and the iframe keeps " + + "focus. In a real third-party integration where you can't tag the " + + "injected node, use `keepVisible()` from `@react-aria/overlays`.", + }, + }, + }, +}; + +/** + * Iframe-based stand-in for Stripe Link/autofill. To mirror Stripe faithfully + * the iframe is CROSS-ORIGIN: it loads a `data:` URL, which the browser gives + * an opaque origin, so the host page cannot reach into `contentDocument` (just + * like a real Stripe frame). The iframe focuses its own first field via an + * inline script on load, since the parent can't do it across the origin + * boundary. + * + * The real bug is NOT the FocusScope top-layer check — it's `ariaHideOutside` + * (pulled in by Easy UI's Modal via `useModalOverlay`). When this overlay is + * portaled into `document.body` while the modal is open, `ariaHideOutside`'s + * MutationObserver sets `inert` on it. `inert` blurs the iframe, and the + * modal's focus containment then restores focus into the Easy UI modal. + * + * `attr` controls the fix: + * - "none": nothing tagged → `ariaHideOutside` inerts the overlay → broken. + * - "root": `data-react-aria-top-layer="true"` on the PORTAL ROOT (the node + * appended to `document.body`, which is what the observer evaluates) → the + * overlay is kept visible → the iframe keeps focus. + * + * The status readout polls whether the iframe is currently inside an `inert` or + * `aria-hidden` subtree, making the mechanism visible without devtools. + */ +function IframeThirdPartyModal({ attr }: { attr: "none" | "root" }) { + const [isOpen, setIsOpen] = useState(false); + const iframeRef = useRef(null); + const [status, setStatus] = useState(null); + + React.useEffect(() => { + if (!isOpen) { + setStatus(null); + return; + } + let raf = 0; + const tick = () => { + const f = iframeRef.current; + if (f) { + const inert = Boolean(f.closest("[inert]")); + const hidden = Boolean(f.closest('[aria-hidden="true"]')); + setStatus( + inert || hidden + ? `BROKEN — Easy UI hid the overlay (inert=${inert}, aria-hidden=${hidden}); the iframe can't hold focus.` + : "OK — overlay is visible and interactive.", + ); + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [isOpen]); + + // Cross-origin document (opaque origin via data: URL). The inline script + // focuses the first field on load because the parent cannot reach in. + const html = ` +

Third-party modal (cross-origin iframe)

+
+ + +
+