{children}
diff --git a/easy-ui-react/src/Modal/ThirdPartyOverlayBoundary.tsx b/easy-ui-react/src/Modal/ThirdPartyOverlayBoundary.tsx
new file mode 100644
index 00000000..ff69b7de
--- /dev/null
+++ b/easy-ui-react/src/Modal/ThirdPartyOverlayBoundary.tsx
@@ -0,0 +1,41 @@
+import * as React from "react";
+import {
+ UseThirdPartyOverlaysOptions,
+ useThirdPartyOverlays,
+} from "./useThirdPartyOverlays";
+
+export type ThirdPartyOverlayBoundaryProps = {
+ /** The `Modal` (or any subtree) hosting the third-party widget. */
+ children: React.ReactNode;
+} & Omit
;
+
+/**
+ * Declarative wrapper around {@link useThirdPartyOverlays}. Wrap a `Modal` with
+ * it so a widget's body-level overlays stay interactive. Because `Modal` portals
+ * its content out of this subtree, the boundary doesn't rely on a ref — the hook
+ * tells inline widgets (inside the dialog) apart from escaped overlays
+ * automatically, so it works wherever it sits relative to the modal.
+ *
+ * It can wrap a `Modal` directly (as the modal child of `Modal.Trigger` /
+ * `ModalContainer`) or wrap the trigger/container itself — both work.
+ */
+export const ThirdPartyOverlayBoundary = ({
+ children,
+ selector,
+ filter,
+ minVisibleSizePx,
+ ...injectedProps
+}: ThirdPartyOverlayBoundaryProps) => {
+ useThirdPartyOverlays({ selector, filter, minVisibleSizePx });
+
+ // `Modal.Trigger` and `ModalContainer` clone their modal child to inject
+ // overlay props onto it. When the boundary *is* that child — i.e. it wraps a
+ // `Modal` directly — forward those injected props to the wrapped element so
+ // the boundary stays transparent and the `Modal` receives exactly what it
+ // would have without the wrapper.
+ if (React.isValidElement(children) && Object.keys(injectedProps).length > 0) {
+ return React.cloneElement(children, injectedProps);
+ }
+
+ return <>{children}>;
+};
diff --git a/easy-ui-react/src/Modal/topLayer.test.ts b/easy-ui-react/src/Modal/topLayer.test.ts
new file mode 100644
index 00000000..e6cbbc3d
--- /dev/null
+++ b/easy-ui-react/src/Modal/topLayer.test.ts
@@ -0,0 +1,116 @@
+import {
+ TOP_LAYER_ATTR,
+ bodyLevelAncestor,
+ claimTopLayer,
+ markElementAsTopLayer,
+ releaseTopLayer,
+ topLayerClaimCount,
+ unmarkElementAsTopLayer,
+} from "./topLayer";
+
+describe("topLayer", () => {
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ describe("markElementAsTopLayer", () => {
+ test("tags the node and reports a change", () => {
+ const el = document.createElement("div");
+ expect(markElementAsTopLayer(el)).toBe(true);
+ expect(el.getAttribute(TOP_LAYER_ATTR)).toBe("true");
+ });
+
+ test("clears react-aria's aria-hidden and inert", () => {
+ const el = document.createElement("div");
+ el.setAttribute("aria-hidden", "true");
+ el.inert = true;
+
+ expect(markElementAsTopLayer(el)).toBe(true);
+ expect(el.hasAttribute("aria-hidden")).toBe(false);
+ expect(el.inert).toBe(false);
+ });
+
+ test("is a no-op once the node is already exempt", () => {
+ const el = document.createElement("div");
+ markElementAsTopLayer(el);
+ // a second pass must report no change so observers don't loop
+ expect(markElementAsTopLayer(el)).toBe(false);
+ });
+ });
+
+ describe("unmarkElementAsTopLayer", () => {
+ test("removes the tag and reports a change", () => {
+ const el = document.createElement("div");
+ markElementAsTopLayer(el);
+
+ expect(unmarkElementAsTopLayer(el)).toBe(true);
+ expect(el.hasAttribute(TOP_LAYER_ATTR)).toBe(false);
+ });
+
+ test("is a no-op on an untagged node", () => {
+ const el = document.createElement("div");
+ expect(unmarkElementAsTopLayer(el)).toBe(false);
+ });
+
+ test("does not re-add inert / aria-hidden (react-aria owns those)", () => {
+ const el = document.createElement("div");
+ markElementAsTopLayer(el);
+ unmarkElementAsTopLayer(el);
+
+ expect(el.hasAttribute("aria-hidden")).toBe(false);
+ expect(el.inert).toBeFalsy();
+ });
+ });
+
+ describe("claimTopLayer / releaseTopLayer", () => {
+ test("tags on the first claim", () => {
+ const el = document.createElement("div");
+ claimTopLayer(el);
+ expect(el.getAttribute(TOP_LAYER_ATTR)).toBe("true");
+ expect(topLayerClaimCount(el)).toBe(1);
+ });
+
+ test("reverts only when the last claimant releases", () => {
+ const el = document.createElement("div");
+ claimTopLayer(el);
+ claimTopLayer(el);
+ expect(topLayerClaimCount(el)).toBe(2);
+
+ // first release: another claimant remains, so the tag stays
+ expect(releaseTopLayer(el)).toBe(false);
+ expect(el.getAttribute(TOP_LAYER_ATTR)).toBe("true");
+ expect(topLayerClaimCount(el)).toBe(1);
+
+ // last release: now it reverts
+ expect(releaseTopLayer(el)).toBe(true);
+ expect(el.hasAttribute(TOP_LAYER_ATTR)).toBe(false);
+ expect(topLayerClaimCount(el)).toBe(0);
+ });
+
+ test("releasing an unclaimed node is a no-op", () => {
+ const el = document.createElement("div");
+ expect(releaseTopLayer(el)).toBe(false);
+ expect(topLayerClaimCount(el)).toBe(0);
+ });
+ });
+
+ describe("bodyLevelAncestor", () => {
+ test("returns the direct child of containing the element", () => {
+ const container = document.createElement("div");
+ const middle = document.createElement("div");
+ const leaf = document.createElement("span");
+ middle.appendChild(leaf);
+ container.appendChild(middle);
+ document.body.appendChild(container);
+
+ expect(bodyLevelAncestor(leaf)).toBe(container);
+ });
+
+ test("returns the element itself when it is already a body child", () => {
+ const el = document.createElement("div");
+ document.body.appendChild(el);
+
+ expect(bodyLevelAncestor(el)).toBe(el);
+ });
+ });
+});
diff --git a/easy-ui-react/src/Modal/topLayer.ts b/easy-ui-react/src/Modal/topLayer.ts
new file mode 100644
index 00000000..7ded44d3
--- /dev/null
+++ b/easy-ui-react/src/Modal/topLayer.ts
@@ -0,0 +1,104 @@
+/**
+ * Top-layer DOM primitives
+ * ------------------------
+ * Low-level helpers for exempting a body-level node from react-aria's modal
+ * hiding/containment via the `data-react-aria-top-layer` opt-out. See the module
+ * overview in `index.ts` for the why.
+ */
+
+export const TOP_LAYER_ATTR = "data-react-aria-top-layer";
+
+/**
+ * Exempt a node from react-aria's modal hiding/containment. Returns whether
+ * anything actually changed — the no-op-when-clean result lets observers
+ * re-apply it on every mutation without looping.
+ */
+export const markElementAsTopLayer = (node: HTMLElement): boolean => {
+ let changed = false;
+ if (node.getAttribute(TOP_LAYER_ATTR) !== "true") {
+ node.setAttribute(TOP_LAYER_ATTR, "true");
+ changed = true;
+ }
+ if (node.hasAttribute("aria-hidden")) {
+ node.removeAttribute("aria-hidden");
+ changed = true;
+ }
+ // react-aria sets `inert` as a property (not only an attribute) when
+ // `shouldUseInert` is on, so release the property too.
+ if (node.inert) {
+ node.inert = false;
+ changed = true;
+ }
+ return changed;
+};
+
+/**
+ * Revert {@link markElementAsTopLayer}. Returns whether anything changed.
+ *
+ * We only strip the top-layer tag — we deliberately do NOT re-add `inert` /
+ * `aria-hidden`. This runs as the modal unmounts, when the whole page is
+ * becoming interactive again; react-aria owns those attributes and clears them
+ * on its own teardown. The tag, however, is ours, and if it lingers on a
+ * body-level node after the modal is gone, react-aria's `isElementInChildScope`
+ * keeps treating that subtree as part of a (now-destroyed) focus scope — which
+ * derails focus restore on close and corrupts the next modal that opens.
+ */
+export const unmarkElementAsTopLayer = (node: HTMLElement): boolean => {
+ if (node.getAttribute(TOP_LAYER_ATTR) === "true") {
+ node.removeAttribute(TOP_LAYER_ATTR);
+ return true;
+ }
+ return false;
+};
+
+/**
+ * Refcounted ownership of the top-layer tag.
+ *
+ * The overlay scan is page-wide (`document.body.querySelectorAll`), so two
+ * boundaries with the same selector both match — and both tag — the same
+ * body-level node. Each tracks the node in its own set, so without coordination
+ * the first one to unmount would strip a tag the other still needs, letting
+ * react-aria re-`inert` the node and re-lock the overlay. Refcounting fixes
+ * that: the tag is applied on the first claim and reverted only when the last
+ * claimant releases. A `WeakMap` keeps no detached nodes alive — entries vanish
+ * with the node.
+ */
+const topLayerClaims = new WeakMap();
+
+/**
+ * Claim the top-layer exemption for a node, applying the tag on the first claim.
+ * Re-asserts the tag on every claim (react-aria may have re-applied `inert`
+ * since); returns whether the tag actually changed.
+ */
+export const claimTopLayer = (node: HTMLElement): boolean => {
+ topLayerClaims.set(node, (topLayerClaims.get(node) ?? 0) + 1);
+ return markElementAsTopLayer(node);
+};
+
+/**
+ * Release one claim on a node. Reverts the node (and returns `true`) only when
+ * the last claimant releases; while other claimants remain, the tag is left in
+ * place and this returns `false`.
+ */
+export const releaseTopLayer = (node: HTMLElement): boolean => {
+ const count = topLayerClaims.get(node) ?? 0;
+ if (count <= 1) {
+ topLayerClaims.delete(node);
+ return unmarkElementAsTopLayer(node);
+ }
+ topLayerClaims.set(node, count - 1);
+ return false;
+};
+
+/** Current number of live claims on a node (`0` if none). */
+export const topLayerClaimCount = (node: HTMLElement): number =>
+ topLayerClaims.get(node) ?? 0;
+
+/** Climb to the element that is a direct child of ``. */
+export const bodyLevelAncestor = (el: HTMLElement): HTMLElement => {
+ let node = el;
+ while (node.parentElement && node.parentElement !== document.body) {
+ node = node.parentElement;
+ }
+ return node;
+};
diff --git a/easy-ui-react/src/Modal/useThirdPartyOverlays.test.tsx b/easy-ui-react/src/Modal/useThirdPartyOverlays.test.tsx
new file mode 100644
index 00000000..a9883c34
--- /dev/null
+++ b/easy-ui-react/src/Modal/useThirdPartyOverlays.test.tsx
@@ -0,0 +1,90 @@
+import { renderHook } from "@testing-library/react";
+import { TOP_LAYER_ATTR } from "./topLayer";
+import { useThirdPartyOverlays } from "./useThirdPartyOverlays";
+
+const STRIPE_SELECTOR = 'iframe[name^="__privateStripe"]';
+
+describe("useThirdPartyOverlays lifecycle", () => {
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ const mountStripeOverlay = () => {
+ const container = document.createElement("div");
+ const iframe = document.createElement("iframe");
+ iframe.setAttribute("name", "__privateStripeFrame123");
+ container.appendChild(iframe);
+ document.body.appendChild(container);
+ return { container, iframe };
+ };
+
+ test("tags an escaped overlay on mount and reverts it on unmount", () => {
+ const { container } = mountStripeOverlay();
+
+ const { unmount } = renderHook(() =>
+ useThirdPartyOverlays({ selector: STRIPE_SELECTOR }),
+ );
+
+ // the body-level ancestor of the Stripe iframe is exempted while mounted
+ expect(container.getAttribute(TOP_LAYER_ATTR)).toBe("true");
+
+ unmount();
+
+ // and the tag must not linger once the modal is gone
+ expect(container.hasAttribute(TOP_LAYER_ATTR)).toBe(false);
+ });
+
+ test("blurs focus stuck inside a tagged overlay on unmount", () => {
+ const { container } = mountStripeOverlay();
+ const input = document.createElement("input");
+ container.appendChild(input);
+
+ const { unmount } = renderHook(() =>
+ useThirdPartyOverlays({ selector: STRIPE_SELECTOR }),
+ );
+
+ input.focus();
+ expect(document.activeElement).toBe(input);
+
+ unmount();
+
+ // focus is released to so react-aria can restore it cleanly
+ expect(document.activeElement).not.toBe(input);
+ });
+
+ test("keeps a shared overlay tagged until the last boundary releases it", () => {
+ const { container } = mountStripeOverlay();
+
+ // two boundaries with the same selector both match the same escaped node
+ const a = renderHook(() =>
+ useThirdPartyOverlays({ selector: STRIPE_SELECTOR }),
+ );
+ const b = renderHook(() =>
+ useThirdPartyOverlays({ selector: STRIPE_SELECTOR }),
+ );
+
+ expect(container.getAttribute(TOP_LAYER_ATTR)).toBe("true");
+
+ // one unmounts — the other still needs the node exempted, so the tag stays
+ a.unmount();
+ expect(container.getAttribute(TOP_LAYER_ATTR)).toBe("true");
+
+ // the last one unmounts — only now is the tag reverted
+ b.unmount();
+ expect(container.hasAttribute(TOP_LAYER_ATTR)).toBe(false);
+ });
+
+ test("leaves overlays inside a dialog alone", () => {
+ const dialog = document.createElement("div");
+ dialog.setAttribute("role", "dialog");
+ const iframe = document.createElement("iframe");
+ iframe.setAttribute("name", "__privateStripeFrame123");
+ dialog.appendChild(iframe);
+ document.body.appendChild(dialog);
+
+ renderHook(() => useThirdPartyOverlays({ selector: STRIPE_SELECTOR }));
+
+ // widgets inside the dialog are already interactive; nothing to tag
+ expect(dialog.hasAttribute(TOP_LAYER_ATTR)).toBe(false);
+ });
+});
diff --git a/easy-ui-react/src/Modal/useThirdPartyOverlays.ts b/easy-ui-react/src/Modal/useThirdPartyOverlays.ts
new file mode 100644
index 00000000..5d99ec2d
--- /dev/null
+++ b/easy-ui-react/src/Modal/useThirdPartyOverlays.ts
@@ -0,0 +1,200 @@
+import * as React from "react";
+import {
+ bodyLevelAncestor,
+ claimTopLayer,
+ markElementAsTopLayer,
+ releaseTopLayer,
+ topLayerClaimCount,
+} from "./topLayer";
+
+type IgnoreRefs = React.RefObject | React.RefObject[];
+
+export type UseThirdPartyOverlaysOptions = {
+ /**
+ * CSS selector matching the overlay node(s) the widget appends to
+ * `document.body` (e.g. Stripe iframes, reCAPTCHA challenge iframes).
+ */
+ selector: string;
+ /**
+ * Optional ref(s) whose subtrees should be left untouched. Nodes inside a
+ * `[role="dialog"]` are already excluded automatically, so this is only needed
+ * for extra precision (e.g. an inline widget rendered outside any dialog).
+ */
+ ignoreWithin?: IgnoreRefs;
+ /**
+ * Optional extra predicate applied to each matched element, for cases a CSS
+ * selector can't express (size, attributes, etc.).
+ */
+ filter?: (element: HTMLElement) => boolean;
+ /**
+ * Minimum width/height (px) a matched node (or its body-level container) must
+ * have to count as a visible/open overlay for `isOverlayOpen`. Defaults to
+ * 100, which filters out the 0x0 controller iframes some widgets keep mounted.
+ */
+ minVisibleSizePx?: number;
+};
+
+const DEFAULT_MIN_VISIBLE_SIZE_PX = 100;
+
+// A matched node inside a dialog is already interactive — only nodes that
+// escaped the modal into `document.body` need rescuing. This lets the hook work
+// without a ref even when the modal's content is portalled away.
+const DIALOG_SELECTOR = '[role="dialog"], [role="alertdialog"]';
+
+const toArray = (refs?: IgnoreRefs): React.RefObject[] => {
+ if (!refs) {
+ return [];
+ }
+ return Array.isArray(refs) ? refs : [refs];
+};
+
+const hasSize = (el: HTMLElement, min: number) => {
+ const rect = el.getBoundingClientRect();
+ return rect.width >= min && rect.height >= min;
+};
+
+/**
+ * Watches `document.body` for third-party overlay nodes matching `selector` and
+ * tags them so they escape an Easy UI / react-aria modal's focus trap, `inert`,
+ * and `aria-hidden`. The modal stays mounted — nothing is closed or re-opened.
+ *
+ * @returns `isOverlayOpen` — true while a visibly-sized matched overlay exists,
+ * for callers that want to react (e.g. dim the modal). Tagging happens
+ * regardless of this value.
+ */
+export const useThirdPartyOverlays = ({
+ selector,
+ ignoreWithin,
+ filter,
+ minVisibleSizePx = DEFAULT_MIN_VISIBLE_SIZE_PX,
+}: UseThirdPartyOverlaysOptions) => {
+ const [isOverlayOpen, setIsOverlayOpen] = React.useState(false);
+
+ // Read the latest non-primitive options at scan time so the observer doesn't
+ // need to re-subscribe when callers pass inline refs/arrays/closures.
+ const optionsRef = React.useRef({ ignoreWithin, filter });
+ optionsRef.current = { ignoreWithin, filter };
+
+ // Every body-level node we've tagged, so we can revert them when the modal
+ // unmounts. Without this, stale `data-react-aria-top-layer` nodes linger in
+ // the body and react-aria keeps treating them as part of the destroyed focus
+ // scope — which is what locked the page up when the modal closed.
+ const taggedNodesRef = React.useRef>(new Set());
+
+ React.useEffect(() => {
+ if (typeof document === "undefined") {
+ return undefined;
+ }
+
+ // Capture the (stable) Set so the cleanup closure references the same
+ // instance the effect used, not whatever the ref points to at teardown.
+ const taggedNodes = taggedNodesRef.current;
+
+ const scan = () => {
+ const ignoreRefs = toArray(optionsRef.current.ignoreWithin);
+ const { filter: filterFn } = optionsRef.current;
+ const matches = Array.from(
+ document.body.querySelectorAll(selector),
+ );
+
+ const containers = new Set();
+ let overlayVisible = false;
+
+ matches.forEach((element) => {
+ // widgets rendered inside the dialog are already interactive; only
+ // overlays that escaped into the body need rescuing
+ if (element.closest(DIALOG_SELECTOR)) {
+ return;
+ }
+ // leave inline widgets that live inside an ignored subtree alone
+ if (ignoreRefs.some((ref) => ref.current?.contains(element))) {
+ return;
+ }
+ if (filterFn && !filterFn(element)) {
+ return;
+ }
+ const container = bodyLevelAncestor(element);
+ // never tag a subtree we were told to ignore (e.g. the modal's portal)
+ if (
+ ignoreRefs.some(
+ (ref) => ref.current && container.contains(ref.current),
+ )
+ ) {
+ return;
+ }
+ containers.add(container);
+ if (
+ hasSize(element, minVisibleSizePx) ||
+ hasSize(container, minVisibleSizePx)
+ ) {
+ overlayVisible = true;
+ }
+ });
+
+ containers.forEach((container) => {
+ if (taggedNodes.has(container)) {
+ // already our claim — re-assert the tag in case react-aria re-applied
+ // inert/aria-hidden since the last scan
+ markElementAsTopLayer(container);
+ } else {
+ taggedNodes.add(container);
+ claimTopLayer(container);
+ }
+ });
+ setIsOverlayOpen(overlayVisible);
+ };
+
+ scan();
+
+ // Coalesce mutation bursts into a single scan per frame. react-aria toggles
+ // `inert`/`aria-hidden` across the whole page when a modal opens/closes, so
+ // we deliberately do NOT observe attributes (that storm caused a hang) — we
+ // only watch childList. react-aria re-applies `inert` from its own childList
+ // observer, so a node addition is the only event we need to react to.
+ let frame = 0;
+ const schedule = () => {
+ if (frame) {
+ return;
+ }
+ frame = window.requestAnimationFrame(() => {
+ frame = 0;
+ scan();
+ });
+ };
+
+ const observer = new MutationObserver(schedule);
+ observer.observe(document.body, { childList: true, subtree: true });
+
+ return () => {
+ if (frame) {
+ window.cancelAnimationFrame(frame);
+ }
+ observer.disconnect();
+
+ // Release every node we claimed. Refcounting means a node shared with
+ // another mounted boundary keeps its tag until that boundary releases too
+ // — only the final claimant actually reverts it. If focus is inside a node
+ // we're truly reverting (e.g. the user closed the modal while a Stripe
+ // Link/OTP iframe was focused), blur it first: leaving focus in a
+ // top-layer subtree as the scope unmounts is exactly what sends
+ // react-aria's focus restore into a loop. Blurring drops focus to ,
+ // from which react-aria restores to the trigger cleanly. We skip the blur
+ // while another boundary still claims the node, since it stays live.
+ const active = document.activeElement;
+ taggedNodes.forEach((node) => {
+ const isLastClaim = topLayerClaimCount(node) <= 1;
+ if (
+ isLastClaim &&
+ active instanceof HTMLElement &&
+ node.contains(active)
+ ) {
+ active.blur();
+ }
+ releaseTopLayer(node);
+ });
+ taggedNodes.clear();
+ };
+ }, [selector, minVisibleSizePx]);
+
+ return { isOverlayOpen };
+};
diff --git a/package-lock.json b/package-lock.json
index 8f345e36..47fc1835 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -47,7 +47,7 @@
},
"easy-ui-icons": {
"name": "@easypost/easy-ui-icons",
- "version": "1.0.0-alpha.54",
+ "version": "1.0.0-alpha.55",
"devDependencies": {
"@material-symbols/svg-300": "^0.40.2",
"@material-symbols/svg-400": "^0.40.2",
@@ -127,14 +127,15 @@
},
"easy-ui-react": {
"name": "@easypost/easy-ui",
- "version": "1.0.0-alpha.117",
+ "version": "1.0.0-alpha.121",
"dependencies": {
- "@easypost/easy-ui-icons": "1.0.0-alpha.54",
+ "@easypost/easy-ui-icons": "1.0.0-alpha.55",
"@easypost/easy-ui-tokens": "1.0.0-alpha.17",
"@react-aria/toast": "^3.0.9",
"@react-aria/utils": "^3.32.0",
"@react-stately/toast": "^3.1.2",
"@react-types/shared": "^3.32.1",
+ "@testing-library/react-hooks": "^8.0.1",
"lodash": "^4.17.21",
"overlayscrollbars": "^2.3.0",
"overlayscrollbars-react": "^0.5.6",
@@ -681,10 +682,11 @@
}
},
"node_modules/@bundled-es-modules/deepmerge": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@bundled-es-modules/deepmerge/-/deepmerge-4.3.1.tgz",
- "integrity": "sha512-Rk453EklPUPC3NRWc3VUNI/SSUjdBaFoaQvFRmNBNtMHVtOFD5AntiWg5kEE1hqcPqedYFDzxE3ZcMYPcA195w==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@bundled-es-modules/deepmerge/-/deepmerge-4.3.2.tgz",
+ "integrity": "sha512-q8doe7ndrY2IolUOFIn0A0++JBX38pMhN7kFhTF4cnjIcILf6X6H2yWczInyv8ZFdR0lrE8088X8XS5efxXz8A==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"deepmerge": "^4.3.1"
}
@@ -5839,6 +5841,36 @@
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
"dev": true
},
+ "node_modules/@testing-library/react-hooks": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz",
+ "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "react-error-boundary": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "@types/react": "^16.9.0 || ^17.0.0",
+ "react": "^16.9.0 || ^17.0.0",
+ "react-dom": "^16.9.0 || ^17.0.0",
+ "react-test-renderer": "^16.9.0 || ^17.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ },
+ "react-test-renderer": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@testing-library/user-event": {
"version": "14.6.1",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
@@ -11683,6 +11715,22 @@
"react": "^19.2.4"
}
},
+ "node_modules/react-error-boundary": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
+ "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=10",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "react": ">=16.13.1"
+ }
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -14662,9 +14710,9 @@
}
},
"@bundled-es-modules/deepmerge": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/@bundled-es-modules/deepmerge/-/deepmerge-4.3.1.tgz",
- "integrity": "sha512-Rk453EklPUPC3NRWc3VUNI/SSUjdBaFoaQvFRmNBNtMHVtOFD5AntiWg5kEE1hqcPqedYFDzxE3ZcMYPcA195w==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@bundled-es-modules/deepmerge/-/deepmerge-4.3.2.tgz",
+ "integrity": "sha512-q8doe7ndrY2IolUOFIn0A0++JBX38pMhN7kFhTF4cnjIcILf6X6H2yWczInyv8ZFdR0lrE8088X8XS5efxXz8A==",
"dev": true,
"requires": {
"deepmerge": "^4.3.1"
@@ -15306,7 +15354,7 @@
"@easypost/easy-ui": {
"version": "file:easy-ui-react",
"requires": {
- "@easypost/easy-ui-icons": "1.0.0-alpha.54",
+ "@easypost/easy-ui-icons": "1.0.0-alpha.55",
"@easypost/easy-ui-tokens": "1.0.0-alpha.17",
"@react-aria/toast": "^3.0.9",
"@react-aria/utils": "^3.32.0",
@@ -15315,6 +15363,7 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.2",
+ "@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^14.6.1",
"@types/lodash": "^4.17.18",
"@types/react": "^19.2.14",
@@ -18196,6 +18245,15 @@
}
}
},
+ "@testing-library/react-hooks": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz",
+ "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==",
+ "requires": {
+ "@babel/runtime": "^7.12.5",
+ "react-error-boundary": "^3.1.0"
+ }
+ },
"@testing-library/user-event": {
"version": "14.6.1",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
@@ -22083,6 +22141,14 @@
"scheduler": "^0.27.0"
}
},
+ "react-error-boundary": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
+ "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==",
+ "requires": {
+ "@babel/runtime": "^7.12.5"
+ }
+ },
"react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",