You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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.
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.
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.
Copy file name to clipboardExpand all lines: easy-ui-react/src/Modal/Modal.mdx
+71Lines changed: 71 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -133,6 +133,77 @@ Use this sparingly. It trades away the modal's focus containment and background
133
133
</Modal.Trigger>
134
134
```
135
135
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.TriggerallowsThirdPartyOverlays>
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.TriggerchildNestingBehavior="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. */}
0 commit comments