Skip to content

Commit f17a7b7

Browse files
feat(Modal): add nesting behaviors and fix third-party overlay dismissal (#1896)
## 📝 Changes Tested thoroughly on EPWA with no noticeable negative effects. Adds control over how nested modals stack, and fixes a dismissal bug for modals that host third-party overlays. **Nesting behaviors (feature).** New optional `childNestingBehavior` and `selfNestingBehavior` props on `Modal.Trigger` and `ModalContainer` control how a modal stacks relative to the modal it's nested under. A connection resolves to one of: - `stack` (default) — both modals keep their own backdrops; matches today's behavior, so existing modals are unchanged. - `replace` — the nested modal hides the modal beneath it so only the topmost is visible. Configure from either end: `childNestingBehavior` is set on the parent and **cascades** down the tree; `selfNestingBehavior` is set on the child, is **local** (does not cascade), and overrides the parent's `childNestingBehavior` for that one connection — useful for surgically adjusting a single nested modal without touching its parent. **Third-party overlay dismissal (fix).** Previously the modal box was tagged `data-react-aria-top-layer`, which made react-aria treat every click inside the modal as "not outside," so nested react-aria overlays (e.g. `Select`, `Menu`) wouldn't close on outside click. The box now uses `data-live-announcer` instead, which `ariaHideOutside` honors to keep the box visible under a surrounding modal **without** breaking outside-click dismissal. A new global `shouldCloseOnInteractOutside` also ensures clicking a nested modal never dismisses the one beneath it, while backdrop clicks and genuine third-party overlays (e.g. Stripe) still behave correctly. **Notes for reviewers** - No breaking changes. The new props are optional and default to today's behavior. Removed context fields (`hasOpenNestedModal`/`setHasOpenNestedModal`) were internal-only and unused on `main`. - New nesting logic is isolated in a dedicated `useModalNesting` hook; the only shared-path change (`shouldCloseOnInteractOutside`) is behavior-preserving for existing single modals (verified: clicking a `Select` option or the modal body does not dismiss a standard modal; backdrop dismissal is unchanged). - Try the **Modal → Nested** story; the `childNestingBehavior` control switches between all three modes live. ## ✅ Checklist Easy UI has certain UX standards that must be met. In general, non-trivial changes should meet the following criteria: - [x] Visuals match Design Specs in Figma - [x] Stories accompany any component changes - [x] Code is in accordance with our style guide - [x] Design tokens are utilized - [x] Unit tests accompany any component changes - [x] TSDoc is written for any API surface area - [x] Specs are up-to-date - [x] Console is free from warnings - [x] No accessibility violations are reported - [x] Cross-browser check is performed (Chrome, Safari, Firefox) - [x] Changeset is added
1 parent 79e1ab2 commit f17a7b7

14 files changed

Lines changed: 1441 additions & 62 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@easypost/easy-ui": patch
3+
---
4+
5+
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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@easypost/easy-ui": patch
3+
---
4+
5+
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.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@easypost/easy-ui": minor
3+
---
4+
5+
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).
6+
7+
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.

easy-ui-react/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"test:watch": "vitest"
3030
},
3131
"dependencies": {
32+
"@react-aria/overlays": "^3.31.0",
3233
"@easypost/easy-ui-icons": "1.0.0-alpha.55",
3334
"@easypost/easy-ui-tokens": "1.0.0-alpha.17",
3435
"@react-aria/toast": "^3.0.9",

easy-ui-react/src/Modal/Modal.mdx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,77 @@ Use this sparingly. It trades away the modal's focus containment and background
133133
</Modal.Trigger>
134134
```
135135

136+
### Nesting a third-party overlay under another modal
137+
138+
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.
139+
140+
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.
141+
142+
```tsx
143+
{/* The outer modal relaxes automatically while the nested Stripe modal is open. */}
144+
<Modal.Trigger>
145+
<Button>Open</Button>
146+
<Modal>
147+
<Modal.Header>Billing</Modal.Header>
148+
<Modal.Body>
149+
<Modal.Trigger allowsThirdPartyOverlays>
150+
<Button>Add card</Button>
151+
<Modal>
152+
<Modal.Header>New Credit Card</Modal.Header>
153+
<Modal.Body>{/* Stripe CardElement, etc. */}</Modal.Body>
154+
</Modal>
155+
</Modal.Trigger>
156+
</Modal.Body>
157+
</Modal>
158+
</Modal.Trigger>
159+
```
160+
161+
## Nesting behavior
162+
163+
When modals nest, the connection between a parent modal and its nested child can present in one of two ways:
164+
165+
- `stack` (the default) — both modals keep their own backdrops; modals simply stack.
166+
- `replace` — the nested modal hides the modal beneath it, so only the topmost modal is visible.
167+
168+
You configure that connection from either end, on `<Modal.Trigger />` or `<ModalContainer />`:
169+
170+
- **`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.
171+
- **`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.
172+
173+
```tsx
174+
{/* Set once at the top; every nested modal replaces the one beneath it. */}
175+
<Modal.Trigger childNestingBehavior="replace">
176+
<Button>Open modal</Button>
177+
<Modal>
178+
<Modal.Header>Title</Modal.Header>
179+
<Modal.Body>{/* each nested modal takes over the one beneath it */}</Modal.Body>
180+
</Modal>
181+
</Modal.Trigger>
182+
```
183+
184+
### Configuring a single nested modal
185+
186+
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.
187+
188+
```tsx
189+
<Modal.Trigger>
190+
<Button>Open parent</Button>
191+
<Modal>
192+
<Modal.Header>Parent</Modal.Header>
193+
<Modal.Body>
194+
{/* This nested modal takes over the parent without touching the parent. */}
195+
<Modal.Trigger selfNestingBehavior="replace">
196+
<Button>Open nested</Button>
197+
<Modal>
198+
<Modal.Header>Nested</Modal.Header>
199+
<Modal.Body>{/**/}</Modal.Body>
200+
</Modal>
201+
</Modal.Trigger>
202+
</Modal.Body>
203+
</Modal>
204+
</Modal.Trigger>
205+
```
206+
136207
## Properties
137208

138209
### Modal.Trigger

0 commit comments

Comments
 (0)