Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/modal-nested-third-party-overlays.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/modal-third-party-overlay-dismiss.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/nested-modal-single-backdrop.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions easy-ui-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
72 changes: 72 additions & 0 deletions easy-ui-react/src/Modal/Modal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,78 @@ Use this sparingly. It trades away the modal's focus containment and background
</Modal.Trigger>
```

### 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. */}
<Modal.Trigger>
<Button>Open</Button>
<Modal>
<Modal.Header>Billing</Modal.Header>
<Modal.Body>
<Modal.Trigger allowsThirdPartyOverlays>
<Button>Add card</Button>
<Modal>
<Modal.Header>New Credit Card</Modal.Header>
<Modal.Body>{/* Stripe CardElement, etc. */}</Modal.Body>
</Modal>
</Modal.Trigger>
</Modal.Body>
</Modal>
</Modal.Trigger>
```

## 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 `<Modal.Trigger />` or `<ModalContainer />`:

- **`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. */}
<Modal.Trigger childNestingBehavior="stack-shared-backdrop">
<Button>Open modal</Button>
<Modal>
<Modal.Header>Title</Modal.Header>
<Modal.Body>{/* nested modals stay visible with a single backdrop */}</Modal.Body>
</Modal>
</Modal.Trigger>
```

### 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
<Modal.Trigger>
<Button>Open parent</Button>
<Modal>
<Modal.Header>Parent</Modal.Header>
<Modal.Body>
{/* This nested modal takes over the parent without touching the parent. */}
<Modal.Trigger selfNestingBehavior="replace">
<Button>Open nested</Button>
<Modal>
<Modal.Header>Nested</Modal.Header>
<Modal.Body>{/* … */}</Modal.Body>
</Modal>
</Modal.Trigger>
</Modal.Body>
</Modal>
</Modal.Trigger>
```

## Properties

### Modal.Trigger
Expand Down
6 changes: 6 additions & 0 deletions easy-ui-react/src/Modal/Modal.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading