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