{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