diff --git a/.changeset/modal-nested-third-party-overlays.md b/.changeset/modal-nested-third-party-overlays.md new file mode 100644 index 00000000..73294b3a --- /dev/null +++ b/.changeset/modal-nested-third-party-overlays.md @@ -0,0 +1,5 @@ +--- +"@easypost/easy-ui": patch +--- + +Fix third-party overlays (e.g. Stripe Link/autofill) locking up when their modal (`allowsThirdPartyOverlays`) is nested inside another modal. A surrounding focus-trapping modal kept hiding the page (`inert`) and containing focus, which re-broke the injected overlay. A modal now automatically relaxes its focus trap and background hiding while it has an open `allowsThirdPartyOverlays` descendant, then restores them when that descendant closes. Modals without such a descendant are unaffected. diff --git a/.changeset/modal-third-party-overlay-dismiss.md b/.changeset/modal-third-party-overlay-dismiss.md new file mode 100644 index 00000000..b98c7537 --- /dev/null +++ b/.changeset/modal-third-party-overlay-dismiss.md @@ -0,0 +1,5 @@ +--- +"@easypost/easy-ui": patch +--- + +Fix react-aria overlays (e.g. `Select`, `Menu`) not closing on outside click when rendered inside a `Modal` that uses `allowsThirdPartyOverlays`. The modal box was tagged `data-react-aria-top-layer`, which made react-aria treat every click inside the modal as "not outside" for all overlays. Modals now keep nested overlays dismissable while preserving the third-party overlay behavior: the modal stays visible under a surrounding modal, clicking a nested modal no longer dismisses the one beneath it, and genuine third-party overlays (e.g. Stripe) still don't dismiss the modal. diff --git a/.changeset/nested-modal-single-backdrop.md b/.changeset/nested-modal-single-backdrop.md new file mode 100644 index 00000000..af704051 --- /dev/null +++ b/.changeset/nested-modal-single-backdrop.md @@ -0,0 +1,7 @@ +--- +"@easypost/easy-ui": minor +--- + +Add `childNestingBehavior` and `selfNestingBehavior` props to `Modal.Trigger` and `ModalContainer` to control how modals stack when nested. The connection between a parent and a nested child can resolve to `stack` (both keep their backdrops — the default), `stack-shared-backdrop` (the nested modal suppresses its backdrop so only the lowest backdrop shows), or `replace` (the nested modal hides the modal beneath it). + +Configure it from either end: `childNestingBehavior` is set on the parent, applies to its children, and cascades down the tree; `selfNestingBehavior` is set on the child, applies only to its own connection to its parent, does not cascade, and overrides the parent's `childNestingBehavior` for that connection. `selfNestingBehavior` is useful for surgically changing one nested modal in a large tree without touching its parent. diff --git a/easy-ui-react/package.json b/easy-ui-react/package.json index 21d852fe..c07f00e1 100644 --- a/easy-ui-react/package.json +++ b/easy-ui-react/package.json @@ -29,6 +29,7 @@ "test:watch": "vitest" }, "dependencies": { + "@react-aria/overlays": "^3.31.0", "@easypost/easy-ui-icons": "1.0.0-alpha.55", "@easypost/easy-ui-tokens": "1.0.0-alpha.17", "@react-aria/toast": "^3.0.9", @@ -51,6 +52,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.mdx b/easy-ui-react/src/Modal/Modal.mdx index dc88af15..0fb084c6 100644 --- a/easy-ui-react/src/Modal/Modal.mdx +++ b/easy-ui-react/src/Modal/Modal.mdx @@ -133,6 +133,78 @@ Use this sparingly. It trades away the modal's focus containment and background ``` +### Nesting a third-party overlay under another modal + +A modal with `allowsThirdPartyOverlays` doesn't run focus trapping or background hiding itself, but a **surrounding** focus-trapping modal would — and react-aria keeps the surrounding modal's page-hiding active even while the nested one is open, which would re-`inert` the third-party overlay (e.g. Stripe Link) and steal focus back. + +This is handled automatically: a focus-trapping modal relaxes its focus trap and background hiding for as long as it has an open `allowsThirdPartyOverlays` descendant (at any depth), then restores them when that descendant closes. You only need to set `allowsThirdPartyOverlays` on the modal that hosts the overlay — no configuration is required on its ancestors. + +```tsx +{/* The outer modal relaxes automatically while the nested Stripe modal is open. */} + + + + Billing + + + + + New Credit Card + {/* Stripe CardElement, etc. */} + + + + + +``` + +## Nesting behavior + +When modals nest, the connection between a parent modal and its nested child can present in one of three ways: + +- `stack` (the default) — both modals keep their own backdrops; modals simply stack. +- `stack-shared-backdrop` — the nested modal suppresses its backdrop, so only the lowest modal's backdrop shows. +- `replace` — the nested modal hides the modal beneath it, so only the topmost modal is visible. + +You configure that connection from either end, on `` or ``: + +- **`childNestingBehavior`** is set on the parent. It applies to its nested children and **cascades** down the tree, so setting it once at the top governs the whole stack. A descendant that doesn't set its own inherits it. +- **`selfNestingBehavior`** is set on the child. It applies only to that modal's connection to its parent, is **local** (does not cascade), and **overrides** the parent's `childNestingBehavior` for that one connection. + +```tsx +{/* Set once at the top; the whole nested stack shares a single backdrop. */} + + + + Title + {/* nested modals stay visible with a single backdrop */} + + +``` + +### Configuring a single nested modal + +When you can't (or don't want to) change the parent — for example one nested modal deep in a larger tree of modals that should take over its parent — set `selfNestingBehavior` on the nested modal instead. It changes only that modal's connection to its parent, without touching the parent's configuration. + +```tsx + + + + Parent + + {/* This nested modal takes over the parent without touching the parent. */} + + + + Nested + {/* … */} + + + + + +``` + ## Properties ### Modal.Trigger diff --git a/easy-ui-react/src/Modal/Modal.module.scss b/easy-ui-react/src/Modal/Modal.module.scss index d607b98d..2d9e55b0 100644 --- a/easy-ui-react/src/Modal/Modal.module.scss +++ b/easy-ui-react/src/Modal/Modal.module.scss @@ -29,6 +29,12 @@ display: none; } +// Suppresses only the dark overlay so the modal box stays visible and +// interactive, unlike `underlayBgHidden` which hides the whole modal. +.underlayBgNoBackdrop::before { + display: none; +} + .underlayBox { position: relative; display: flex; diff --git a/easy-ui-react/src/Modal/Modal.nesting.test.tsx b/easy-ui-react/src/Modal/Modal.nesting.test.tsx new file mode 100644 index 00000000..b16c74a6 --- /dev/null +++ b/easy-ui-react/src/Modal/Modal.nesting.test.tsx @@ -0,0 +1,401 @@ +import { screen } from "@testing-library/react"; +import React from "react"; +import { vi } from "vitest"; +import { Button } from "../Button"; +import { + mockGetComputedStyle, + mockIntersectionObserver, + render, + userClick, +} from "../utilities/test"; +import { Modal, ModalContainer, ModalNestingBehavior } from "./Modal"; + +describe(" nesting behavior", () => { + let restoreGetComputedStyle: () => void; + let restoreIntersectionObserver: () => void; + + beforeEach(() => { + vi.useFakeTimers(); + restoreGetComputedStyle = mockGetComputedStyle(); + restoreIntersectionObserver = mockIntersectionObserver(); + }); + + afterEach(() => { + restoreIntersectionObserver(); + restoreGetComputedStyle(); + vi.useRealTimers(); + }); + + it("should stack modals with their own backdrops when childNestingBehavior is stack", async () => { + const { user } = render(); + + expect(getVisibleBackdrops()).toHaveLength(1); + + await userClick(user, screen.getByRole("button", { name: "Open inner" })); + + // `stack` stacks: nothing is hidden, so both modals render their own + // backdrops on top of each other. + expect(getBackdrops()).toHaveLength(2); + expect(isBackdropHidden("Outer")).toBe(false); + expect(isBackdropSuppressed("Outer")).toBe(false); + expect(getVisibleBackdrops()).toHaveLength(2); + }); + + it("should hide the modal beneath an open nested modal when childNestingBehavior is replace", async () => { + const { user } = render(); + + await userClick(user, screen.getByRole("button", { name: "Open inner" })); + + // `replace` hides the whole modal beneath, leaving only the topmost visible. + expect(isBackdropHidden("Outer")).toBe(true); + expect(getVisibleBackdrops()).toHaveLength(1); + }); + + it("should restore the hidden modal once the nested modal closes", async () => { + const { user } = render(); + + // Only the outer modal is open: it renders its (visible) backdrop. + expect(getBackdrops()).toHaveLength(1); + expect(getVisibleBackdrops()).toHaveLength(1); + + await userClick(user, screen.getByRole("button", { name: "Open inner A" })); + + // Both modals are mounted, but the outer is hidden so only the topmost + // (inner) backdrop is visible. + expect(getBackdrops()).toHaveLength(2); + expect(getVisibleBackdrops()).toHaveLength(1); + expect(isBackdropHidden("Outer")).toBe(true); + expect(isBackdropHidden("Inner A")).toBe(false); + + await userClick( + user, + screen.getByRole("button", { name: "Close inner A" }), + ); + + // Closing the nested modal restores the outer modal. + expect(getBackdrops()).toHaveLength(1); + expect(getVisibleBackdrops()).toHaveLength(1); + expect(isBackdropHidden("Outer")).toBe(false); + }); + + it("should keep the parent hidden until every sibling nested modal closes", async () => { + // A counter (not a boolean) tracks replacing children, so the parent stays + // hidden while any sibling nested modal is still open. This harness drives + // both nested modals from controls outside the parent. + const { user } = render(); + + await userClick(user, screen.getByRole("button", { name: "Open inner A" })); + await userClick(user, screen.getByRole("button", { name: "Open inner B" })); + + // Two sibling nested modals are open; the shared parent stays hidden. + expect(isBackdropHidden("Outer")).toBe(true); + + await userClick( + user, + screen.getByRole("button", { name: "Close inner A" }), + ); + + // One sibling remains open, so the parent must stay hidden. A boolean flag + // would have incorrectly un-hidden it here; the count must not. + expect(isBackdropHidden("Outer")).toBe(true); + + await userClick( + user, + screen.getByRole("button", { name: "Close inner B" }), + ); + + // With all nested modals closed, the parent returns. + expect(isBackdropHidden("Outer")).toBe(false); + }); + + it("should keep all modals visible and show only the lowest backdrop when childNestingBehavior is stack-shared-backdrop", async () => { + const { user } = render( + , + ); + + // The lowest (root) modal keeps its backdrop. + expect(isBackdropSuppressed("Outer")).toBe(false); + + await userClick(user, screen.getByRole("button", { name: "Open inner" })); + + // The outer modal stays visible (not hidden) and keeps its backdrop, while + // the nested modal stays visible but suppresses its own backdrop. + expect(isBackdropHidden("Outer")).toBe(false); + expect(isBackdropSuppressed("Outer")).toBe(false); + expect(isBackdropSuppressed("Inner")).toBe(true); + expect(screen.getByTestId("inner-content")).toBeInTheDocument(); + }); + + it("should cascade childNestingBehavior to descendant modals that don't set their own", async () => { + const { user } = render( + , + ); + + await userClick(user, screen.getByRole("button", { name: "Open inner" })); + + // The nested modal inherits `stack-shared-backdrop` from its ancestor. + expect(isBackdropSuppressed("Inner")).toBe(true); + }); + + it("should let a descendant override an inherited childNestingBehavior via selfNestingBehavior", async () => { + const { user } = render( + , + ); + + await userClick(user, screen.getByRole("button", { name: "Open inner" })); + + // Ancestor keeps the lowest backdrop, but the descendant's own value wins so + // it renders its own backdrop. + expect(isBackdropSuppressed("Outer")).toBe(false); + expect(isBackdropSuppressed("Inner")).toBe(false); + expect(isBackdropHidden("Inner")).toBe(false); + }); + + it("should not hide the parent by default when a nested modal opens", async () => { + const { user } = render(); + + await userClick(user, screen.getByRole("button", { name: "Open inner" })); + + // The parent is a plain (stack) modal, so it stays visible behind the nested + // modal. + expect(isBackdropHidden("Outer")).toBe(false); + }); + + it("should hide the parent when a nested modal sets selfNestingBehavior replace, without configuring the parent", async () => { + const { user } = render( + , + ); + + // Before the nested modal opens, the parent renders normally. + expect(isBackdropHidden("Outer")).toBe(false); + + await userClick(user, screen.getByRole("button", { name: "Open inner" })); + + // The nested modal's own connection resolved to `replace`, so the parent + // hides even though it was never configured with `childNestingBehavior`. + expect(isBackdropHidden("Outer")).toBe(true); + expect(screen.getByTestId("inner-content")).toBeInTheDocument(); + + await userClick(user, screen.getByRole("button", { name: "Close inner" })); + + // Closing the nested modal restores the parent. + expect(isBackdropHidden("Outer")).toBe(false); + }); + + it("should suppress only its own backdrop when a nested modal sets selfNestingBehavior stack-shared-backdrop", async () => { + const { user } = render( + , + ); + + await userClick(user, screen.getByRole("button", { name: "Open inner" })); + + // The nested modal shares the parent's backdrop: the parent stays visible + // with its backdrop, and only the nested modal suppresses its own. + expect(isBackdropHidden("Outer")).toBe(false); + expect(isBackdropSuppressed("Outer")).toBe(false); + expect(isBackdropSuppressed("Inner")).toBe(true); + expect(screen.getByTestId("inner-content")).toBeInTheDocument(); + }); +}); + +// A parent modal whose children replace it (via cascading `childNestingBehavior`), +// opened from two triggers inside it. The inner triggers use +// `allowsThirdPartyOverlays` so their controls stay reachable while open. +function NestedReplaceModals() { + return ( + + + + Outer + + + + {(close) => ( + + Inner A + + + + + )} + + + + + ); +} + +// Drives two replacing nested modals that share a single parent from controls +// rendered *outside* the parent. Everything uses `allowsThirdPartyOverlays` so no +// modal `ariaHideOutside`s the controls, and everything is non-dismissable so +// clicking those outside controls doesn't dismiss an open modal via +// interact-outside. Nested modals are closed explicitly via their own buttons. +function ControlledReplaceModals() { + const [isAOpen, setIsAOpen] = React.useState(false); + const [isBOpen, setIsBOpen] = React.useState(false); + return ( + <> + + + + + + Outer + + setIsAOpen(false)} + > + {isAOpen && ( + + Inner A + + + + + )} + + setIsBOpen(false)} + > + {isBOpen && ( + + Inner B + + + + + )} + + + + + + ); +} + +// Outer modal opens by default; the inner trigger uses `allowsThirdPartyOverlays` +// so its controls stay reachable while both modals are open. The outer sets +// `childNestingBehavior` (which cascades) and the inner sets `selfNestingBehavior` +// (which overrides for its own connection), exercising the cascade/override paths. +function NestingBehaviorNested({ + outerBehavior, + innerBehavior, +}: { + outerBehavior?: ModalNestingBehavior; + innerBehavior?: ModalNestingBehavior; +}) { + return ( + + + + Outer + + + + {(close) => ( + + Inner + +
Inner content
+ +
+
+ )} +
+
+
+
+ ); +} + +// A plain (stack) parent modal with a single nested modal. The nested modal can +// configure its own connection to the parent via `selfNestingBehavior` — without +// the parent itself being configured — mirroring a one-off nesting deep in a +// larger modal tree. +function ReplaceParentNested({ + innerSelfBehavior, +}: { + innerSelfBehavior?: ModalNestingBehavior; +}) { + return ( + + + + Outer + + + + {(close) => ( + + Inner + +
Inner content
+ +
+
+ )} +
+
+
+
+ ); +} + +// CSS-module class names are scoped in tests (e.g. `_underlayBg_1a2b3`), so we +// match on the embedded local name rather than an exact class. +const UNDERLAY_CLASS = "underlayBg"; +const UNDERLAY_HIDDEN_CLASS = "underlayBgHidden"; +const UNDERLAY_NO_BACKDROP_CLASS = "underlayBgNoBackdrop"; + +function getBackdrops() { + // The modifier classes share the `underlayBg` prefix, so each backdrop element + // is matched exactly once. + return Array.from( + document.querySelectorAll(`[class*="${UNDERLAY_CLASS}"]`), + ) as HTMLElement[]; +} + +function getVisibleBackdrops() { + return getBackdrops().filter( + (el) => !el.className.includes(UNDERLAY_HIDDEN_CLASS), + ); +} + +function getBackdrop(headerText: string) { + return screen + .getByText(headerText) + .closest(`[class*="${UNDERLAY_CLASS}"]`) as HTMLElement; +} + +// `replace` hides the whole modal (box and backdrop) via `display: none`. +function isBackdropHidden(headerText: string) { + return getBackdrop(headerText).className.includes(UNDERLAY_HIDDEN_CLASS); +} + +// `stack-shared-backdrop` suppresses only the dark overlay while keeping the +// modal box visible, unlike `underlayBgHidden` which hides the whole modal. +function isBackdropSuppressed(headerText: string) { + return getBackdrop(headerText).className.includes(UNDERLAY_NO_BACKDROP_CLASS); +} diff --git a/easy-ui-react/src/Modal/Modal.stories.tsx b/easy-ui-react/src/Modal/Modal.stories.tsx index a58c08fc..f34f9216 100644 --- a/easy-ui-react/src/Modal/Modal.stories.tsx +++ b/easy-ui-react/src/Modal/Modal.stories.tsx @@ -1,6 +1,8 @@ import { action } from "storybook/actions"; import { Meta, StoryObj } from "@storybook/react-vite"; -import React, { Key, useState } from "react"; +import { CardElement, Elements } from "@stripe/react-stripe-js"; +import { loadStripe } from "@stripe/stripe-js"; +import React, { Key, useMemo, useState } from "react"; import { Button } from "../Button"; import { EasyPostLogo, @@ -12,6 +14,7 @@ import { ModalTrigger } from "./ModalTrigger"; import { Menu } from "../Menu"; import { DropdownButton } from "../DropdownButton"; import { Select } from "../Select"; +import { Text } from "../Text"; import { HorizontalStack } from "../HorizontalStack"; type ModalStory = StoryObj; @@ -232,13 +235,35 @@ export const MenuTrigger: ModalTriggerStory = { }, }; -export const Nested: ModalTriggerStory = { - render: () => { +type NestedModalStory = StoryObj< + React.ComponentProps & { publishableKey: string } +>; + +export const Nested: NestedModalStory = { + render: (args) => { const [modal1, setModal1] = useState(true); const [modal2, setModal2] = useState(false); const [modal3, setModal3] = useState(false); + + // Load real Stripe.js (one instance per key) so Modal 2 can mount the real + // `CardElement` and inject Stripe's Link / autofill overlays — the + // third-party overlays that `allowsThirdPartyOverlays` keeps usable. + const { publishableKey } = args; + const stripePromise = useMemo( + () => (publishableKey ? loadStripe(publishableKey) : null), + [publishableKey], + ); + return ( + // `childNestingBehavior` is set only on the outermost modal; it cascades to + // the nested modals below. Modal 2 overrides its own connection to the + // outer modal with `selfNestingBehavior="replace"`. + // + // Modal 2 sets `allowsThirdPartyOverlays`; this focus-trapping outer modal + // automatically relaxes while it's open so Modal 2's Stripe Link / autofill + // overlay isn't inert'd or robbed of focus. { setModal1(false); }} @@ -250,10 +275,16 @@ export const Nested: ModalTriggerStory = { Space for content + { setModal2(false); }} + allowsThirdPartyOverlays > {modal2 && ( @@ -262,6 +293,33 @@ export const Nested: ModalTriggerStory = { Content 2 + + {stripePromise ? ( + +
+ +
+
+ ) : ( + + Paste a Stripe test publishable key (pk_test_…) into + the "publishableKey" control, then reopen + this modal to mount the real Stripe CardElement and + trigger Link / autofill. + + )} {modal3 && ( { @@ -271,6 +329,17 @@ export const Nested: ModalTriggerStory = { Modal 3 + Content 3 @@ -324,6 +393,26 @@ export const Nested: ModalTriggerStory = { ); }, + args: { + childNestingBehavior: "stack-shared-backdrop", + publishableKey: "", + }, + argTypes: { + childNestingBehavior: { + control: "select", + options: ["stack", "stack-shared-backdrop", "replace"], + }, + publishableKey: { + control: "text", + description: + "Stripe test publishable key (pk_test_…). Mounts the real Stripe " + + "CardElement in Modal 2 so its Link / autofill third-party overlay " + + "can be exercised against `allowsThirdPartyOverlays`.", + }, + }, + parameters: { + controls: { include: ["childNestingBehavior", "publishableKey"] }, + }, }; export const WithSelect: ModalStory = { diff --git a/easy-ui-react/src/Modal/Modal.test.tsx b/easy-ui-react/src/Modal/Modal.test.tsx index e11a12ba..413418f2 100644 --- a/easy-ui-react/src/Modal/Modal.test.tsx +++ b/easy-ui-react/src/Modal/Modal.test.tsx @@ -1,5 +1,5 @@ -import { screen } from "@testing-library/react"; -import React from "react"; +import { act, screen } from "@testing-library/react"; +import React, { useState } from "react"; import { vi } from "vitest"; import { Button } from "../Button"; import { HorizontalStack } from "../HorizontalStack"; @@ -10,6 +10,7 @@ import { userClick, userKeyboard, } from "../utilities/test"; +import { Select } from "../Select"; import { Modal, ModalContainer, ModalProps, useModalTrigger } from "./Modal"; import { ModalHeaderProps } from "./ModalHeader"; import { ModalTriggerProps } from "./ModalTrigger"; @@ -212,8 +213,254 @@ describe("", () => { renderModalWithOutsideContent({ allowsThirdPartyOverlays: true }); expect(isElementHidden(screen.getByTestId("outside-content"))).toBe(false); }); + + it("should keep a nested allowsThirdPartyOverlays modal interactive when opened inside a standard modal", async () => { + const { user } = render(); + await userClick(user, screen.getByRole("button", { name: "Open inner" })); + + // The outer (standard) modal applies `ariaHideOutside`, which would + // otherwise `inert` the nested third-party modal — making it unclickable + // and causing pointer events to fall through and dismiss it. + expect(isElementHidden(screen.getByTestId("inner-content"))).toBe(false); + expect( + screen.getByRole("button", { name: "Inner action" }), + ).toBeInTheDocument(); + }); + + it("should dismiss a Select on outside click within an allowsThirdPartyOverlays modal", async () => { + const { user } = render(); + + await userClick(user, screen.getByRole("button", { name: /fruit/i })); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + + // Click elsewhere inside the modal (not the listbox, not the backdrop). The + // modal box used to be tagged `data-react-aria-top-layer`, which made + // react-aria treat clicks inside the modal as "not outside" so the Select + // never closed. + await userClick(user, screen.getByTestId("modal-content")); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); + + it("should dismiss an allowsThirdPartyOverlays modal when interacting outside it", async () => { + const { user } = render( + <> + + + + + Header + Content + + + , + ); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + await userClick(user, screen.getByTestId("outside")); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("should not dismiss the outer modal when interacting with a nested modal", async () => { + const { user } = render(); + + expect(screen.getByTestId("outer-content")).toBeInTheDocument(); + await userClick(user, screen.getByTestId("inner-content")); + + // Clicking inside the nested modal must not dismiss the modal beneath it. + expect(screen.getByTestId("outer-content")).toBeInTheDocument(); + expect(screen.getByTestId("inner-content")).toBeInTheDocument(); + }); + + it("should not dismiss the modal when interacting with a third-party top-layer overlay", async () => { + const { user } = render( + <> +
+ +
+ + + + Header + Content + + + , + ); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + // react-aria ignores interact-outside on `[data-react-aria-top-layer]`, so a + // genuine third-party overlay (e.g. Stripe) doesn't dismiss the modal. + await userClick(user, screen.getByTestId("third-party")); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("does not inert a node injected after a nested third-party modal opens", async () => { + render( + + + + Outer + + + + + Inner + Inner content + + + + + , + ); + + // Simulate a third-party (e.g. Stripe Link) overlay injected into the body + // *after* the modals open. A standard outer modal automatically stops hiding + // the page while its `allowsThirdPartyOverlays` descendant is open, so the + // injected overlay isn't `inert`'d/`aria-hidden` (which would lock it up). + const injected = await injectBodyNode(); + expect(isElementHidden(injected)).toBe(false); + }); + + it("still hides outside content when no third-party descendant is open", async () => { + render( + <> +
Outside content
+ + + + Header + Content + + + , + ); + + // With no third-party descendant open, a modal behaves exactly like a + // standard focus-trapping modal: it hides the rest of the page. + expect(isElementHidden(screen.getByTestId("outside-content"))).toBe(true); + }); + + it("resumes hiding the page after the nested third-party modal closes", async () => { + const { user } = render(); + + await userClick(user, screen.getByRole("button", { name: "Open inner" })); + const whileOpen = await injectBodyNode(); + expect(isElementHidden(whileOpen)).toBe(false); + + await userClick(user, screen.getByRole("button", { name: "Close inner" })); + const afterClose = await injectBodyNode(); + // The outer modal traps again once the third-party descendant is gone. + expect(isElementHidden(afterClose)).toBe(true); + }); }); +// Appends a node to after the modals are open (mimicking a third-party +// script like Stripe) and lets react-aria's `ariaHideOutside` MutationObserver +// react before we assert. +async function injectBodyNode() { + const node = document.createElement("div"); + await act(async () => { + document.body.appendChild(node); + await Promise.resolve(); + }); + return node; +} + +function ToggleableNestedThirdPartyModal() { + const [isInnerOpen, setIsInnerOpen] = useState(false); + return ( + + + + Outer + + + setIsInnerOpen(false)} + > + {isInnerOpen && ( + + Inner + + + + + )} + + + + + ); +} + +function NestedThirdPartyModal() { + return ( + + + + Outer + + + + + Inner + +
Inner content
+ +
+
+
+
+
+
+ ); +} + +function ModalWithSelect() { + return ( + + + + Header + +
Modal content
+ +
+
+
+ ); +} + +function NestedDismissModals() { + return ( + + + + Outer + +
Outer content
+ + + + Inner + +
Inner content
+
+
+
+
+
+
+ ); +} + const CustomSymbol = (props: object) => ; function renderModal({ diff --git a/easy-ui-react/src/Modal/Modal.tsx b/easy-ui-react/src/Modal/Modal.tsx index 643dfd44..36d7a38f 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 type { ModalNestingBehavior } from "./context"; import styles from "./Modal.module.scss"; @@ -122,3 +123,4 @@ Modal.Body = ModalBody; Modal.Footer = ModalFooter; export { ModalContainer, useModalTrigger }; +export type { ModalNestingBehavior }; diff --git a/easy-ui-react/src/Modal/ModalContainer.tsx b/easy-ui-react/src/Modal/ModalContainer.tsx index 502a417f..3583d3c2 100644 --- a/easy-ui-react/src/Modal/ModalContainer.tsx +++ b/easy-ui-react/src/Modal/ModalContainer.tsx @@ -2,7 +2,7 @@ import React, { ReactElement, ReactNode, cloneElement, useState } from "react"; import { useOverlayTrigger } from "react-aria"; import { useOverlayTriggerState } from "react-stately"; import { ModalUnderlay } from "./ModalUnderlay"; -import { ModalTriggerProvider } from "./context"; +import { ModalNestingBehavior, ModalTriggerProvider } from "./context"; type ModalContainerProps = { /** @@ -24,6 +24,25 @@ type ModalContainerProps = { */ allowsThirdPartyOverlays?: boolean; + /** + * Controls how this modal's nested children stack relative to it, and cascades + * to descendant modals. `stack` gives each child its own backdrop, + * `stack-shared-backdrop` makes children share this modal's backdrop, and + * `replace` hides this modal while a child is open. When unset, it inherits the + * nearest ancestor modal's value (or `stack` at the root). + */ + childNestingBehavior?: ModalNestingBehavior; + + /** + * Controls how this modal stacks relative to its parent, overriding the + * parent's `childNestingBehavior` for just this modal. `stack` keeps both + * backdrops, `stack-shared-backdrop` suppresses this modal's backdrop, and + * `replace` hides the parent while this modal is open. Local to this modal — it + * does not cascade. Useful for surgically changing one nested modal in a larger + * tree without touching its parent. + */ + selfNestingBehavior?: ModalNestingBehavior; + /** * Handler that is called when the overlay is closed. */ @@ -43,6 +62,8 @@ export function ModalContainer(props: ModalContainerProps) { children, isDismissable = true, allowsThirdPartyOverlays = false, + childNestingBehavior, + selfNestingBehavior, onDismiss = () => {}, } = props; @@ -77,7 +98,13 @@ export function ModalContainer(props: ModalContainerProps) { const { overlayProps } = useOverlayTrigger({ type: "dialog" }, state); return ( - + {state.isOpen && ( void) => ReactElement; @@ -33,6 +33,25 @@ export type ModalTriggerProps = { */ allowsThirdPartyOverlays?: boolean; + /** + * Controls how this modal's nested children stack relative to it, and cascades + * to descendant modals. `stack` gives each child its own backdrop, + * `stack-shared-backdrop` makes children share this modal's backdrop, and + * `replace` hides this modal while a child is open. When unset, it inherits the + * nearest ancestor modal's value (or `stack` at the root). + */ + childNestingBehavior?: ModalNestingBehavior; + + /** + * Controls how this modal stacks relative to its parent, overriding the + * parent's `childNestingBehavior` for just this modal. `stack` keeps both + * backdrops, `stack-shared-backdrop` suppresses this modal's backdrop, and + * `replace` hides the parent while this modal is open. Local to this modal — it + * does not cascade. Useful for surgically changing one nested modal in a larger + * tree without touching its parent. + */ + selfNestingBehavior?: ModalNestingBehavior; + /** * Whether the modal is open by default (controlled). */ @@ -45,7 +64,14 @@ export type ModalTriggerProps = { }; export function ModalTrigger(props: ModalTriggerProps) { - const { children, isDismissable = true, ...inTriggerProps } = props; + const { + children, + isDismissable = true, + allowsThirdPartyOverlays = false, + childNestingBehavior, + selfNestingBehavior, + ...inTriggerProps + } = props; const state = useOverlayTriggerState(inTriggerProps); const { triggerProps, overlayProps } = useOverlayTrigger( @@ -62,7 +88,13 @@ export function ModalTrigger(props: ModalTriggerProps) { const [trigger, modal] = children; return ( - + {cloneElement(trigger, triggerProps)} {state.isOpen && ( diff --git a/easy-ui-react/src/Modal/ModalUnderlay.tsx b/easy-ui-react/src/Modal/ModalUnderlay.tsx index 912b7dfe..20e23cf8 100644 --- a/easy-ui-react/src/Modal/ModalUnderlay.tsx +++ b/easy-ui-react/src/Modal/ModalUnderlay.tsx @@ -1,10 +1,8 @@ -import React, { ReactNode, RefObject, useRef } from "react"; -import { - Overlay, - useModalOverlay, - useOverlay, - usePreventScroll, -} from "react-aria"; +import React, { ReactNode, RefObject, useEffect, useRef } from "react"; +import { Overlay, useOverlay, usePreventScroll } from "react-aria"; +// `react-aria` doesn't re-export `ariaHideOutside`; the relaxable variant calls +// it directly so it can gate page-hiding on an open third-party descendant. +import { ariaHideOutside } from "@react-aria/overlays"; import { DOMAttributes } from "@react-types/shared"; import { OverlayTriggerState } from "react-stately"; import { classNames } from "../utilities/css"; @@ -12,6 +10,16 @@ import { useModalTriggerContext } from "./context"; import styles from "./Modal.module.scss"; +// Marks every Easy UI modal box. A modal ignores interact-outside events that +// land inside another (nested) Easy UI modal's box, so opening or clicking a +// nested modal never dismisses the one beneath it. Backdrop clicks land outside +// the box and still dismiss. +const MODAL_BOX_ATTRIBUTE = "data-easy-ui-modal-box"; + +function shouldCloseOnInteractOutside(target: Element) { + return !target.closest(`[${MODAL_BOX_ATTRIBUTE}]`); +} + type ModalUnderlayProps = { /** * Modal state. @@ -48,10 +56,27 @@ type ModalUnderlayContentProps = { underlayProps: DOMAttributes; modalRef: RefObject; children: ReactNode; + + /** + * Keeps this modal box visible when a surrounding Easy UI modal's + * `ariaHideOutside` would otherwise `inert` it. Needed by the third-party + * variant, which doesn't run `ariaHideOutside` itself. + */ + keepVisibleUnderModal?: boolean; + + /** + * Drives focus containment on the underlay's `Overlay`. The standard variant + * toggles this as a third-party descendant opens and closes; the third-party + * variant leaves it at its default (it never contains focus). + */ + shouldContainFocus?: boolean; }; export function ModalUnderlay(props: ModalUnderlayProps) { - // Branch into sibling components so each calls its hooks unconditionally. + // Branch into sibling components so each calls its hooks unconditionally. The + // branch keys off the stable `allowsThirdPartyOverlays` prop only, so a modal + // never swaps variants at runtime (which would remount its children — and any + // third-party overlay). return props.allowsThirdPartyOverlays ? ( ) : ( @@ -60,28 +85,52 @@ export function ModalUnderlay(props: ModalUnderlayProps) { } /** - * 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). + * Standard modal behavior: traps focus and hides the rest of the page + * (`aria-hidden`/`inert`). It reproduces `useModalOverlay` from the lower-level + * hooks rather than calling it directly so both behaviors can be toggled off in + * place — no remount — while an `allowsThirdPartyOverlays` descendant is open. + * + * That relaxation is automatic and necessary: react-aria keeps only the + * *topmost* `ariaHideOutside` observer active, and a third-party descendant uses + * `useOverlay` (no `ariaHideOutside`), so it never takes that observer over. Left + * trapping, this modal's observer would `inert` the descendant's injected + * overlay (e.g. Stripe Link) and its focus trap would steal focus back. Skipping + * `ariaHideOutside` also tears down this modal's observer so an outer ancestor's + * observer (if any) takes back over — every focus-trapping ancestor relaxes in + * turn. With no third-party descendant open (`shouldTrap` true), this is + * behaviorally identical to `useModalOverlay`. */ function FocusTrappingUnderlay(props: ModalUnderlayProps) { const { state, children, isDismissable = true } = props; + const { hasOpenThirdPartyDescendant } = useModalTriggerContext(); + const shouldTrap = !hasOpenThirdPartyDescendant; const ref = useRef(null); - const { modalProps, underlayProps } = useModalOverlay( + const { overlayProps, underlayProps } = useOverlay( { + isOpen: state.isOpen, + onClose: state.close, isDismissable, isKeyboardDismissDisabled: !isDismissable, + shouldCloseOnInteractOutside, }, - state, ref, ); + usePreventScroll({ isDisabled: !state.isOpen }); + + // Mirror `useModalOverlay`'s page-hiding, but only while trapping. + useEffect(() => { + if (state.isOpen && shouldTrap && ref.current) { + return ariaHideOutside([ref.current], { shouldUseInert: true }); + } + }, [state.isOpen, shouldTrap]); return ( {children} @@ -110,6 +159,7 @@ function ThirdPartyOverlayUnderlay(props: ModalUnderlayProps) { onClose: state.close, isDismissable, isKeyboardDismissDisabled: !isDismissable, + shouldCloseOnInteractOutside, }, ref, ); @@ -120,6 +170,7 @@ function ThirdPartyOverlayUnderlay(props: ModalUnderlayProps) { modalProps={overlayProps} underlayProps={underlayProps} modalRef={ref} + keepVisibleUnderModal > {children} @@ -135,18 +186,46 @@ function ModalUnderlayContent({ underlayProps, modalRef, children, + keepVisibleUnderModal = false, + shouldContainFocus = false, }: ModalUnderlayContentProps) { - const { hasOpenNestedModal } = useModalTriggerContext(); - + const { isNested, hasReplacingChild, selfNestingBehavior } = + useModalTriggerContext(); + + // A nested modal whose connection to this one resolved to `replace` hides this + // modal entirely (tracked as `hasReplacingChild`), so only the topmost modal + // is visible. When this modal's own connection to its parent resolves to + // `stack-shared-backdrop`, it suppresses its own backdrop so only the lowest + // modal's backdrop shows. `stack` does neither. const className = classNames( styles.underlayBg, - hasOpenNestedModal && styles.underlayBgHidden, + hasReplacingChild && styles.underlayBgHidden, + isNested && + selfNestingBehavior === "stack-shared-backdrop" && + styles.underlayBgNoBackdrop, ); return ( - +
-
+
{children}
diff --git a/easy-ui-react/src/Modal/context.tsx b/easy-ui-react/src/Modal/context.tsx index 9b97ab9b..f1003bf5 100644 --- a/easy-ui-react/src/Modal/context.tsx +++ b/easy-ui-react/src/Modal/context.tsx @@ -4,12 +4,34 @@ import { DOMAttributes, RefObject, createContext, + useCallback, useContext, useMemo, useState, } from "react"; +// SSR-safe `useLayoutEffect`: behaves as `useLayoutEffect` on the client and +// no-ops without warning on the server, where `ModalTriggerProvider` still +// renders. +import { useLayoutEffect } from "@react-aria/utils"; import { OverlayTriggerState } from "react-stately"; +/** + * Controls how one modal in a nested stack presents relative to the modal it + * connects to. The same value describes that connection from either end — + * `childNestingBehavior` (set on the parent, cascades down) and + * `selfNestingBehavior` (set on the child, local) — and resolves to: + * + * - `stack` — both modals keep their own backdrops; modals simply stack. + * - `stack-shared-backdrop` — the nested modal suppresses its backdrop, so only + * the lowest modal's backdrop shows. + * - `replace` — the nested modal hides the modal beneath it, so only the topmost + * modal is visible. + */ +export type ModalNestingBehavior = + | "stack" + | "stack-shared-backdrop" + | "replace"; + export type ModalContextType = { dialogProps: DOMAttributes; titleProps: DOMAttributes; @@ -23,8 +45,51 @@ export type ModalContextType = { type ModalTriggerContextType = { isDismissable: boolean; state: OverlayTriggerState; - hasOpenNestedModal: boolean; - setHasOpenNestedModal: (hasOpenNestedModal: boolean) => void; + /** + * Whether this modal is nested under an open ancestor modal. Combined with + * `selfNestingBehavior`, this decides whether the modal suppresses its own + * backdrop. + */ + isNested: boolean; + /** + * Whether this modal currently has one or more open nested modals whose + * connection resolved to `replace`. When true, this modal hides itself so the + * nested modal takes its place. + */ + hasReplacingChild: boolean; + /** + * The resolved behavior this modal imposes on its children's connections. + * Cascades: a child that sets neither `childNestingBehavior` nor + * `selfNestingBehavior` inherits this value. + */ + childNestingBehavior: ModalNestingBehavior; + /** + * The resolved behavior of this modal's connection to its parent. Drives this + * modal's own rendering — suppressing its backdrop or hiding its parent. + */ + selfNestingBehavior: ModalNestingBehavior; + /** + * Registers an open nested modal with this modal. The `replaces` argument + * records whether that nested modal's connection resolved to `replace`. + * Returns a cleanup that unregisters it. Callers invoke this from an effect + * tied to the nested modal's open state so the count stays accurate across + * open/close/unmount. + */ + registerNested: (replaces: boolean) => () => void; + /** + * Whether an open descendant modal (at any depth) hosts third-party overlays + * (`allowsThirdPartyOverlays`). A focus-trapping modal reads this to stop + * hiding/trapping the page while such a descendant is open, so the descendant's + * injected overlay (e.g. Stripe Link) isn't `inert`'d or robbed of focus. + */ + hasOpenThirdPartyDescendant: boolean; + /** + * Registers that this modal's subtree contains an open third-party-overlay + * modal, propagating up to every ancestor. Returns a cleanup that unregisters + * it. Like {@link registerNested}, callers invoke this from an effect tied to + * open state so the count stays accurate across open/close/unmount. + */ + registerThirdPartyOverlay: () => () => void; }; export type ModalTriggerProviderProps = Pick< @@ -32,6 +97,25 @@ export type ModalTriggerProviderProps = Pick< "state" | "isDismissable" > & { children: ReactNode; + /** + * How this modal's nested children present relative to it. Cascades to + * descendants. Defaults to the nearest ancestor's value, or `stack` at the + * root. + */ + childNestingBehavior?: ModalNestingBehavior; + /** + * How this modal presents relative to its parent. Local to this modal (does + * not cascade) and overrides the parent's `childNestingBehavior` for this one + * connection. Defaults to the parent's `childNestingBehavior`. + */ + selfNestingBehavior?: ModalNestingBehavior; + /** + * Whether this modal hosts third-party overlays itself. Used to register this + * modal's subtree with focus-trapping ancestors so they can relax while it's + * open. This is the underlay's behavior; the provider only needs it to drive + * registration. + */ + allowsThirdPartyOverlays?: boolean; }; export const ModalContext = createContext(null); @@ -60,15 +144,161 @@ export const useModalTrigger = () => { return modalTriggerContext.state; }; +/** + * Resolves a modal's nesting connections and wires up parent/child registration, + * returning the nesting values for the trigger context. A connection can be + * configured from either end — the parent's `childNestingBehavior` (cascades) or + * the child's `selfNestingBehavior` (local, wins for its own connection) — and + * resolves to `stack`, `stack-shared-backdrop`, or `replace`. See + * {@link ModalNestingBehavior}. + */ +function useModalNesting({ + childNestingBehavior, + selfNestingBehavior, + allowsThirdPartyOverlays, + isOpen, +}: { + childNestingBehavior: ModalNestingBehavior | undefined; + selfNestingBehavior: ModalNestingBehavior | undefined; + allowsThirdPartyOverlays: boolean; + isOpen: boolean; +}) { + const parentContext = useContext(ModalTriggerContext); + const [replacingChildCount, setReplacingChildCount] = useState(0); + const [thirdPartyDescendantCount, setThirdPartyDescendantCount] = useState(0); + + // Both behaviors fall back to the parent's resolved `childNestingBehavior` + // (which cascades), then `stack`. The child's `selfNestingBehavior` wins for + // its own connection to the parent. + const childNesting = + childNestingBehavior ?? parentContext?.childNestingBehavior ?? "stack"; + const selfNesting = + selfNestingBehavior ?? parentContext?.childNestingBehavior ?? "stack"; + + // The provider tree mirrors the modal tree, and a modal's underlay only + // renders while open, so a present parent context means this modal is nested + // under an open ancestor. + const isNested = parentContext != null; + + // A counter (rather than a boolean) keeps sibling nested modals independent: + // closing one replacing modal must not un-hide this modal while another is + // still open. Functional updates avoid reading stale state across calls. + const registerNested = useCallback((replaces: boolean) => { + if (!replaces) { + return () => {}; + } + setReplacingChildCount((count) => count + 1); + let released = false; + return () => { + if (released) { + return; + } + released = true; + setReplacingChildCount((count) => Math.max(0, count - 1)); + }; + }, []); + + // If this modal's connection to its parent resolves to `replace`, register + // that with the parent so the parent hides while this modal is open. We depend + // on the parent's `registerNested` (stable for the parent's lifetime) rather + // than the whole parent context, whose identity changes as its state updates — + // depending on the full context would re-fire this effect and flicker the + // modal. + // + // This must be a layout effect, not a passive one. When this modal closes, the + // cleanup unhides the parent (the parent is kept mounted but `display: none` + // while replaced). A passive cleanup runs *after* the browser paints, so the + // frame between this modal unmounting and the parent unhiding shows neither — + // a visible flash. A layout cleanup runs synchronously during commit and the + // parent's unhide is flushed before paint, closing that gap. + const parentRegisterNested = parentContext?.registerNested; + const selfReplacesParent = selfNesting === "replace"; + useLayoutEffect(() => { + if (!parentRegisterNested || !isOpen) { + return; + } + return parentRegisterNested(selfReplacesParent); + }, [parentRegisterNested, isOpen, selfReplacesParent]); + + // Counts open third-party-overlay modals anywhere in this modal's subtree. A + // counter (not a boolean) keeps independent descendants from clobbering each + // other; functional updates avoid stale reads across calls. + const registerThirdPartyOverlay = useCallback(() => { + setThirdPartyDescendantCount((count) => count + 1); + let released = false; + return () => { + if (released) { + return; + } + released = true; + setThirdPartyDescendantCount((count) => Math.max(0, count - 1)); + }; + }, []); + + // Propagate third-party-overlay presence up the whole ancestor chain: this + // modal registers with its parent when it either hosts a third-party overlay + // itself or already has one open in its subtree. Bubbling means every + // focus-trapping ancestor — not just the nearest — sees it and can relax, + // which matters because react-aria keeps only the topmost `ariaHideOutside` + // observer active (see ModalUnderlay). Layout effect for the same + // flush-before-paint reasons as the `replace` registration above. + const parentRegisterThirdPartyOverlay = + parentContext?.registerThirdPartyOverlay; + const hasThirdPartyInSubtree = + allowsThirdPartyOverlays || thirdPartyDescendantCount > 0; + useLayoutEffect(() => { + if ( + !parentRegisterThirdPartyOverlay || + !isOpen || + !hasThirdPartyInSubtree + ) { + return; + } + return parentRegisterThirdPartyOverlay(); + }, [parentRegisterThirdPartyOverlay, isOpen, hasThirdPartyInSubtree]); + + return useMemo( + () => ({ + isNested, + hasReplacingChild: replacingChildCount > 0, + childNestingBehavior: childNesting, + selfNestingBehavior: selfNesting, + registerNested, + hasOpenThirdPartyDescendant: thirdPartyDescendantCount > 0, + registerThirdPartyOverlay, + }), + [ + isNested, + replacingChildCount, + childNesting, + selfNesting, + registerNested, + thirdPartyDescendantCount, + registerThirdPartyOverlay, + ], + ); +} + export function ModalTriggerProvider({ state, isDismissable, + childNestingBehavior, + selfNestingBehavior, + allowsThirdPartyOverlays = false, children, }: ModalTriggerProviderProps) { - const [hasOpenNestedModal, setHasOpenNestedModal] = useState(false); - const context = useMemo(() => { - return { hasOpenNestedModal, setHasOpenNestedModal, state, isDismissable }; - }, [hasOpenNestedModal, state, isDismissable]); + const nesting = useModalNesting({ + childNestingBehavior, + selfNestingBehavior, + allowsThirdPartyOverlays, + isOpen: state.isOpen, + }); + + const context = useMemo( + () => ({ ...nesting, state, isDismissable }), + [nesting, state, isDismissable], + ); + return ( {children} diff --git a/package-lock.json b/package-lock.json index 8f345e36..6967e0c8 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,10 +127,11 @@ }, "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/overlays": "^3.31.0", "@react-aria/toast": "^3.0.9", "@react-aria/utils": "^3.32.0", "@react-stately/toast": "^3.1.2", @@ -146,6 +147,8 @@ "use-clipboard-copy": "^0.2.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", @@ -681,10 +684,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" } @@ -5477,6 +5481,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@stripe/react-stripe-js": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-6.6.0.tgz", + "integrity": "sha512-utODPiu2/JGjCnh5BX1M1F2uyjCwDKum4Bo8CeWdTCNOlzM0980YadzBMe7YoIwjfu3uadX4PMe3L2SderejqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": ">=9.5.0 <10.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.8.0.tgz", + "integrity": "sha512-DHJpol/98VKyojNSYmpkB5vOMnlf87hPe0wPxyaYTNiTMk5QjKMXDfSZLwGctYIXAgAWDFeRABc8lFAj0BELyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", "dev": true, @@ -14662,9 +14691,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,12 +15335,15 @@ "@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/overlays": "^3.31.0", "@react-aria/toast": "^3.0.9", "@react-aria/utils": "^3.32.0", "@react-stately/toast": "^3.1.2", "@react-types/shared": "^3.32.1", + "@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", @@ -18005,6 +18037,21 @@ } } }, + "@stripe/react-stripe-js": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-6.6.0.tgz", + "integrity": "sha512-utODPiu2/JGjCnh5BX1M1F2uyjCwDKum4Bo8CeWdTCNOlzM0980YadzBMe7YoIwjfu3uadX4PMe3L2SderejqA==", + "dev": true, + "requires": { + "prop-types": "^15.7.2" + } + }, + "@stripe/stripe-js": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.8.0.tgz", + "integrity": "sha512-DHJpol/98VKyojNSYmpkB5vOMnlf87hPe0wPxyaYTNiTMk5QjKMXDfSZLwGctYIXAgAWDFeRABc8lFAj0BELyw==", + "dev": true + }, "@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", "dev": true