@trackunit/react-modal 1.12.0 → 1.12.8

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,9 +1,8 @@
1
1
  import { jsx, jsxs } from 'react/jsx-runtime';
2
2
  import { registerTranslations } from '@trackunit/i18n-library-translation';
3
- import { FloatingFocusManager, useFloating, shift, autoUpdate, useDismiss, useInteractions } from '@floating-ui/react';
4
- import { Portal, Card, Button, Heading, Text, IconButton, Icon, useScrollBlock } from '@trackunit/react-components';
5
- import { zIndex } from '@trackunit/ui-design-tokens';
6
- import { useRef, useLayoutEffect, forwardRef, useState, useContext, useEffect, useCallback, useMemo } from 'react';
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, useId, useSyncExternalStore, useRef, forwardRef, useState, useContext, useEffect, useCallback, useMemo } from 'react';
7
6
  import { cvaMerge } from '@trackunit/css-class-variance-utilities';
8
7
  import { ModalDialogContext } from '@trackunit/react-core-contexts-api';
9
8
 
@@ -50,15 +49,14 @@ const setupLibraryTranslations = () => {
50
49
  };
51
50
 
52
51
  const cvaModalContainer = cvaMerge([
53
- "h-full",
54
- "w-full",
55
52
  "flex",
56
- "fixed",
57
- "inset-0",
58
- "min-h-[100dvh]",
59
- "min-w-[100dvw]",
60
- "overflow-auto",
61
53
  "items-center",
54
+ // Allow clicks to pass through to FloatingOverlay for dismiss behavior
55
+ "pointer-events-none",
56
+ // Animate scale changes when stacking modals
57
+ "transition-transform",
58
+ "duration-200",
59
+ "ease-out",
62
60
  ]);
63
61
  const modalSizeConfigs = {
64
62
  small: {
@@ -115,10 +113,25 @@ const cvaModalCard = cvaMerge([
115
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))]",
116
114
  "max-h-[var(--modal-max-height)]",
117
115
  "@container", // This is used to target the container size for all children
118
- ]);
116
+ // Re-enable pointer events for modal content (container has pointer-events-none)
117
+ "pointer-events-auto",
118
+ ], {
119
+ variants: {
120
+ animation: {
121
+ // First modal in stack: simple fade
122
+ fade: "animate-fade-in-fast",
123
+ // Stacked modals: fade + rise from below
124
+ rise: "animate-fade-in-rising",
125
+ },
126
+ },
127
+ defaultVariants: {
128
+ animation: "fade",
129
+ },
130
+ });
119
131
  const cvaModalBackdrop = cvaMerge([
132
+ "flex",
120
133
  "justify-center",
121
- "items-center",
134
+ // "items-center",
122
135
  "fixed",
123
136
  "inset-0",
124
137
  "min-h-[100dvh]",
@@ -126,28 +139,25 @@ const cvaModalBackdrop = cvaMerge([
126
139
  "w-full",
127
140
  "h-full",
128
141
  "z-overlay",
129
- "invisible",
130
- "opacity-0",
131
- "transition-all",
132
- "bg-black/50",
133
- "animate-modal-fade",
134
- ]);
135
-
136
- /**
137
- * The Backdrop for the modal.
138
- *
139
- * @param {ModalBackdropProps} props - The props for the ModalBackdrop component
140
- * @returns {ReactElement} ModalBackdrop component
141
- */
142
- const ModalBackdrop = ({ children, onClick }) => {
143
- const ref = useRef(null);
144
- return (jsx("div", { className: cvaModalBackdrop(), onMouseDown: e => {
145
- // only react on click on backdrop - accessing first child to get around styled element
146
- if (ref.current !== null && e.target === ref.current.children[0]) {
147
- onClick();
148
- }
149
- }, ref: ref, children: children }));
150
- };
142
+ ], {
143
+ variants: {
144
+ isFrontmost: {
145
+ // Frontmost modal shows backdrop
146
+ true: "bg-black/50",
147
+ // Non-frontmost modals have transparent backdrop with no transition (avoids flash)
148
+ false: ["bg-transparent", "transition-none"],
149
+ },
150
+ shouldAnimate: {
151
+ // Only animate backdrop when this is the first/only modal
152
+ true: "animate-fade-in-fast",
153
+ false: [],
154
+ },
155
+ },
156
+ defaultVariants: {
157
+ isFrontmost: true,
158
+ shouldAnimate: true,
159
+ },
160
+ });
151
161
 
152
162
  function resolveRootEl(refLike) {
153
163
  if (refLike && typeof refLike === "object" && "current" in refLike) {
@@ -254,6 +264,244 @@ const useModalFooterBorder = (rootRef, { footerClass = "border-t", enabled = tru
254
264
  }, [enabled, footerClass, bodySelector, footerSelector, rootRef]);
255
265
  };
256
266
 
267
+ /**
268
+ * Modal Stack Registry - Cross-Bundle Modal Awareness
269
+ *
270
+ * ## Architecture Overview
271
+ *
272
+ * This module provides a registry for tracking open modals across bundle boundaries
273
+ * (host application + Iris app iframes). It enables proper modal stacking behavior
274
+ * where modals can visually indicate their depth (scale, backdrop visibility, animations).
275
+ *
276
+ * ## Why postMessage Instead of Penpal?
277
+ *
278
+ * While most host-iframe communication uses Penpal (RPC-style), modal stack changes
279
+ * use raw postMessage for these reasons:
280
+ *
281
+ * 1. **Package Independence**: This module is part of `@trackunit/react-modal`, a standalone
282
+ * UI component library. It should not depend on `iris-app-runtime` to avoid circular
283
+ * dependencies and keep the modal component reusable.
284
+ *
285
+ * 2. **Broadcast Semantics**: When a host modal opens, ALL iframe modals need to know
286
+ * simultaneously (not just one specific connection). postMessage with broadcast
287
+ * to all iframes fits this pattern naturally.
288
+ *
289
+ * 3. **Low Latency**: Stack count changes need real-time sync for smooth animations
290
+ * (scale transforms, backdrop fading). Raw postMessage has less overhead than Penpal.
291
+ *
292
+ * 4. **Module Isolation**: Each bundle (host + each iframe) gets its own instance of
293
+ * this registry. They need cross-window synchronization, not shared state.
294
+ *
295
+ * ## Communication Flow
296
+ *
297
+ * ```
298
+ * ┌─────────────────────────────────────────────────────────────────────┐
299
+ * │ HOST APPLICATION │
300
+ * │ │
301
+ * │ modalStackRegistry ←───────────────────────────────────────────┐ │
302
+ * │ │ │ │
303
+ * │ │ subscribe() │ │
304
+ * │ ▼ │ │
305
+ * │ ModalDialogProviderHost │ │
306
+ * │ │ │ │
307
+ * │ ├─► broadcasts MODAL_STACK_MESSAGE_TYPE to all iframes │ │
308
+ * │ │ │ │
309
+ * │ └─► listens for IFRAME_MODAL_STACK_MESSAGE_TYPE ──────────┘ │
310
+ * │ (aggregates per-iframe, cleans up on iframe removal) │
311
+ * └─────────────────────────────────────────────────────────────────────┘
312
+ * │
313
+ * postMessage (bidirectional)
314
+ * │
315
+ * ┌─────────────────────────────────────────────────────────────────────┐
316
+ * │ IFRAME (Iris App) │
317
+ * │ │
318
+ * │ modalStackRegistry (separate instance) │
319
+ * │ │ │
320
+ * │ ├─► listens for MODAL_STACK_MESSAGE_TYPE from host │
321
+ * │ │ (updates hostModalCount) │
322
+ * │ │ │
323
+ * │ └─► on register/unregister, broadcasts │
324
+ * │ IFRAME_MODAL_STACK_MESSAGE_TYPE to parent │
325
+ * └─────────────────────────────────────────────────────────────────────┘
326
+ * ```
327
+ *
328
+ * ## Usage with useModalStack Hook
329
+ *
330
+ * The `useModalStack` hook consumes this registry to calculate:
331
+ * - `depthFromFront`: How many modals are in front of this one (0 = frontmost)
332
+ * - `stackSize`: Total modals open (local + host + iframe)
333
+ * - `stackSizeAtOpen`: Stack size when this modal opened (for animation decisions)
334
+ *
335
+ * This enables the Modal component to:
336
+ * - Scale down non-frontmost modals (visual stacking effect)
337
+ * - Show backdrop only on the frontmost modal
338
+ * - Choose appropriate animations based on whether it's opening over another modal
339
+ *
340
+ * @module modalStackRegistry
341
+ */
342
+ let modalStack = [];
343
+ let hostModalCount = 0;
344
+ let iframeModalCount = 0;
345
+ const listeners = new Set();
346
+ const notify = () => listeners.forEach(listener => listener());
347
+ /**
348
+ * Message type for host → iframe modal stack communication.
349
+ * The host broadcasts this to all iframes when its modal count changes.
350
+ * Iframes listen for this to update their `hostModalCount`.
351
+ */
352
+ const MODAL_STACK_MESSAGE_TYPE = "IRIS_APP_HOST_MODAL_STACK_CHANGE";
353
+ /**
354
+ * Message type for iframe → host modal stack communication.
355
+ * Each iframe sends this to the parent when its modal count changes.
356
+ * The host aggregates these per-iframe for accurate total tracking.
357
+ */
358
+ const IFRAME_MODAL_STACK_MESSAGE_TYPE = "IRIS_APP_IFRAME_MODAL_STACK_CHANGE";
359
+ const isInIframe = typeof window !== "undefined" && window.parent !== window;
360
+ /**
361
+ * Broadcast this iframe's modal count to the parent (host).
362
+ * Only runs when in an iframe context. Called automatically on register/unregister.
363
+ */
364
+ const broadcastToParent = (modalCount) => {
365
+ if (isInIframe) {
366
+ window.parent.postMessage({ type: IFRAME_MODAL_STACK_MESSAGE_TYPE, data: { iframeModalCount: modalCount } }, "*");
367
+ }
368
+ };
369
+ /**
370
+ * Set up the global message listener for cross-bundle modal stack communication.
371
+ * This listener runs in the same bundle context as the Modal components, ensuring
372
+ * the correct registry instance is updated.
373
+ *
374
+ * Note: The host also has a separate listener in ModalDialogProviderHost that
375
+ * tracks per-iframe counts and handles cleanup when iframes are removed.
376
+ */
377
+ if (typeof window !== "undefined") {
378
+ window.addEventListener("message", event => {
379
+ // Host → Iframe: Update host modal count
380
+ if (event.data?.type === MODAL_STACK_MESSAGE_TYPE && typeof event.data?.data?.hostModalCount === "number") {
381
+ modalStackRegistry.setHostModalCount(event.data.data.hostModalCount);
382
+ }
383
+ // Iframe → Host: Update iframe modal count (fallback, primary handler is in ModalDialogProviderHost)
384
+ if (event.data?.type === IFRAME_MODAL_STACK_MESSAGE_TYPE &&
385
+ typeof event.data?.data?.iframeModalCount === "number") {
386
+ modalStackRegistry.setIframeModalCount(event.data.data.iframeModalCount);
387
+ }
388
+ });
389
+ }
390
+ /**
391
+ * Module-level registry to track the stack of open modals.
392
+ *
393
+ * This registry enables:
394
+ * - Tracking modal order within a single bundle (local stack)
395
+ * - Awareness of modals in other bundles (host ↔ iframe communication)
396
+ * - Subscriber notifications for React integration via useSyncExternalStore
397
+ *
398
+ * Cross-bundle counts:
399
+ * - `hostModalCount`: Number of modals open in the host (relevant for iframes)
400
+ * - `iframeModalCount`: Number of modals open in iframes (relevant for host)
401
+ *
402
+ * Note: This is intentionally NOT a React hook because:
403
+ * 1. It needs to be a singleton that persists across React component lifecycles
404
+ * 2. It must work with postMessage for cross-bundle communication (host ↔ iframe)
405
+ * 3. The useModalStack hook consumes this registry via useSyncExternalStore for React integration
406
+ */
407
+ const modalStackRegistry = {
408
+ register: (id) => {
409
+ modalStack.push(id);
410
+ notify();
411
+ // If in iframe, notify parent of our modal count
412
+ broadcastToParent(modalStack.length);
413
+ },
414
+ unregister: (id) => {
415
+ modalStack = modalStack.filter(modalId => modalId !== id);
416
+ notify();
417
+ // If in iframe, notify parent of our modal count
418
+ broadcastToParent(modalStack.length);
419
+ },
420
+ getStackPosition: (id) => modalStack.indexOf(id),
421
+ getStackSize: () => modalStack.length,
422
+ /** Get the number of modals open in the host (only relevant for Iris apps in iframes) */
423
+ getHostModalCount: () => hostModalCount,
424
+ /** Get the number of modals open in iframes (only relevant for host) */
425
+ getIframeModalCount: () => iframeModalCount,
426
+ /** Set the host modal count (called when receiving messages from host) */
427
+ setHostModalCount: (count) => {
428
+ if (hostModalCount !== count) {
429
+ hostModalCount = count;
430
+ notify();
431
+ }
432
+ },
433
+ /** Set the iframe modal count (called when receiving messages from iframes) */
434
+ setIframeModalCount: (count) => {
435
+ if (iframeModalCount !== count) {
436
+ iframeModalCount = count;
437
+ notify();
438
+ }
439
+ },
440
+ subscribe: (listener) => {
441
+ listeners.add(listener);
442
+ return () => {
443
+ listeners.delete(listener);
444
+ };
445
+ },
446
+ };
447
+
448
+ // Track the stack size when each modal opened (includes host modals for proper animation)
449
+ const modalOpenState = new Map();
450
+ /**
451
+ * Hook to track this modal's position in the stack of open modals.
452
+ * Returns the depth from front (0 = frontmost, 1 = one modal in front, etc.)
453
+ *
454
+ * This hook is aware of modals across bundle boundaries (host + Iris apps)
455
+ * by listening for host modal count changes via postMessage.
456
+ *
457
+ * @param isOpen - Whether the modal is currently open
458
+ */
459
+ const useModalStack = (isOpen) => {
460
+ const modalId = useId();
461
+ // Subscribe to stack changes to re-render when stack updates
462
+ const localStackSize = useSyncExternalStore(modalStackRegistry.subscribe, modalStackRegistry.getStackSize, modalStackRegistry.getStackSize);
463
+ // Subscribe to host modal count (only relevant for Iris apps in iframes)
464
+ const hostModalCount = useSyncExternalStore(modalStackRegistry.subscribe, modalStackRegistry.getHostModalCount, modalStackRegistry.getHostModalCount);
465
+ // Subscribe to iframe modal count (only relevant for host)
466
+ const iframeModalCount = useSyncExternalStore(modalStackRegistry.subscribe, modalStackRegistry.getIframeModalCount, modalStackRegistry.getIframeModalCount);
467
+ const stackPosition = useSyncExternalStore(modalStackRegistry.subscribe, () => modalStackRegistry.getStackPosition(modalId), () => modalStackRegistry.getStackPosition(modalId));
468
+ // Register/unregister based on open state
469
+ // useLayoutEffect ensures registration happens before paint to avoid backdrop flash
470
+ useLayoutEffect(() => {
471
+ if (isOpen) {
472
+ // Capture total stack size before registering (includes cross-bundle modals for proper animation)
473
+ // - hostModalCount: modals in host (relevant for iframes)
474
+ // - iframeModalCount: modals in iframes (relevant for host)
475
+ const totalSizeAtOpen = modalStackRegistry.getStackSize() +
476
+ modalStackRegistry.getHostModalCount() +
477
+ modalStackRegistry.getIframeModalCount();
478
+ modalOpenState.set(modalId, totalSizeAtOpen);
479
+ modalStackRegistry.register(modalId);
480
+ return () => {
481
+ modalStackRegistry.unregister(modalId);
482
+ modalOpenState.delete(modalId);
483
+ };
484
+ }
485
+ return undefined;
486
+ }, [isOpen, modalId]);
487
+ // Total stack size includes local modals, host modals, and iframe modals
488
+ const totalStackSize = localStackSize + hostModalCount + iframeModalCount;
489
+ // Calculate depth from front (0 = frontmost)
490
+ // Host modals are always "on top" of Iris app modals, so if hostModalCount > 0,
491
+ // all Iris app modals have at least that many modals in front of them
492
+ const localDepthFromFront = stackPosition >= 0 ? localStackSize - 1 - stackPosition : 0;
493
+ const depthFromFront = localDepthFromFront + hostModalCount;
494
+ // Get the total stack size when this modal opened (stable once set)
495
+ const stackSizeAtOpen = modalOpenState.get(modalId) ?? 0;
496
+ return {
497
+ depthFromFront,
498
+ stackSize: totalStackSize,
499
+ stackSizeAtOpen,
500
+ };
501
+ };
502
+
503
+ /** Scale factor per modal depth level (5% smaller per level back) */
504
+ const SCALE_FACTOR_PER_LEVEL = 0.05;
257
505
  /**
258
506
  * - Modals are used to present critical information or request user input needed to complete a user's workflow.
259
507
  * - Modals interrupt a user's workflow by design.
@@ -270,9 +518,9 @@ const useModalFooterBorder = (rootRef, { footerClass = "border-t", enabled = tru
270
518
  * );
271
519
  * };
272
520
  *
273
- * const childComponent = ({...modalProps}: UseModalReturnValue) => {
521
+ * const ChildComponent = ({...modalProps}: UseModalReturnValue) => {
274
522
  * return (* <Modal {...modalProps}>
275
- * <ModalHeader onClose={modalProps.close} heading="My Modal" />
523
+ * <ModalHeader onClickClose={modalProps.close} heading="My Modal" />
276
524
  * <ModalBody>
277
525
  * <p>This is a modal</p>
278
526
  * </ModalBody>
@@ -281,14 +529,21 @@ const useModalFooterBorder = (rootRef, { footerClass = "border-t", enabled = tru
281
529
  * };
282
530
  * ```
283
531
  */
284
- const Modal = ({ children, isOpen, role = "dialog", "data-testid": dataTestId, className, size, onBackdropClick, floatingUi, ref, }) => {
532
+ const Modal = ({ children, isOpen, role = "dialog", "data-testid": dataTestId, className, size, floatingUi, ref, }) => {
285
533
  // For dialogs/modals, Floating UI recommends not using floatingStyles since the modal
286
534
  // is viewport-centered via CSS, not positioned relative to a reference element.
287
535
  // See: https://floating-ui.com/docs/dialog
288
536
  const { rootElement, refs, context, getFloatingProps } = floatingUi;
289
537
  const cardRef = useRef(null);
538
+ // Merge the custom ref from useModal with FloatingUI's setFloating ref
539
+ const mergedRef = useMergeRefs([refs.setFloating, ref]);
290
540
  useModalFooterBorder(cardRef, { enabled: isOpen, footerClass: "border-t pt-4" });
291
- return (jsx(Portal, { root: rootElement, children: isOpen ? (jsx(FloatingFocusManager, { context: context, children: jsx("div", { ref: refs.setFloating, style: { zIndex: zIndex.overlay }, ...getFloatingProps(), children: jsx(ModalBackdrop, { onClick: onBackdropClick, children: jsx("div", { "aria-modal": true, className: cvaModalContainer(), ref: ref, role: role, children: jsx(Card, { className: cvaModalCard({ className }), "data-testid": dataTestId, ref: cardRef, style: getModalCardCSSVariables(size), children: children }) }) }) }) })) : null }));
541
+ // Track modal stack position for stacked modal styling
542
+ const { depthFromFront, stackSizeAtOpen } = useModalStack(isOpen);
543
+ const isFrontmost = depthFromFront === 0;
544
+ return (jsx(Portal, { root: rootElement, children: isOpen ? (jsx(FloatingOverlay, { className: cvaModalBackdrop({ isFrontmost, shouldAnimate: stackSizeAtOpen === 0 }), lockScroll: true, children: jsx(FloatingFocusManager, { context: context, children: jsx("div", { "aria-modal": true, className: cvaModalContainer(), ref: mergedRef, role: role,
545
+ // Scale down modals that are behind the frontmost
546
+ 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 }));
292
547
  };
293
548
  Modal.displayName = "Modal";
294
549
 
@@ -342,7 +597,7 @@ const cvaModalBodyContainer = cvaMerge([
342
597
  * @returns {ReactElement} Modal body wrapper element.
343
598
  */
344
599
  const ModalBody = forwardRef(({ children, id, "data-testid": dataTestId, className }, ref) => {
345
- return (jsx("div", { className: cvaModalBodyContainer({ className }), "data-modal-body": true, "data-testid": dataTestId, id: id, children: children }));
600
+ return (jsx("div", { className: cvaModalBodyContainer({ className }), "data-modal-body": true, "data-testid": dataTestId, id: id, ref: ref, children: children }));
346
601
  });
347
602
 
348
603
  const cvaModalFooterContainer = cvaMerge([
@@ -487,15 +742,15 @@ const cvaIconContainer = cvaMerge(["flex", "place-items-center"]);
487
742
  * @param {ModalHeaderProps} props Component props.
488
743
  * @param {string} [props.heading] Main heading text.
489
744
  * @param {string} [props.subHeading] Optional subheading content.
490
- * @param {() => void} props.onClose Close button click handler.
745
+ * @param {() => void} props.onClickClose Close button click handler.
491
746
  * @param {string} [props."data-testid"] Optional test id for the container.
492
747
  * @param {string} [props.className] Optional additional class name(s).
493
- * @returns {ReactElement} The modal header element.
748
+ * @returns The modal header element.
494
749
  */
495
- const ModalHeader = forwardRef(({ heading, subHeading, onClose, "data-testid": dataTestId, className, id, children, accessories }, ref) => {
750
+ const ModalHeader = forwardRef(({ heading, subHeading, onClickClose, "data-testid": dataTestId, className, id, children, accessories, }, ref) => {
496
751
  return (jsxs("div", { className: cvaContainer({
497
752
  className,
498
- }), "data-testid": dataTestId, id: id, children: [jsxs("div", { className: cvaHeadingContainer(), children: [jsxs("div", { className: cvaTitleContainer(), children: [jsx(Heading, { variant: "tertiary", children: heading }), accessories] }), Boolean(subHeading) ? (jsx(Text, { size: "small", subtle: true, children: subHeading })) : null, children] }), jsx("div", { className: cvaIconContainer(), children: jsx(IconButton, { className: "!h-min", "data-testid": dataTestId ? `${dataTestId}-close-button` : "modal-close-button", icon: jsx(Icon, { name: "XMark", size: "small" }), onClick: onClose, size: "small", variant: "ghost-neutral" }) })] }));
753
+ }), "data-testid": dataTestId, id: id, ref: ref, children: [jsxs("div", { className: cvaHeadingContainer(), children: [jsxs("div", { className: cvaTitleContainer(), children: [jsx(Heading, { variant: "tertiary", children: heading }), accessories] }), Boolean(subHeading) ? (jsx(Text, { size: "small", subtle: true, children: subHeading })) : null, children] }), jsx("div", { className: cvaIconContainer(), children: jsx(IconButton, { className: "!h-min", "data-testid": dataTestId ? `${dataTestId}-close-button` : "modal-close-button", icon: jsx(Icon, { name: "XMark", size: "small" }), onClick: onClickClose, size: "small", variant: "ghost-neutral" }) })] }));
499
754
  });
500
755
 
501
756
  /**
@@ -506,89 +761,142 @@ const ModalHeader = forwardRef(({ heading, subHeading, onClose, "data-testid": d
506
761
  * - <Modal {...modal} size="medium" />
507
762
  */
508
763
  const useModal = (props) => {
509
- const { isOpen: controlledIsOpen, onOpenChange, rootElement, closeOnOutsideClick = true, closeOnEsc = true, onClose, onOpen, ref: customRef, role, } = props ?? {};
510
- const [internalIsOpen, setIsOpen] = useState(Boolean(controlledIsOpen));
764
+ const { isOpen: controlledIsOpen, defaultOpen, rootElement, dismiss: dismissOptions, onClose, onOpen, onOpenChange, onBeforeClose, ref: customRef, role, } = props ?? {};
765
+ const [internalIsOpen, setIsOpen] = useState(defaultOpen ?? false);
511
766
  const isOpen = typeof controlledIsOpen === "boolean" ? controlledIsOpen : internalIsOpen;
512
- const { blockScroll, restoreScroll } = useScrollBlock();
513
767
  const ref = useRef(null);
768
+ // Prevents multiple confirmation dialogs from stacking when user rapidly triggers dismiss
769
+ const isPendingCloseRef = useRef(false);
770
+ // Store callbacks and state in refs to keep open/close/toggle stable across renders
771
+ // This prevents cascading re-renders when consumers pass inline callbacks
772
+ const onCloseRef = useRef(onClose);
773
+ const onOpenRef = useRef(onOpen);
774
+ const onOpenChangeRef = useRef(onOpenChange);
775
+ const onBeforeCloseRef = useRef(onBeforeClose);
776
+ const isOpenRef = useRef(isOpen);
777
+ useLayoutEffect(() => {
778
+ onCloseRef.current = onClose;
779
+ onOpenRef.current = onOpen;
780
+ onOpenChangeRef.current = onOpenChange;
781
+ onBeforeCloseRef.current = onBeforeClose;
782
+ isOpenRef.current = isOpen;
783
+ });
514
784
  const modalDialogContext = useContext(ModalDialogContext);
515
785
  if (!modalDialogContext) {
516
786
  throw new Error("useModal must be used within the ModalDialogContextProvider");
517
787
  }
518
788
  const { openModal, closeModal } = modalDialogContext;
789
+ // Sync controlled isOpen state with iframe size controller
790
+ // This ensures the iframe resizes correctly when using controlled mode
791
+ useWatch({
792
+ value: controlledIsOpen,
793
+ immediate: true,
794
+ skip: typeof controlledIsOpen !== "boolean",
795
+ onChange: (current, prev) => {
796
+ if (current && !prev) {
797
+ void openModal();
798
+ }
799
+ else if (!current && prev) {
800
+ void closeModal();
801
+ }
802
+ },
803
+ });
804
+ // Cleanup: notify context when component unmounts while modal is open
805
+ useEffect(() => {
806
+ return () => {
807
+ if (isOpenRef.current) {
808
+ void closeModal();
809
+ }
810
+ };
811
+ }, [closeModal]);
812
+ const handleClose = useCallback((event, reason) => {
813
+ setIsOpen(false);
814
+ onCloseRef.current?.(event, reason);
815
+ onOpenChangeRef.current?.(false, event, reason);
816
+ void closeModal();
817
+ }, [closeModal]);
519
818
  const { refs, context } = useFloating({
520
819
  open: isOpen,
521
- onOpenChange: (newIsOpen, event, openChangeReason) => {
820
+ onOpenChange: (newIsOpen, event, reason) => {
821
+ // Opening - just update state (same as original)
522
822
  if (newIsOpen) {
523
- blockScroll();
524
- }
525
- else {
526
- restoreScroll();
823
+ setIsOpen(true);
824
+ return;
527
825
  }
528
- if (onOpenChange) {
529
- return onOpenChange(newIsOpen, event, openChangeReason);
826
+ // Only pass through the modal-relevant reasons
827
+ const modalReason = reason === "outside-press" || reason === "escape-key" ? reason : undefined;
828
+ if (modalReason && onBeforeCloseRef.current) {
829
+ // Already handling a close attempt, ignore this dismiss event
830
+ if (isPendingCloseRef.current) {
831
+ return;
832
+ }
833
+ isPendingCloseRef.current = true;
834
+ void Promise.resolve(onBeforeCloseRef.current(event, modalReason))
835
+ .then(shouldClose => {
836
+ if (shouldClose) {
837
+ handleClose(event, modalReason);
838
+ }
839
+ })
840
+ .finally(() => {
841
+ isPendingCloseRef.current = false;
842
+ });
843
+ return;
530
844
  }
531
- setIsOpen(newIsOpen);
845
+ // Normal close flow (no onBeforeClose, or not a dismiss reason)
846
+ handleClose(event, modalReason);
532
847
  },
533
848
  whileElementsMounted: autoUpdate,
534
849
  middleware: [shift()],
535
850
  });
536
- const dismiss = useDismiss(context, {
537
- escapeKey: closeOnEsc,
538
- outsidePress: closeOnOutsideClick,
539
- });
851
+ const dismiss = useDismiss(context, dismissOptions);
540
852
  const { getFloatingProps } = useInteractions([dismiss]);
541
- // Handles closing and opening the modal outside of Iris apps/outside an iframe.
542
- useEffect(() => {
543
- if (isOpen) {
544
- openModal();
545
- }
546
- else {
547
- closeModal();
548
- }
549
- // Return cleanup function that ensures modal is closed when component unmounts
550
- return () => {
551
- closeModal();
552
- };
553
- }, [isOpen, openModal, closeModal]);
554
- const open = useCallback((...args) => {
555
- onOpen?.(...args);
556
- blockScroll();
853
+ const open = useCallback(() => {
854
+ onOpenRef.current?.();
855
+ onOpenChangeRef.current?.(true);
557
856
  setIsOpen(true);
558
- }, [onOpen, blockScroll]);
559
- const close = useCallback((...args) => {
560
- for (const arg of args) {
561
- if (!arg || typeof arg !== "object") {
562
- continue;
563
- }
564
- // @ts-ignore
565
- if ("preventDefault" in arg) {
566
- arg.preventDefault();
567
- }
568
- // @ts-ignore
569
- if ("stopPropagation" in arg) {
570
- arg.stopPropagation();
857
+ void openModal();
858
+ }, [openModal]);
859
+ const close = useCallback(() => {
860
+ // If onBeforeClose is provided, check it before closing
861
+ if (onBeforeCloseRef.current) {
862
+ // Already handling a close attempt, ignore this one
863
+ if (isPendingCloseRef.current) {
864
+ return;
571
865
  }
866
+ isPendingCloseRef.current = true;
867
+ void Promise.resolve(onBeforeCloseRef.current(undefined, "programmatic"))
868
+ .then(shouldClose => {
869
+ if (shouldClose) {
870
+ onCloseRef.current?.(undefined, "programmatic");
871
+ onOpenChangeRef.current?.(false, undefined, "programmatic");
872
+ setIsOpen(false);
873
+ void closeModal();
874
+ }
875
+ })
876
+ .finally(() => {
877
+ isPendingCloseRef.current = false;
878
+ });
879
+ return;
572
880
  }
573
- onClose?.(...args);
574
- restoreScroll();
881
+ onCloseRef.current?.(undefined, "programmatic");
882
+ onOpenChangeRef.current?.(false, undefined, "programmatic");
575
883
  setIsOpen(false);
576
- }, [onClose, restoreScroll]);
577
- const toggle = useCallback((...args) => {
884
+ void closeModal();
885
+ }, [closeModal]);
886
+ const toggle = useCallback(() => {
578
887
  if (isOpen) {
579
- return close(...args);
888
+ close();
889
+ }
890
+ else {
891
+ open();
580
892
  }
581
- return open(...args);
582
- }, [close, isOpen, open]);
893
+ }, [isOpen, open, close]);
583
894
  return useMemo(() => ({
584
895
  isOpen,
585
- closeOnOutsideClick,
586
896
  toggle,
587
897
  open,
588
898
  close,
589
899
  ref: customRef ?? ref,
590
- // @ts-ignore TOnCloseArgs could be instantiated with a different subtype of constraint BaseUseModalActionArgs
591
- onBackdropClick: () => (closeOnOutsideClick ? close() : null),
592
900
  floatingUi: {
593
901
  refs,
594
902
  rootElement,
@@ -596,7 +904,7 @@ const useModal = (props) => {
596
904
  getFloatingProps,
597
905
  },
598
906
  role,
599
- }), [isOpen, closeOnOutsideClick, toggle, open, close, customRef, refs, rootElement, context, getFloatingProps, role]);
907
+ }), [isOpen, toggle, open, close, customRef, refs, rootElement, context, getFloatingProps, role]);
600
908
  };
601
909
 
602
910
  /*
@@ -608,4 +916,4 @@ const useModal = (props) => {
608
916
  */
609
917
  setupLibraryTranslations();
610
918
 
611
- export { Modal, ModalBackdrop, ModalBody, ModalFooter, ModalHeader, useModal };
919
+ export { IFRAME_MODAL_STACK_MESSAGE_TYPE, MODAL_STACK_MESSAGE_TYPE, Modal, ModalBody, ModalFooter, ModalHeader, modalStackRegistry, useModal };
package/package.json CHANGED
@@ -1,21 +1,20 @@
1
1
  {
2
2
  "name": "@trackunit/react-modal",
3
- "version": "1.12.0",
3
+ "version": "1.12.8",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
7
7
  "node": ">=24.x"
8
8
  },
9
9
  "dependencies": {
10
- "react": "19.0.0",
11
- "@trackunit/react-components": "1.15.0",
12
- "@trackunit/css-class-variance-utilities": "1.11.0",
13
- "@trackunit/i18n-library-translation": "1.11.0",
14
10
  "@floating-ui/react": "^0.26.25",
11
+ "@trackunit/react-components": "1.15.8",
12
+ "react": "19.0.0",
13
+ "@trackunit/css-class-variance-utilities": "1.11.7",
14
+ "@trackunit/shared-utils": "1.13.7",
15
15
  "@floating-ui/react-dom": "2.1.2",
16
- "@trackunit/ui-design-tokens": "1.11.0",
17
- "@trackunit/react-core-contexts-api": "1.12.0",
18
- "@trackunit/shared-utils": "1.13.0"
16
+ "@trackunit/react-core-contexts-api": "1.12.7",
17
+ "@trackunit/i18n-library-translation": "1.11.7"
19
18
  },
20
19
  "module": "./index.esm.js",
21
20
  "main": "./index.cjs.js",