@trackunit/react-modal 1.21.3 → 1.21.6

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 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": config.viewportWidthPercent,
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)-((100dvw-var(--modal-breakpoint))*var(--modal-transition-rate))),var(--modal-max-width))]",
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", // This is used to target the container size for all children
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
- function resolveRootEl(refLike) {
165
- if (refLike && typeof refLike === "object" && "current" in refLike) {
166
- return refLike.current;
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 refLike;
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
- * Behavior:
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. Accepts string or string[]. Defaults to "border-t".
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
- react.useLayoutEffect(() => {
192
- if (!enabled)
193
- return;
194
- let cleanup;
195
- let cancelled = false;
196
- const classes = Array.isArray(footerClass)
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, depthFromFront, stackSizeAtOpen, }) => {
307
- // For dialogs/modals, Floating UI recommends not using floatingStyles since the modal
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
- useModalFooterBorder(cardRef, { enabled: isOpen, footerClass: "border-t pt-4" });
315
- const isFrontmost = depthFromFront === 0;
316
- return (jsxRuntime.jsx(reactComponents.Portal, { root: rootElement, children: isOpen ? (jsxRuntime.jsx(react$1.FloatingOverlay, { className: cvaModalBackdrop({ isFrontmost, shouldAnimate: stackSizeAtOpen === 0 }), lockScroll: true, children: jsxRuntime.jsx(react$1.FloatingFocusManager, { context: context, restoreFocus: restoreFocus, children: jsxRuntime.jsx("div", { "aria-modal": true, className: cvaModalContainer(), ref: mergedRef, role: role,
317
- // Scale down modals that are behind the frontmost
318
- style: isFrontmost ? undefined : { transform: `scale(${1 - depthFromFront * SCALE_FACTOR_PER_LEVEL})` }, ...getFloatingProps(), children: jsxRuntime.jsx(reactComponents.Card, { className: cvaModalCard({ className, animation: stackSizeAtOpen === 0 ? "fade" : "rise" }), "data-testid": dataTestId, ref: cardRef, style: getModalCardCSSVariables(size), children: children }) }) }) })) : null }));
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(() => ({ depthFromFront, stackSize: totalStackSize, stackSizeAtOpen }), [depthFromFront, totalStackSize, stackSizeAtOpen]);
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 isOpen = typeof controlledIsOpen === "boolean" ? controlledIsOpen : internalIsOpen;
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
- // Sync controlled isOpen state with iframe size controller
798
- // This ensures the iframe resizes correctly when using controlled mode
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
- void openModal();
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
- void closeModal();
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
- void closeModal();
825
- }, [closeModal]);
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 { depthFromFront, stackSizeAtOpen } = useModalStack(isOpen);
860
- const dismiss = react$1.useDismiss(context, { ...dismissOptions, enabled: depthFromFront === 0 });
861
- const { getFloatingProps } = react$1.useInteractions([dismiss]);
862
- const open = react.useCallback(() => {
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
- setIsOpen(true);
866
- void openModal();
967
+ await openModal();
968
+ if (openIdRef.current === id) {
969
+ setIsOpen(true);
970
+ }
867
971
  }, [openModal]);
868
- const close = react.useCallback(() => {
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(undefined, "programmatic"))
978
+ void Promise.resolve(onBeforeCloseRef.current(event, reason))
877
979
  .then(shouldClose => {
878
980
  if (shouldClose) {
879
- onCloseRef.current?.(undefined, "programmatic");
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
- onCloseRef.current?.(undefined, "programmatic");
891
- onOpenChangeRef.current?.(false, undefined, "programmatic");
892
- setIsOpen(false);
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
- depthFromFront,
917
- stackSizeAtOpen,
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
- depthFromFront,
930
- stackSizeAtOpen,
1031
+ resolvedDismiss,
1032
+ onCloseComplete,
1033
+ stack,
1034
+ mode,
1035
+ container,
931
1036
  ]);
932
1037
  };
933
1038