-
-
- {children}
-
+ // We manage focus with our own `FocusScope` instead of `Overlay`'s, because
+ // every Easy UI `Modal` calls react-aria's `useDialog`, which force-enables
+ // `Overlay`'s focus containment (via `useOverlayFocusContain`) regardless of
+ // `shouldContainFocus`. With `disableFocusManagement`, `Overlay` still
+ // portals and restores nothing itself; our `FocusScope`'s `contain` prop is
+ // the single, reactive source of truth — and toggling a prop (unlike
+ // wrapping/unwrapping a `FocusScope`) doesn't remount children, so a modal
+ // can relax focus in place when a third-party descendant opens.
+
+
+
-
+
);
}
diff --git a/easy-ui-react/src/Modal/context.tsx b/easy-ui-react/src/Modal/context.tsx
index 9b97ab9b..4ade67b5 100644
--- a/easy-ui-react/src/Modal/context.tsx
+++ b/easy-ui-react/src/Modal/context.tsx
@@ -4,12 +4,29 @@ 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.
+ * - `replace` — the nested modal hides the modal beneath it, so only the topmost
+ * modal is visible.
+ */
+export type ModalNestingBehavior = "stack" | "replace";
+
export type ModalContextType = {
dialogProps: DOMAttributes
;
titleProps: DOMAttributes;
@@ -23,8 +40,45 @@ export type ModalContextType = {
type ModalTriggerContextType = {
isDismissable: boolean;
state: OverlayTriggerState;
- hasOpenNestedModal: boolean;
- setHasOpenNestedModal: (hasOpenNestedModal: boolean) => void;
+ /**
+ * 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 +86,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 +133,153 @@ 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` 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";
+
+ // 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(
+ () => ({
+ hasReplacingChild: replacingChildCount > 0,
+ childNestingBehavior: childNesting,
+ selfNestingBehavior: selfNesting,
+ registerNested,
+ hasOpenThirdPartyDescendant: thirdPartyDescendantCount > 0,
+ registerThirdPartyOverlay,
+ }),
+ [
+ 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..65f2051d 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",
@@ -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"
}
@@ -14662,9 +14664,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,8 +15308,9 @@
"@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",