@trackunit/react-modal 1.21.4 → 1.21.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.cjs.js +264 -159
- package/index.esm.js +268 -163
- package/package.json +4 -4
- package/src/modal/Modal.d.ts +14 -7
- package/src/modal/Modal.variants.d.ts +24 -2
- package/src/modal/modal-mode-switch-reducer.d.ts +18 -0
- package/src/modal/modalStackRegistry.d.ts +0 -5
- package/src/modal/useModal.d.ts +46 -94
- package/src/modal/useModalFooterBorder.d.ts +3 -10
- package/src/modal/useModalStack.d.ts +4 -5
package/index.cjs.js
CHANGED
|
@@ -92,30 +92,49 @@ const modalSizeConfigs = {
|
|
|
92
92
|
maxHeight: "min(80dvh, 1000px)",
|
|
93
93
|
},
|
|
94
94
|
};
|
|
95
|
+
const viewportPercentToContainerUnits = (viewportWidthPercent) => viewportWidthPercent.replaceAll("dvw", "cqw");
|
|
95
96
|
/**
|
|
96
97
|
* Returns the CSS properties for the modal card based on the size.
|
|
97
98
|
*/
|
|
98
|
-
const getModalCardCSSVariables = (size) => {
|
|
99
|
+
const getModalCardCSSVariables = (size, options) => {
|
|
99
100
|
const config = modalSizeConfigs[size];
|
|
101
|
+
const useContainerWidthUnits = options?.useContainerWidthUnits === true;
|
|
102
|
+
const viewportWidthPercent = useContainerWidthUnits
|
|
103
|
+
? viewportPercentToContainerUnits(config.viewportWidthPercent)
|
|
104
|
+
: config.viewportWidthPercent;
|
|
105
|
+
const viewportSpan = useContainerWidthUnits ? "100cqw" : "100dvw";
|
|
100
106
|
// eslint-disable-next-line @trackunit/no-typescript-assertion
|
|
101
107
|
return {
|
|
102
108
|
"--modal-min-width": config.minWidth,
|
|
103
|
-
"--modal-viewport-width-percent":
|
|
109
|
+
"--modal-viewport-width-percent": viewportWidthPercent,
|
|
104
110
|
"--modal-padding-offset": config.paddingOffset,
|
|
105
111
|
"--modal-breakpoint": config.breakpoint,
|
|
106
112
|
"--modal-transition-rate": config.transitionRate,
|
|
107
113
|
"--modal-max-width": config.maxWidth,
|
|
108
114
|
"--modal-max-height": config.maxHeight,
|
|
115
|
+
"--modal-viewport-span": viewportSpan,
|
|
109
116
|
};
|
|
110
117
|
};
|
|
118
|
+
/**
|
|
119
|
+
* Layout contract that modal children depend on. Applied to the children's
|
|
120
|
+
* immediate container in both card mode (the Card) and sheet mode (the
|
|
121
|
+
* aria-modal wrapper). Ensures ModalHeader/ModalBody/ModalFooter experience
|
|
122
|
+
* an identical flex/container-query environment regardless of rendering mode.
|
|
123
|
+
*
|
|
124
|
+
* Does NOT include overflow-y — scrolling is owned by the parent:
|
|
125
|
+
* in card mode by the Card element (`cvaModalCard`), in sheet mode by the
|
|
126
|
+
* Sheet's scroll area. A nested overflow here would absorb content height,
|
|
127
|
+
* preventing Sheet's fit-height measurement from seeing the true size.
|
|
128
|
+
*/
|
|
129
|
+
const cvaModalContentEnvironment = cssClassVarianceUtilities.cvaMerge(["flex", "flex-col", "min-h-0", "@container"]);
|
|
111
130
|
const cvaModalCard = cssClassVarianceUtilities.cvaMerge([
|
|
112
131
|
"m-auto",
|
|
113
132
|
"shadow-2xl",
|
|
133
|
+
"min-h-0",
|
|
114
134
|
"overflow-y-auto",
|
|
115
|
-
"w-[clamp(var(--modal-min-width),calc(var(--modal-viewport-width-percent)-var(--modal-padding-offset)-((
|
|
135
|
+
"w-[clamp(var(--modal-min-width),calc(var(--modal-viewport-width-percent)-var(--modal-padding-offset)-((var(--modal-viewport-span)-var(--modal-breakpoint))*var(--modal-transition-rate))),var(--modal-max-width))]",
|
|
116
136
|
"max-h-[var(--modal-max-height)]",
|
|
117
|
-
"@container",
|
|
118
|
-
// Re-enable pointer events for modal content (container has pointer-events-none)
|
|
137
|
+
"@container",
|
|
119
138
|
"pointer-events-auto",
|
|
120
139
|
], {
|
|
121
140
|
variants: {
|
|
@@ -124,25 +143,20 @@ const cvaModalCard = cssClassVarianceUtilities.cvaMerge([
|
|
|
124
143
|
fade: "animate-fade-in-fast",
|
|
125
144
|
// Stacked modals: fade + rise from below
|
|
126
145
|
rise: "animate-fade-in-rising",
|
|
146
|
+
// No animation (used during formfactor transitions to avoid flicker)
|
|
147
|
+
none: [],
|
|
127
148
|
},
|
|
128
149
|
},
|
|
129
150
|
defaultVariants: {
|
|
130
151
|
animation: "fade",
|
|
131
152
|
},
|
|
132
153
|
});
|
|
133
|
-
const cvaModalBackdrop = cssClassVarianceUtilities.cvaMerge([
|
|
134
|
-
"flex",
|
|
135
|
-
"justify-center",
|
|
136
|
-
// "items-center",
|
|
137
|
-
"fixed",
|
|
138
|
-
"inset-0",
|
|
139
|
-
"min-h-[100dvh]",
|
|
140
|
-
"min-w-[100dvw]",
|
|
141
|
-
"w-full",
|
|
142
|
-
"h-full",
|
|
143
|
-
"z-overlay",
|
|
144
|
-
], {
|
|
154
|
+
const cvaModalBackdrop = cssClassVarianceUtilities.cvaMerge(["flex", "justify-center", "inset-0", "w-full", "h-full", "z-overlay"], {
|
|
145
155
|
variants: {
|
|
156
|
+
contained: {
|
|
157
|
+
true: ["absolute"],
|
|
158
|
+
false: ["fixed", "min-h-[100dvh]", "min-w-[100dvw]"],
|
|
159
|
+
},
|
|
146
160
|
isFrontmost: {
|
|
147
161
|
// Frontmost modal shows backdrop
|
|
148
162
|
true: ["bg-black/50", "backdrop-saturate-150"],
|
|
@@ -161,118 +175,72 @@ const cvaModalBackdrop = cssClassVarianceUtilities.cvaMerge([
|
|
|
161
175
|
},
|
|
162
176
|
});
|
|
163
177
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
178
|
+
/** Reducer tracking formfactor transitions (card/sheet) while the modal is open. */
|
|
179
|
+
const modalModeSwitchReducer = (state, action) => {
|
|
180
|
+
if (action.mode === state.prevMode && action.isOpen === state.prevIsOpen) {
|
|
181
|
+
return state;
|
|
167
182
|
}
|
|
168
|
-
return
|
|
169
|
-
|
|
183
|
+
return {
|
|
184
|
+
prevMode: action.mode,
|
|
185
|
+
prevIsOpen: action.isOpen,
|
|
186
|
+
isModeSwitching: action.mode !== state.prevMode && action.isOpen && state.prevIsOpen
|
|
187
|
+
? true
|
|
188
|
+
: !action.isOpen
|
|
189
|
+
? false
|
|
190
|
+
: state.isModeSwitching,
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
|
|
170
194
|
/**
|
|
171
195
|
* Observes the modal body within the given root element and toggles a top border on the footer
|
|
172
196
|
* when the body becomes vertically scrollable.
|
|
173
197
|
*
|
|
174
|
-
*
|
|
175
|
-
* - Locates elements via stable data-attributes (by default: [data-modal-body], [data-modal-footer]).
|
|
176
|
-
* - Recomputes on resize, DOM mutations within the body, scroll events, and image load/error events.
|
|
177
|
-
* - Cleans up all observers/listeners on unmount or dependency change.
|
|
178
|
-
*
|
|
179
|
-
* Edge cases:
|
|
180
|
-
* - If either body or footer is missing, the hook does nothing.
|
|
181
|
-
* - No DOM mutations occur when `enabled` is false.
|
|
198
|
+
* Thin wrapper around `useOverflowBorder` with modal-specific defaults.
|
|
182
199
|
*
|
|
183
200
|
* @param rootRef Root element that contains both the modal body and footer.
|
|
184
201
|
* @param options Optional configuration.
|
|
185
|
-
* @param options.footerClass CSS class(es) toggled on the footer when the body is scrollable.
|
|
202
|
+
* @param options.footerClass CSS class(es) toggled on the footer when the body is scrollable. Defaults to "border-t".
|
|
186
203
|
* @param options.enabled Whether the hook is active. Defaults to true.
|
|
187
204
|
* @param options.bodySelector CSS selector to locate the body element. Defaults to "[data-modal-body]".
|
|
188
205
|
* @param options.footerSelector CSS selector to locate the footer element. Defaults to "[data-modal-footer]".
|
|
189
206
|
*/
|
|
190
207
|
const useModalFooterBorder = (rootRef, { footerClass = "border-t", enabled = true, bodySelector = "[data-modal-body]", footerSelector = "[data-modal-footer]", } = {}) => {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
? footerClass.filter(Boolean)
|
|
198
|
-
: String(footerClass).split(/\s+/).filter(Boolean);
|
|
199
|
-
let attempts = 0;
|
|
200
|
-
const MAX_ATTEMPTS = 20;
|
|
201
|
-
const tryInit = () => {
|
|
202
|
-
if (cancelled)
|
|
203
|
-
return;
|
|
204
|
-
const root = resolveRootEl(rootRef);
|
|
205
|
-
if (!root) {
|
|
206
|
-
if (attempts++ < MAX_ATTEMPTS)
|
|
207
|
-
requestAnimationFrame(tryInit);
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
const bodyEl = root.querySelector(bodySelector);
|
|
211
|
-
const footerEl = root.querySelector(footerSelector);
|
|
212
|
-
if (!bodyEl || !footerEl) {
|
|
213
|
-
if (attempts++ < MAX_ATTEMPTS)
|
|
214
|
-
requestAnimationFrame(tryInit);
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
const update = () => {
|
|
218
|
-
const hasScrollbar = bodyEl.scrollHeight > bodyEl.clientHeight;
|
|
219
|
-
classes.forEach(cls => footerEl.classList.toggle(cls, hasScrollbar));
|
|
220
|
-
};
|
|
221
|
-
update();
|
|
222
|
-
const ro = new ResizeObserver(update);
|
|
223
|
-
ro.observe(bodyEl);
|
|
224
|
-
const attachImgListeners = (node) => {
|
|
225
|
-
const imgs = node.querySelectorAll("img"); // NodeListOf<HTMLImageElement>
|
|
226
|
-
imgs.forEach(img => {
|
|
227
|
-
if (!img.complete) {
|
|
228
|
-
const once = () => update();
|
|
229
|
-
img.addEventListener("load", once, { once: true });
|
|
230
|
-
img.addEventListener("error", once, { once: true });
|
|
231
|
-
}
|
|
232
|
-
});
|
|
233
|
-
};
|
|
234
|
-
attachImgListeners(bodyEl);
|
|
235
|
-
const mo = new MutationObserver(muts => {
|
|
236
|
-
update();
|
|
237
|
-
for (const m of muts) {
|
|
238
|
-
m.addedNodes.forEach(n => {
|
|
239
|
-
if (n instanceof HTMLImageElement) {
|
|
240
|
-
if (!n.complete) {
|
|
241
|
-
const once = () => update();
|
|
242
|
-
n.addEventListener("load", once, { once: true });
|
|
243
|
-
n.addEventListener("error", once, { once: true });
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
else if (n instanceof HTMLElement) {
|
|
247
|
-
attachImgListeners(n);
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
});
|
|
252
|
-
mo.observe(bodyEl, { childList: true, subtree: true, characterData: true });
|
|
253
|
-
bodyEl.addEventListener("scroll", update, { passive: true });
|
|
254
|
-
cleanup = () => {
|
|
255
|
-
ro.disconnect();
|
|
256
|
-
mo.disconnect();
|
|
257
|
-
bodyEl.removeEventListener("scroll", update);
|
|
258
|
-
classes.forEach(cls => footerEl.classList.remove(cls));
|
|
259
|
-
};
|
|
260
|
-
};
|
|
261
|
-
requestAnimationFrame(tryInit);
|
|
262
|
-
return () => {
|
|
263
|
-
cancelled = true;
|
|
264
|
-
cleanup?.();
|
|
265
|
-
};
|
|
266
|
-
}, [enabled, footerClass, bodySelector, footerSelector, rootRef]);
|
|
208
|
+
reactComponents.useOverflowBorder(rootRef, {
|
|
209
|
+
scrollAreaSelector: bodySelector,
|
|
210
|
+
targetSelector: footerSelector,
|
|
211
|
+
toggleClass: footerClass,
|
|
212
|
+
enabled,
|
|
213
|
+
});
|
|
267
214
|
};
|
|
268
215
|
|
|
269
216
|
/** Scale factor per modal depth level (5% smaller per level back) */
|
|
270
217
|
const SCALE_FACTOR_PER_LEVEL = 0.05;
|
|
218
|
+
/** Maximum number of stacked modals visible behind the frontmost one. */
|
|
219
|
+
const MAX_VISIBLE_STACK_BEHIND = 2;
|
|
220
|
+
/**
|
|
221
|
+
* Inline styles for the Sheet container element in sheet mode.
|
|
222
|
+
* pointer-events: none lets interactions pass through when Sheet returns null
|
|
223
|
+
* (shouldRender=false). Sheet's inner panel has pointer-events-auto so it
|
|
224
|
+
* remains interactive when open.
|
|
225
|
+
*/
|
|
226
|
+
const SHEET_CONTAINER_STYLE = {
|
|
227
|
+
bottom: 0,
|
|
228
|
+
containerType: "size",
|
|
229
|
+
left: 0,
|
|
230
|
+
pointerEvents: "none",
|
|
231
|
+
position: "fixed",
|
|
232
|
+
right: 0,
|
|
233
|
+
top: 0,
|
|
234
|
+
zIndex: "var(--z-overlay)",
|
|
235
|
+
};
|
|
271
236
|
/**
|
|
272
237
|
* Modal presents critical information or requests user input in an overlay dialog that interrupts the current workflow.
|
|
273
238
|
* It renders inside a Portal with a backdrop overlay, focus trapping, and proper accessibility roles.
|
|
274
239
|
* Modals must always be used together with the `useModal` hook, which manages open/close state and Floating UI integration.
|
|
275
240
|
*
|
|
241
|
+
* When the container width is below the "sm" breakpoint (480px), the Modal
|
|
242
|
+
* automatically renders as a bottom Sheet with gesture support.
|
|
243
|
+
*
|
|
276
244
|
* Compose the modal body with `ModalHeader`, `ModalBody`, and `ModalFooter` for consistent structure.
|
|
277
245
|
*
|
|
278
246
|
* ### When to use
|
|
@@ -303,21 +271,99 @@ const SCALE_FACTOR_PER_LEVEL = 0.05;
|
|
|
303
271
|
* @param {ModalProps} props - The props for the Modal component
|
|
304
272
|
* @returns {ReactElement} Modal component
|
|
305
273
|
*/
|
|
306
|
-
const Modal = ({ children, isOpen, role = "dialog", "data-testid": dataTestId, className, size, floatingUi, ref, restoreFocus = true,
|
|
307
|
-
|
|
308
|
-
// is viewport-centered via CSS, not positioned relative to a reference element.
|
|
309
|
-
// See: https://floating-ui.com/docs/dialog
|
|
274
|
+
const Modal = ({ children, container, dismiss, isOpen, mode, requestClose, role = "dialog", "data-testid": dataTestId, className, size, stack, floatingUi, ref, sheetDefaultSize = "fit", restoreFocus = true, onCloseComplete, }) => {
|
|
275
|
+
const { depthFromFront, stackSizeAtOpen } = stack;
|
|
310
276
|
const { rootElement, refs, context, getFloatingProps } = floatingUi;
|
|
311
277
|
const cardRef = react.useRef(null);
|
|
312
|
-
// Merge the custom ref from useModal with FloatingUI's setFloating ref
|
|
313
278
|
const mergedRef = react$1.useMergeRefs([refs.setFloating, ref]);
|
|
314
|
-
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
279
|
+
const sheetRef = react.useRef(null);
|
|
280
|
+
const sheetRefCallback = react.useCallback((el) => {
|
|
281
|
+
sheetRef.current = el;
|
|
282
|
+
}, []);
|
|
283
|
+
const sheetPanelRef = react$1.useMergeRefs([refs.setFloating, sheetRefCallback]);
|
|
284
|
+
const handleSheetCloseComplete = react.useCallback(() => {
|
|
285
|
+
onCloseComplete();
|
|
286
|
+
}, [onCloseComplete]);
|
|
287
|
+
// Container element for the Sheet when no external container is provided.
|
|
288
|
+
// Uses useState as a callback ref so mounting triggers a re-render.
|
|
289
|
+
// The div is kept alive for the full duration mode is "sheet" so that
|
|
290
|
+
// the Sheet's close animation can complete inside it.
|
|
291
|
+
const [sheetContainer, setSheetContainer] = react.useState(null);
|
|
292
|
+
useModalFooterBorder(mode === "sheet" ? sheetRef : cardRef, { enabled: isOpen, footerClass: "border-t pt-4" });
|
|
293
|
+
const isVisibleInStack = depthFromFront <= MAX_VISIBLE_STACK_BEHIND;
|
|
294
|
+
// Detect formfactor transitions (card ↔ sheet) while the modal is already open.
|
|
295
|
+
const [modeState, dispatchMode] = react.useReducer(modalModeSwitchReducer, {
|
|
296
|
+
prevMode: mode,
|
|
297
|
+
prevIsOpen: isOpen,
|
|
298
|
+
isModeSwitching: false,
|
|
299
|
+
});
|
|
300
|
+
if (mode !== modeState.prevMode || isOpen !== modeState.prevIsOpen) {
|
|
301
|
+
dispatchMode({ type: "SYNC", mode, isOpen });
|
|
302
|
+
}
|
|
303
|
+
const { state: sheetState, snap: sheetSnap, onGeometryChange: sheetOnGeometryChange, onSnap: sheetOnSnap, onClickHandle: sheetOnClickHandle, } = reactComponents.useSheetSnap({ defaultSize: sheetDefaultSize, isOpen });
|
|
304
|
+
const onCloseGesture = react.useMemo(() => (dismiss.gesture ? () => requestClose(undefined, "gesture") : undefined), [dismiss.gesture, requestClose]);
|
|
305
|
+
// In card mode, there is no close animation so notify useModal immediately
|
|
306
|
+
// so it can call closeModal() without delay.
|
|
307
|
+
// In sheet mode, Sheet's onCloseComplete handles this instead.
|
|
308
|
+
reactComponents.useWatch({
|
|
309
|
+
value: isOpen,
|
|
310
|
+
onChange: (current, prev) => {
|
|
311
|
+
if (!current && prev && mode === "card") {
|
|
312
|
+
onCloseComplete();
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
const stackScale = 1 - Math.min(depthFromFront, MAX_VISIBLE_STACK_BEHIND) * SCALE_FACTOR_PER_LEVEL;
|
|
317
|
+
const sheetContainerStyle = react.useMemo(() => {
|
|
318
|
+
const style = {
|
|
319
|
+
...SHEET_CONTAINER_STYLE,
|
|
320
|
+
...(container !== undefined ? { position: "absolute" } : {}),
|
|
321
|
+
"--sheet-stack-scale": `${stackScale}`,
|
|
322
|
+
};
|
|
323
|
+
return style;
|
|
324
|
+
}, [container, stackScale]);
|
|
325
|
+
const isContained = container?.current !== null && container?.current !== undefined;
|
|
326
|
+
const cardModeInner = react.useMemo(() => {
|
|
327
|
+
if (mode !== "card" || !isOpen || !isVisibleInStack) {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
return (jsxRuntime.jsx(react$1.FloatingFocusManager, { context: context, restoreFocus: restoreFocus, children: jsxRuntime.jsx("div", { "aria-modal": true, className: cvaModalContainer(), ref: mergedRef, role: role, style: depthFromFront === 0 ? undefined : { transform: `scale(${1 - depthFromFront * SCALE_FACTOR_PER_LEVEL})` }, ...getFloatingProps(), children: jsxRuntime.jsx(reactComponents.Card, { className: cvaModalCard({
|
|
331
|
+
className,
|
|
332
|
+
animation: modeState.isModeSwitching ? "none" : stackSizeAtOpen === 0 ? "fade" : "rise",
|
|
333
|
+
}), "data-modal-content": true, "data-testid": dataTestId, ref: cardRef, style: getModalCardCSSVariables(size, { useContainerWidthUnits: container !== undefined }), children: children }) }) }));
|
|
334
|
+
}, [
|
|
335
|
+
mode,
|
|
336
|
+
isOpen,
|
|
337
|
+
isVisibleInStack,
|
|
338
|
+
context,
|
|
339
|
+
restoreFocus,
|
|
340
|
+
mergedRef,
|
|
341
|
+
role,
|
|
342
|
+
depthFromFront,
|
|
343
|
+
getFloatingProps,
|
|
344
|
+
className,
|
|
345
|
+
modeState.isModeSwitching,
|
|
346
|
+
stackSizeAtOpen,
|
|
347
|
+
dataTestId,
|
|
348
|
+
size,
|
|
349
|
+
container,
|
|
350
|
+
children,
|
|
351
|
+
]);
|
|
352
|
+
if (mode === "sheet") {
|
|
353
|
+
return (jsxRuntime.jsxs(reactComponents.Portal, { root: rootElement, children: [isContained ? (jsxRuntime.jsx(reactComponents.Portal, { root: container.current, children: jsxRuntime.jsx("div", { ref: setSheetContainer, style: sheetContainerStyle }) })) : container === undefined ? (jsxRuntime.jsx("div", { ref: setSheetContainer, style: sheetContainerStyle })) : null, jsxRuntime.jsx(reactComponents.Sheet, { container: sheetContainer, "data-testid": dataTestId, entryAnimation: modeState.isModeSwitching ? "never" : "always", isOpen: isOpen, onClickHandle: sheetOnClickHandle, onCloseComplete: handleSheetCloseComplete, onCloseGesture: onCloseGesture, onGeometryChange: sheetOnGeometryChange, onSnap: sheetOnSnap, ref: sheetPanelRef, snap: sheetSnap, snapping: true, state: sheetState, trapFocus: false, variant: depthFromFront === 0 ? "modal" : "default", children: jsxRuntime.jsx(react$1.FloatingFocusManager, { context: context, restoreFocus: restoreFocus, children: jsxRuntime.jsx("div", { "aria-modal": true, className: cvaModalContentEnvironment({ className: "flex-grow" }), "data-modal-content": true, ref: ref, role: role, ...getFloatingProps(), children: children }) }) })] }));
|
|
354
|
+
}
|
|
355
|
+
if (cardModeInner === null) {
|
|
356
|
+
return jsxRuntime.jsx(reactComponents.Portal, { root: rootElement, children: null });
|
|
357
|
+
}
|
|
358
|
+
if (isContained) {
|
|
359
|
+
return (jsxRuntime.jsx(reactComponents.Portal, { root: rootElement, children: jsxRuntime.jsx(reactComponents.Portal, { root: container.current, children: jsxRuntime.jsx("div", { className: cvaModalBackdrop({
|
|
360
|
+
contained: true,
|
|
361
|
+
isFrontmost: depthFromFront === 0,
|
|
362
|
+
shouldAnimate: stackSizeAtOpen === 0,
|
|
363
|
+
}), children: cardModeInner }) }) }));
|
|
364
|
+
}
|
|
365
|
+
return (jsxRuntime.jsx(reactComponents.Portal, { root: rootElement, children: jsxRuntime.jsx(react$1.FloatingOverlay, { className: cvaModalBackdrop({ isFrontmost: depthFromFront === 0, shouldAnimate: stackSizeAtOpen === 0 }), lockScroll: true, children: cardModeInner }) }));
|
|
319
366
|
};
|
|
320
|
-
Modal.displayName = "Modal";
|
|
321
367
|
|
|
322
368
|
const cvaModalBodyContainer = cssClassVarianceUtilities.cvaMerge([
|
|
323
369
|
"flex",
|
|
@@ -374,6 +420,7 @@ const ModalBody = react.forwardRef(({ children, id, "data-testid": dataTestId, c
|
|
|
374
420
|
|
|
375
421
|
const cvaModalFooterContainer = cssClassVarianceUtilities.cvaMerge([
|
|
376
422
|
"flex",
|
|
423
|
+
"flex-none",
|
|
377
424
|
// flex-wrap
|
|
378
425
|
"justify-end",
|
|
379
426
|
"flex-row",
|
|
@@ -597,11 +644,6 @@ const ModalHeader = react.forwardRef(({ heading, subHeading, onClickClose, "data
|
|
|
597
644
|
* - `stackSize`: Total modals open (local + host + iframe)
|
|
598
645
|
* - `stackSizeAtOpen`: Stack size when this modal opened (for animation decisions)
|
|
599
646
|
*
|
|
600
|
-
* This enables the Modal component to:
|
|
601
|
-
* - Scale down non-frontmost modals (visual stacking effect)
|
|
602
|
-
* - Show backdrop only on the frontmost modal
|
|
603
|
-
* - Choose appropriate animations based on whether it's opening over another modal
|
|
604
|
-
*
|
|
605
647
|
* @module modalStackRegistry
|
|
606
648
|
*/
|
|
607
649
|
let modalStack = [];
|
|
@@ -758,7 +800,11 @@ const useModalStack = (isOpen) => {
|
|
|
758
800
|
const depthFromFront = localDepthFromFront + hostModalCount;
|
|
759
801
|
// Get the total stack size when this modal opened (stable once set)
|
|
760
802
|
const stackSizeAtOpen = modalOpenState.get(modalId) ?? 0;
|
|
761
|
-
return react.useMemo(() => ({
|
|
803
|
+
return react.useMemo(() => ({
|
|
804
|
+
depthFromFront,
|
|
805
|
+
stackSize: totalStackSize,
|
|
806
|
+
stackSizeAtOpen,
|
|
807
|
+
}), [depthFromFront, totalStackSize, stackSizeAtOpen]);
|
|
762
808
|
};
|
|
763
809
|
|
|
764
810
|
/**
|
|
@@ -769,9 +815,18 @@ const useModalStack = (isOpen) => {
|
|
|
769
815
|
* - <Modal {...modal} size="medium" />
|
|
770
816
|
*/
|
|
771
817
|
const useModal = (props) => {
|
|
772
|
-
const { isOpen: controlledIsOpen, defaultOpen, rootElement, dismiss: dismissOptions, onClose, onOpen, onOpenChange, onBeforeClose, ref: customRef, role, } = props ?? {};
|
|
818
|
+
const { isOpen: controlledIsOpen, defaultOpen, rootElement, dismiss: dismissOptions, onClose, onOpen, onOpenChange, onBeforeClose, ref: customRef, role, container, } = props ?? {};
|
|
819
|
+
const nullRef = react.useRef(null);
|
|
820
|
+
const containerBreakpoints = reactComponents.useContainerBreakpoints(container ?? nullRef, { skip: !container });
|
|
821
|
+
const viewportBreakpoints = reactComponents.useViewportBreakpoints({ skip: !!container });
|
|
822
|
+
const viewportCanDetect = viewportBreakpoints.isXs;
|
|
823
|
+
const mode = (container ? containerBreakpoints.isSm : !viewportCanDetect || viewportBreakpoints.isSm)
|
|
824
|
+
? "card"
|
|
825
|
+
: "sheet";
|
|
773
826
|
const [internalIsOpen, setIsOpen] = react.useState(defaultOpen ?? false);
|
|
774
|
-
const
|
|
827
|
+
const [openDeferred, setOpenDeferred] = react.useState(false);
|
|
828
|
+
const openIdRef = react.useRef(0);
|
|
829
|
+
const isOpen = typeof controlledIsOpen === "boolean" ? controlledIsOpen && !openDeferred : internalIsOpen;
|
|
775
830
|
const ref = react.useRef(null);
|
|
776
831
|
// Prevents multiple confirmation dialogs from stacking when user rapidly triggers dismiss
|
|
777
832
|
const isPendingCloseRef = react.useRef(false);
|
|
@@ -794,39 +849,80 @@ const useModal = (props) => {
|
|
|
794
849
|
throw new Error("useModal must be used within the ModalDialogContextProvider");
|
|
795
850
|
}
|
|
796
851
|
const { openModal, closeModal } = modalDialogContext;
|
|
797
|
-
|
|
798
|
-
//
|
|
852
|
+
const errorHandling = react.useContext(reactCoreContextsApi.ErrorHandlingContext);
|
|
853
|
+
// Tracks whether a close is in-flight waiting for the Sheet's close animation
|
|
854
|
+
// to complete before calling closeModal() to resize the iframe back.
|
|
855
|
+
const pendingCloseModalRef = react.useRef(false);
|
|
856
|
+
const { startTimeout: startCloseTimeout, stopTimeout: stopCloseTimeout } = reactComponents.useTimeout({
|
|
857
|
+
onTimeout: () => {
|
|
858
|
+
if (pendingCloseModalRef.current) {
|
|
859
|
+
pendingCloseModalRef.current = false;
|
|
860
|
+
void closeModal();
|
|
861
|
+
errorHandling?.captureException(new Error("Modal close animation safety timeout: transitionend callback never fired"), { level: "warning" });
|
|
862
|
+
}
|
|
863
|
+
},
|
|
864
|
+
duration: reactComponents.SHEET_TRANSITION_DURATION_MS + 100,
|
|
865
|
+
});
|
|
866
|
+
const startPendingCloseModal = react.useCallback(() => {
|
|
867
|
+
pendingCloseModalRef.current = true;
|
|
868
|
+
startCloseTimeout();
|
|
869
|
+
}, [startCloseTimeout]);
|
|
870
|
+
const onCloseComplete = react.useCallback(() => {
|
|
871
|
+
if (pendingCloseModalRef.current) {
|
|
872
|
+
pendingCloseModalRef.current = false;
|
|
873
|
+
stopCloseTimeout();
|
|
874
|
+
void closeModal();
|
|
875
|
+
}
|
|
876
|
+
}, [stopCloseTimeout, closeModal]);
|
|
877
|
+
// Sync controlled isOpen state with iframe size controller.
|
|
878
|
+
// Defers rendering until the iframe has finished resizing to fullscreen
|
|
879
|
+
// so the modal animation plays after the resize, not during it.
|
|
799
880
|
reactComponents.useWatch({
|
|
800
881
|
value: controlledIsOpen,
|
|
801
882
|
immediate: true,
|
|
802
883
|
skip: typeof controlledIsOpen !== "boolean",
|
|
803
884
|
onChange: (current, prev) => {
|
|
804
885
|
if (current && !prev) {
|
|
805
|
-
|
|
886
|
+
const id = ++openIdRef.current;
|
|
887
|
+
const result = openModal();
|
|
888
|
+
if (result instanceof Promise) {
|
|
889
|
+
setOpenDeferred(true);
|
|
890
|
+
void result.then(() => {
|
|
891
|
+
if (openIdRef.current === id) {
|
|
892
|
+
setOpenDeferred(false);
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
}
|
|
806
896
|
}
|
|
807
897
|
else if (!current && prev) {
|
|
808
|
-
|
|
898
|
+
openIdRef.current++;
|
|
899
|
+
setOpenDeferred(false);
|
|
900
|
+
startPendingCloseModal();
|
|
809
901
|
}
|
|
810
902
|
},
|
|
811
903
|
});
|
|
812
|
-
// Cleanup: notify context when component unmounts while modal is open
|
|
904
|
+
// Cleanup: notify context when component unmounts while modal is open.
|
|
905
|
+
// Calls closeModal() directly (no animation to wait for during unmount).
|
|
813
906
|
react.useEffect(() => {
|
|
814
907
|
return () => {
|
|
908
|
+
stopCloseTimeout();
|
|
909
|
+
pendingCloseModalRef.current = false;
|
|
815
910
|
if (isOpenRef.current) {
|
|
816
911
|
void closeModal();
|
|
817
912
|
}
|
|
818
913
|
};
|
|
819
|
-
}, [closeModal]);
|
|
914
|
+
}, [closeModal, stopCloseTimeout]);
|
|
820
915
|
const handleClose = react.useCallback((event, reason) => {
|
|
916
|
+
openIdRef.current++;
|
|
917
|
+
setOpenDeferred(false);
|
|
821
918
|
setIsOpen(false);
|
|
822
919
|
onCloseRef.current?.(event, reason);
|
|
823
920
|
onOpenChangeRef.current?.(false, event, reason);
|
|
824
|
-
|
|
825
|
-
}, [
|
|
921
|
+
startPendingCloseModal();
|
|
922
|
+
}, [startPendingCloseModal]);
|
|
826
923
|
const { refs, context } = react$1.useFloating({
|
|
827
924
|
open: isOpen,
|
|
828
925
|
onOpenChange: (newIsOpen, event, reason) => {
|
|
829
|
-
// Opening - just update state (same as original)
|
|
830
926
|
if (newIsOpen) {
|
|
831
927
|
setIsOpen(true);
|
|
832
928
|
return;
|
|
@@ -856,30 +952,33 @@ const useModal = (props) => {
|
|
|
856
952
|
whileElementsMounted: react$1.autoUpdate,
|
|
857
953
|
middleware: [react$1.shift()],
|
|
858
954
|
});
|
|
859
|
-
const
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
955
|
+
const resolvedDismiss = react.useMemo(() => ({
|
|
956
|
+
escapeKey: dismissOptions?.escapeKey ?? true,
|
|
957
|
+
outsidePress: dismissOptions?.outsidePress ?? true,
|
|
958
|
+
gesture: dismissOptions?.gesture ?? true,
|
|
959
|
+
}), [dismissOptions]);
|
|
960
|
+
const stack = useModalStack(isOpen);
|
|
961
|
+
const dismissInteraction = react$1.useDismiss(context, { ...dismissOptions, enabled: stack.depthFromFront === 0 });
|
|
962
|
+
const { getFloatingProps } = react$1.useInteractions([dismissInteraction]);
|
|
963
|
+
const open = react.useCallback(async () => {
|
|
964
|
+
const id = ++openIdRef.current;
|
|
863
965
|
onOpenRef.current?.();
|
|
864
966
|
onOpenChangeRef.current?.(true);
|
|
865
|
-
|
|
866
|
-
|
|
967
|
+
await openModal();
|
|
968
|
+
if (openIdRef.current === id) {
|
|
969
|
+
setIsOpen(true);
|
|
970
|
+
}
|
|
867
971
|
}, [openModal]);
|
|
868
|
-
const
|
|
869
|
-
// If onBeforeClose is provided, check it before closing
|
|
972
|
+
const requestClose = react.useCallback((event, reason) => {
|
|
870
973
|
if (onBeforeCloseRef.current) {
|
|
871
|
-
// Already handling a close attempt, ignore this one
|
|
872
974
|
if (isPendingCloseRef.current) {
|
|
873
975
|
return;
|
|
874
976
|
}
|
|
875
977
|
isPendingCloseRef.current = true;
|
|
876
|
-
void Promise.resolve(onBeforeCloseRef.current(
|
|
978
|
+
void Promise.resolve(onBeforeCloseRef.current(event, reason))
|
|
877
979
|
.then(shouldClose => {
|
|
878
980
|
if (shouldClose) {
|
|
879
|
-
|
|
880
|
-
onOpenChangeRef.current?.(false, undefined, "programmatic");
|
|
881
|
-
setIsOpen(false);
|
|
882
|
-
void closeModal();
|
|
981
|
+
handleClose(event, reason);
|
|
883
982
|
}
|
|
884
983
|
})
|
|
885
984
|
.finally(() => {
|
|
@@ -887,17 +986,15 @@ const useModal = (props) => {
|
|
|
887
986
|
});
|
|
888
987
|
return;
|
|
889
988
|
}
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
void closeModal();
|
|
894
|
-
}, [closeModal]);
|
|
989
|
+
handleClose(event, reason);
|
|
990
|
+
}, [handleClose]);
|
|
991
|
+
const close = react.useCallback(() => requestClose(undefined, "programmatic"), [requestClose]);
|
|
895
992
|
const toggle = react.useCallback(() => {
|
|
896
993
|
if (isOpen) {
|
|
897
994
|
close();
|
|
898
995
|
}
|
|
899
996
|
else {
|
|
900
|
-
open();
|
|
997
|
+
void open();
|
|
901
998
|
}
|
|
902
999
|
}, [isOpen, open, close]);
|
|
903
1000
|
return react.useMemo(() => ({
|
|
@@ -905,6 +1002,7 @@ const useModal = (props) => {
|
|
|
905
1002
|
toggle,
|
|
906
1003
|
open,
|
|
907
1004
|
close,
|
|
1005
|
+
requestClose,
|
|
908
1006
|
ref: customRef ?? ref,
|
|
909
1007
|
floatingUi: {
|
|
910
1008
|
refs,
|
|
@@ -913,21 +1011,28 @@ const useModal = (props) => {
|
|
|
913
1011
|
getFloatingProps,
|
|
914
1012
|
},
|
|
915
1013
|
role,
|
|
916
|
-
|
|
917
|
-
|
|
1014
|
+
dismiss: resolvedDismiss,
|
|
1015
|
+
onCloseComplete,
|
|
1016
|
+
stack,
|
|
1017
|
+
mode,
|
|
1018
|
+
container,
|
|
918
1019
|
}), [
|
|
919
1020
|
isOpen,
|
|
920
1021
|
toggle,
|
|
921
1022
|
open,
|
|
922
1023
|
close,
|
|
1024
|
+
requestClose,
|
|
923
1025
|
customRef,
|
|
924
1026
|
refs,
|
|
925
1027
|
rootElement,
|
|
926
1028
|
context,
|
|
927
1029
|
getFloatingProps,
|
|
928
1030
|
role,
|
|
929
|
-
|
|
930
|
-
|
|
1031
|
+
resolvedDismiss,
|
|
1032
|
+
onCloseComplete,
|
|
1033
|
+
stack,
|
|
1034
|
+
mode,
|
|
1035
|
+
container,
|
|
931
1036
|
]);
|
|
932
1037
|
};
|
|
933
1038
|
|