Skip to content
Merged
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-behavior.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) 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.
1 change: 1 addition & 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 Down
71 changes: 71 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,77 @@ 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 two ways:

- `stack` (the default) β€” 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.

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; every nested modal replaces the one beneath it. */}
<Modal.Trigger childNestingBehavior="replace">
<Button>Open modal</Button>
<Modal>
<Modal.Header>Title</Modal.Header>
<Modal.Body>{/* each nested modal takes over the one beneath it */}</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
Loading
Loading