@trackunit/react-modal 1.20.10 → 1.20.11

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
@@ -266,238 +266,6 @@ const useModalFooterBorder = (rootRef, { footerClass = "border-t", enabled = tru
266
266
  }, [enabled, footerClass, bodySelector, footerSelector, rootRef]);
267
267
  };
268
268
 
269
- /**
270
- * Modal Stack Registry - Cross-Bundle Modal Awareness
271
- *
272
- * ## Architecture Overview
273
- *
274
- * This module provides a registry for tracking open modals across bundle boundaries
275
- * (host application + Iris app iframes). It enables proper modal stacking behavior
276
- * where modals can visually indicate their depth (scale, backdrop visibility, animations).
277
- *
278
- * ## Why postMessage Instead of Penpal?
279
- *
280
- * While most host-iframe communication uses Penpal (RPC-style), modal stack changes
281
- * use raw postMessage for these reasons:
282
- *
283
- * 1. **Package Independence**: This module is part of `@trackunit/react-modal`, a standalone
284
- * UI component library. It should not depend on `iris-app-runtime` to avoid circular
285
- * dependencies and keep the modal component reusable.
286
- *
287
- * 2. **Broadcast Semantics**: When a host modal opens, ALL iframe modals need to know
288
- * simultaneously (not just one specific connection). postMessage with broadcast
289
- * to all iframes fits this pattern naturally.
290
- *
291
- * 3. **Low Latency**: Stack count changes need real-time sync for smooth animations
292
- * (scale transforms, backdrop fading). Raw postMessage has less overhead than Penpal.
293
- *
294
- * 4. **Module Isolation**: Each bundle (host + each iframe) gets its own instance of
295
- * this registry. They need cross-window synchronization, not shared state.
296
- *
297
- * ## Communication Flow
298
- *
299
- * ```
300
- * ┌─────────────────────────────────────────────────────────────────────┐
301
- * │ HOST APPLICATION │
302
- * │ │
303
- * │ modalStackRegistry ←───────────────────────────────────────────┐ │
304
- * │ │ │ │
305
- * │ │ subscribe() │ │
306
- * │ ▼ │ │
307
- * │ ModalDialogProviderHost │ │
308
- * │ │ │ │
309
- * │ ├─► broadcasts MODAL_STACK_MESSAGE_TYPE to all iframes │ │
310
- * │ │ │ │
311
- * │ └─► listens for IFRAME_MODAL_STACK_MESSAGE_TYPE ──────────┘ │
312
- * │ (aggregates per-iframe, cleans up on iframe removal) │
313
- * └─────────────────────────────────────────────────────────────────────┘
314
- * │
315
- * postMessage (bidirectional)
316
- * │
317
- * ┌─────────────────────────────────────────────────────────────────────┐
318
- * │ IFRAME (Iris App) │
319
- * │ │
320
- * │ modalStackRegistry (separate instance) │
321
- * │ │ │
322
- * │ ├─► listens for MODAL_STACK_MESSAGE_TYPE from host │
323
- * │ │ (updates hostModalCount) │
324
- * │ │ │
325
- * │ └─► on register/unregister, broadcasts │
326
- * │ IFRAME_MODAL_STACK_MESSAGE_TYPE to parent │
327
- * └─────────────────────────────────────────────────────────────────────┘
328
- * ```
329
- *
330
- * ## Usage with useModalStack Hook
331
- *
332
- * The `useModalStack` hook consumes this registry to calculate:
333
- * - `depthFromFront`: How many modals are in front of this one (0 = frontmost)
334
- * - `stackSize`: Total modals open (local + host + iframe)
335
- * - `stackSizeAtOpen`: Stack size when this modal opened (for animation decisions)
336
- *
337
- * This enables the Modal component to:
338
- * - Scale down non-frontmost modals (visual stacking effect)
339
- * - Show backdrop only on the frontmost modal
340
- * - Choose appropriate animations based on whether it's opening over another modal
341
- *
342
- * @module modalStackRegistry
343
- */
344
- let modalStack = [];
345
- let hostModalCount = 0;
346
- let iframeModalCount = 0;
347
- const listeners = new Set();
348
- const notify = () => listeners.forEach(listener => listener());
349
- /**
350
- * Message type for host → iframe modal stack communication.
351
- * The host broadcasts this to all iframes when its modal count changes.
352
- * Iframes listen for this to update their `hostModalCount`.
353
- */
354
- const MODAL_STACK_MESSAGE_TYPE = "IRIS_APP_HOST_MODAL_STACK_CHANGE";
355
- /**
356
- * Message type for iframe → host modal stack communication.
357
- * Each iframe sends this to the parent when its modal count changes.
358
- * The host aggregates these per-iframe for accurate total tracking.
359
- */
360
- const IFRAME_MODAL_STACK_MESSAGE_TYPE = "IRIS_APP_IFRAME_MODAL_STACK_CHANGE";
361
- const isInIframe = typeof window !== "undefined" && window.parent !== window;
362
- /**
363
- * Broadcast this iframe's modal count to the parent (host).
364
- * Only runs when in an iframe context. Called automatically on register/unregister.
365
- */
366
- const broadcastToParent = (modalCount) => {
367
- if (isInIframe) {
368
- window.parent.postMessage({ type: IFRAME_MODAL_STACK_MESSAGE_TYPE, data: { iframeModalCount: modalCount } }, "*");
369
- }
370
- };
371
- /**
372
- * Set up the global message listener for cross-bundle modal stack communication.
373
- * This listener runs in the same bundle context as the Modal components, ensuring
374
- * the correct registry instance is updated.
375
- *
376
- * Note: The host also has a separate listener in ModalDialogProviderHost that
377
- * tracks per-iframe counts and handles cleanup when iframes are removed.
378
- */
379
- if (typeof window !== "undefined") {
380
- window.addEventListener("message", event => {
381
- // Host → Iframe: Update host modal count
382
- if (event.data?.type === MODAL_STACK_MESSAGE_TYPE && typeof event.data?.data?.hostModalCount === "number") {
383
- modalStackRegistry.setHostModalCount(event.data.data.hostModalCount);
384
- }
385
- // Iframe → Host: Update iframe modal count (fallback, primary handler is in ModalDialogProviderHost)
386
- if (event.data?.type === IFRAME_MODAL_STACK_MESSAGE_TYPE &&
387
- typeof event.data?.data?.iframeModalCount === "number") {
388
- modalStackRegistry.setIframeModalCount(event.data.data.iframeModalCount);
389
- }
390
- });
391
- }
392
- /**
393
- * Module-level registry to track the stack of open modals.
394
- *
395
- * This registry enables:
396
- * - Tracking modal order within a single bundle (local stack)
397
- * - Awareness of modals in other bundles (host ↔ iframe communication)
398
- * - Subscriber notifications for React integration via useSyncExternalStore
399
- *
400
- * Cross-bundle counts:
401
- * - `hostModalCount`: Number of modals open in the host (relevant for iframes)
402
- * - `iframeModalCount`: Number of modals open in iframes (relevant for host)
403
- *
404
- * Note: This is intentionally NOT a React hook because:
405
- * 1. It needs to be a singleton that persists across React component lifecycles
406
- * 2. It must work with postMessage for cross-bundle communication (host ↔ iframe)
407
- * 3. The useModalStack hook consumes this registry via useSyncExternalStore for React integration
408
- */
409
- const modalStackRegistry = {
410
- register: (id) => {
411
- modalStack.push(id);
412
- notify();
413
- // If in iframe, notify parent of our modal count
414
- broadcastToParent(modalStack.length);
415
- },
416
- unregister: (id) => {
417
- modalStack = modalStack.filter(modalId => modalId !== id);
418
- notify();
419
- // If in iframe, notify parent of our modal count
420
- broadcastToParent(modalStack.length);
421
- },
422
- getStackPosition: (id) => modalStack.indexOf(id),
423
- getStackSize: () => modalStack.length,
424
- /** Get the number of modals open in the host (only relevant for Iris apps in iframes) */
425
- getHostModalCount: () => hostModalCount,
426
- /** Get the number of modals open in iframes (only relevant for host) */
427
- getIframeModalCount: () => iframeModalCount,
428
- /** Set the host modal count (called when receiving messages from host) */
429
- setHostModalCount: (count) => {
430
- if (hostModalCount !== count) {
431
- hostModalCount = count;
432
- notify();
433
- }
434
- },
435
- /** Set the iframe modal count (called when receiving messages from iframes) */
436
- setIframeModalCount: (count) => {
437
- if (iframeModalCount !== count) {
438
- iframeModalCount = count;
439
- notify();
440
- }
441
- },
442
- subscribe: (listener) => {
443
- listeners.add(listener);
444
- return () => {
445
- listeners.delete(listener);
446
- };
447
- },
448
- };
449
-
450
- // Track the stack size when each modal opened (includes host modals for proper animation)
451
- const modalOpenState = new Map();
452
- /**
453
- * Hook to track this modal's position in the stack of open modals.
454
- * Returns the depth from front (0 = frontmost, 1 = one modal in front, etc.)
455
- *
456
- * This hook is aware of modals across bundle boundaries (host + Iris apps)
457
- * by listening for host modal count changes via postMessage.
458
- *
459
- * @param isOpen - Whether the modal is currently open
460
- */
461
- const useModalStack = (isOpen) => {
462
- const modalId = react.useId();
463
- // Subscribe to stack changes to re-render when stack updates
464
- const localStackSize = react.useSyncExternalStore(modalStackRegistry.subscribe, modalStackRegistry.getStackSize, modalStackRegistry.getStackSize);
465
- // Subscribe to host modal count (only relevant for Iris apps in iframes)
466
- const hostModalCount = react.useSyncExternalStore(modalStackRegistry.subscribe, modalStackRegistry.getHostModalCount, modalStackRegistry.getHostModalCount);
467
- // Subscribe to iframe modal count (only relevant for host)
468
- const iframeModalCount = react.useSyncExternalStore(modalStackRegistry.subscribe, modalStackRegistry.getIframeModalCount, modalStackRegistry.getIframeModalCount);
469
- const stackPosition = react.useSyncExternalStore(modalStackRegistry.subscribe, () => modalStackRegistry.getStackPosition(modalId), () => modalStackRegistry.getStackPosition(modalId));
470
- // Register/unregister based on open state
471
- // useLayoutEffect ensures registration happens before paint to avoid backdrop flash
472
- react.useLayoutEffect(() => {
473
- if (isOpen) {
474
- // Capture total stack size before registering (includes cross-bundle modals for proper animation)
475
- // - hostModalCount: modals in host (relevant for iframes)
476
- // - iframeModalCount: modals in iframes (relevant for host)
477
- const totalSizeAtOpen = modalStackRegistry.getStackSize() +
478
- modalStackRegistry.getHostModalCount() +
479
- modalStackRegistry.getIframeModalCount();
480
- modalOpenState.set(modalId, totalSizeAtOpen);
481
- modalStackRegistry.register(modalId);
482
- return () => {
483
- modalStackRegistry.unregister(modalId);
484
- modalOpenState.delete(modalId);
485
- };
486
- }
487
- return undefined;
488
- }, [isOpen, modalId]);
489
- // Total stack size includes local modals, host modals, and iframe modals
490
- const totalStackSize = localStackSize + hostModalCount + iframeModalCount;
491
- // Calculate depth from front (0 = frontmost)
492
- // Host modals are always "on top" of Iris app modals, so if hostModalCount > 0,
493
- // all Iris app modals have at least that many modals in front of them
494
- const localDepthFromFront = stackPosition >= 0 ? localStackSize - 1 - stackPosition : 0;
495
- const depthFromFront = localDepthFromFront + hostModalCount;
496
- // Get the total stack size when this modal opened (stable once set)
497
- const stackSizeAtOpen = modalOpenState.get(modalId) ?? 0;
498
- return react.useMemo(() => ({ depthFromFront, stackSize: totalStackSize, stackSizeAtOpen }), [depthFromFront, totalStackSize, stackSizeAtOpen]);
499
- };
500
-
501
269
  /** Scale factor per modal depth level (5% smaller per level back) */
502
270
  const SCALE_FACTOR_PER_LEVEL = 0.05;
503
271
  /**
@@ -535,7 +303,7 @@ const SCALE_FACTOR_PER_LEVEL = 0.05;
535
303
  * @param {ModalProps} props - The props for the Modal component
536
304
  * @returns {ReactElement} Modal component
537
305
  */
538
- const Modal = ({ children, isOpen, role = "dialog", "data-testid": dataTestId, className, size, floatingUi, ref, restoreFocus = true, }) => {
306
+ const Modal = ({ children, isOpen, role = "dialog", "data-testid": dataTestId, className, size, floatingUi, ref, restoreFocus = true, depthFromFront, stackSizeAtOpen, }) => {
539
307
  // For dialogs/modals, Floating UI recommends not using floatingStyles since the modal
540
308
  // is viewport-centered via CSS, not positioned relative to a reference element.
541
309
  // See: https://floating-ui.com/docs/dialog
@@ -544,8 +312,6 @@ const Modal = ({ children, isOpen, role = "dialog", "data-testid": dataTestId, c
544
312
  // Merge the custom ref from useModal with FloatingUI's setFloating ref
545
313
  const mergedRef = react$1.useMergeRefs([refs.setFloating, ref]);
546
314
  useModalFooterBorder(cardRef, { enabled: isOpen, footerClass: "border-t pt-4" });
547
- // Track modal stack position for stacked modal styling
548
- const { depthFromFront, stackSizeAtOpen } = useModalStack(isOpen);
549
315
  const isFrontmost = depthFromFront === 0;
550
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,
551
317
  // Scale down modals that are behind the frontmost
@@ -763,6 +529,238 @@ const ModalHeader = react.forwardRef(({ heading, subHeading, onClickClose, "data
763
529
  }), "data-testid": dataTestId, id: id, ref: ref, children: [jsxRuntime.jsxs("div", { className: cvaHeadingContainer(), children: [jsxRuntime.jsxs("div", { className: cvaTitleContainer(), children: [jsxRuntime.jsx(reactComponents.Heading, { variant: "tertiary", children: heading }), accessories] }), Boolean(subHeading) ? (jsxRuntime.jsx(reactComponents.Text, { size: "small", subtle: true, children: subHeading })) : null, children] }), jsxRuntime.jsx("div", { className: cvaIconContainer(), children: jsxRuntime.jsx(reactComponents.IconButton, { className: "!h-min", "data-testid": dataTestId ? `${dataTestId}-close-button` : "modal-close-button", icon: jsxRuntime.jsx(reactComponents.Icon, { name: "XMark", size: "small" }), onClick: onClickClose, size: "small", variant: "ghost-neutral" }) })] }));
764
530
  });
765
531
 
532
+ /**
533
+ * Modal Stack Registry - Cross-Bundle Modal Awareness
534
+ *
535
+ * ## Architecture Overview
536
+ *
537
+ * This module provides a registry for tracking open modals across bundle boundaries
538
+ * (host application + Iris app iframes). It enables proper modal stacking behavior
539
+ * where modals can visually indicate their depth (scale, backdrop visibility, animations).
540
+ *
541
+ * ## Why postMessage Instead of Penpal?
542
+ *
543
+ * While most host-iframe communication uses Penpal (RPC-style), modal stack changes
544
+ * use raw postMessage for these reasons:
545
+ *
546
+ * 1. **Package Independence**: This module is part of `@trackunit/react-modal`, a standalone
547
+ * UI component library. It should not depend on `iris-app-runtime` to avoid circular
548
+ * dependencies and keep the modal component reusable.
549
+ *
550
+ * 2. **Broadcast Semantics**: When a host modal opens, ALL iframe modals need to know
551
+ * simultaneously (not just one specific connection). postMessage with broadcast
552
+ * to all iframes fits this pattern naturally.
553
+ *
554
+ * 3. **Low Latency**: Stack count changes need real-time sync for smooth animations
555
+ * (scale transforms, backdrop fading). Raw postMessage has less overhead than Penpal.
556
+ *
557
+ * 4. **Module Isolation**: Each bundle (host + each iframe) gets its own instance of
558
+ * this registry. They need cross-window synchronization, not shared state.
559
+ *
560
+ * ## Communication Flow
561
+ *
562
+ * ```
563
+ * ┌─────────────────────────────────────────────────────────────────────┐
564
+ * │ HOST APPLICATION │
565
+ * │ │
566
+ * │ modalStackRegistry ←───────────────────────────────────────────┐ │
567
+ * │ │ │ │
568
+ * │ │ subscribe() │ │
569
+ * │ ▼ │ │
570
+ * │ ModalDialogProviderHost │ │
571
+ * │ │ │ │
572
+ * │ ├─► broadcasts MODAL_STACK_MESSAGE_TYPE to all iframes │ │
573
+ * │ │ │ │
574
+ * │ └─► listens for IFRAME_MODAL_STACK_MESSAGE_TYPE ──────────┘ │
575
+ * │ (aggregates per-iframe, cleans up on iframe removal) │
576
+ * └─────────────────────────────────────────────────────────────────────┘
577
+ * │
578
+ * postMessage (bidirectional)
579
+ * │
580
+ * ┌─────────────────────────────────────────────────────────────────────┐
581
+ * │ IFRAME (Iris App) │
582
+ * │ │
583
+ * │ modalStackRegistry (separate instance) │
584
+ * │ │ │
585
+ * │ ├─► listens for MODAL_STACK_MESSAGE_TYPE from host │
586
+ * │ │ (updates hostModalCount) │
587
+ * │ │ │
588
+ * │ └─► on register/unregister, broadcasts │
589
+ * │ IFRAME_MODAL_STACK_MESSAGE_TYPE to parent │
590
+ * └─────────────────────────────────────────────────────────────────────┘
591
+ * ```
592
+ *
593
+ * ## Usage with useModalStack Hook
594
+ *
595
+ * The `useModalStack` hook consumes this registry to calculate:
596
+ * - `depthFromFront`: How many modals are in front of this one (0 = frontmost)
597
+ * - `stackSize`: Total modals open (local + host + iframe)
598
+ * - `stackSizeAtOpen`: Stack size when this modal opened (for animation decisions)
599
+ *
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
+ * @module modalStackRegistry
606
+ */
607
+ let modalStack = [];
608
+ let hostModalCount = 0;
609
+ let iframeModalCount = 0;
610
+ const listeners = new Set();
611
+ const notify = () => listeners.forEach(listener => listener());
612
+ /**
613
+ * Message type for host → iframe modal stack communication.
614
+ * The host broadcasts this to all iframes when its modal count changes.
615
+ * Iframes listen for this to update their `hostModalCount`.
616
+ */
617
+ const MODAL_STACK_MESSAGE_TYPE = "IRIS_APP_HOST_MODAL_STACK_CHANGE";
618
+ /**
619
+ * Message type for iframe → host modal stack communication.
620
+ * Each iframe sends this to the parent when its modal count changes.
621
+ * The host aggregates these per-iframe for accurate total tracking.
622
+ */
623
+ const IFRAME_MODAL_STACK_MESSAGE_TYPE = "IRIS_APP_IFRAME_MODAL_STACK_CHANGE";
624
+ const isInIframe = typeof window !== "undefined" && window.parent !== window;
625
+ /**
626
+ * Broadcast this iframe's modal count to the parent (host).
627
+ * Only runs when in an iframe context. Called automatically on register/unregister.
628
+ */
629
+ const broadcastToParent = (modalCount) => {
630
+ if (isInIframe) {
631
+ window.parent.postMessage({ type: IFRAME_MODAL_STACK_MESSAGE_TYPE, data: { iframeModalCount: modalCount } }, "*");
632
+ }
633
+ };
634
+ /**
635
+ * Set up the global message listener for cross-bundle modal stack communication.
636
+ * This listener runs in the same bundle context as the Modal components, ensuring
637
+ * the correct registry instance is updated.
638
+ *
639
+ * Note: The host also has a separate listener in ModalDialogProviderHost that
640
+ * tracks per-iframe counts and handles cleanup when iframes are removed.
641
+ */
642
+ if (typeof window !== "undefined") {
643
+ window.addEventListener("message", event => {
644
+ // Host → Iframe: Update host modal count
645
+ if (event.data?.type === MODAL_STACK_MESSAGE_TYPE && typeof event.data?.data?.hostModalCount === "number") {
646
+ modalStackRegistry.setHostModalCount(event.data.data.hostModalCount);
647
+ }
648
+ // Iframe → Host: Update iframe modal count (fallback, primary handler is in ModalDialogProviderHost)
649
+ if (event.data?.type === IFRAME_MODAL_STACK_MESSAGE_TYPE &&
650
+ typeof event.data?.data?.iframeModalCount === "number") {
651
+ modalStackRegistry.setIframeModalCount(event.data.data.iframeModalCount);
652
+ }
653
+ });
654
+ }
655
+ /**
656
+ * Module-level registry to track the stack of open modals.
657
+ *
658
+ * This registry enables:
659
+ * - Tracking modal order within a single bundle (local stack)
660
+ * - Awareness of modals in other bundles (host ↔ iframe communication)
661
+ * - Subscriber notifications for React integration via useSyncExternalStore
662
+ *
663
+ * Cross-bundle counts:
664
+ * - `hostModalCount`: Number of modals open in the host (relevant for iframes)
665
+ * - `iframeModalCount`: Number of modals open in iframes (relevant for host)
666
+ *
667
+ * Note: This is intentionally NOT a React hook because:
668
+ * 1. It needs to be a singleton that persists across React component lifecycles
669
+ * 2. It must work with postMessage for cross-bundle communication (host ↔ iframe)
670
+ * 3. The useModalStack hook consumes this registry via useSyncExternalStore for React integration
671
+ */
672
+ const modalStackRegistry = {
673
+ register: (id) => {
674
+ modalStack.push(id);
675
+ notify();
676
+ // If in iframe, notify parent of our modal count
677
+ broadcastToParent(modalStack.length);
678
+ },
679
+ unregister: (id) => {
680
+ modalStack = modalStack.filter(modalId => modalId !== id);
681
+ notify();
682
+ // If in iframe, notify parent of our modal count
683
+ broadcastToParent(modalStack.length);
684
+ },
685
+ getStackPosition: (id) => modalStack.indexOf(id),
686
+ getStackSize: () => modalStack.length,
687
+ /** Get the number of modals open in the host (only relevant for Iris apps in iframes) */
688
+ getHostModalCount: () => hostModalCount,
689
+ /** Get the number of modals open in iframes (only relevant for host) */
690
+ getIframeModalCount: () => iframeModalCount,
691
+ /** Set the host modal count (called when receiving messages from host) */
692
+ setHostModalCount: (count) => {
693
+ if (hostModalCount !== count) {
694
+ hostModalCount = count;
695
+ notify();
696
+ }
697
+ },
698
+ /** Set the iframe modal count (called when receiving messages from iframes) */
699
+ setIframeModalCount: (count) => {
700
+ if (iframeModalCount !== count) {
701
+ iframeModalCount = count;
702
+ notify();
703
+ }
704
+ },
705
+ subscribe: (listener) => {
706
+ listeners.add(listener);
707
+ return () => {
708
+ listeners.delete(listener);
709
+ };
710
+ },
711
+ };
712
+
713
+ // Track the stack size when each modal opened (includes host modals for proper animation)
714
+ const modalOpenState = new Map();
715
+ /**
716
+ * Hook to track this modal's position in the stack of open modals.
717
+ * Returns the depth from front (0 = frontmost, 1 = one modal in front, etc.)
718
+ *
719
+ * This hook is aware of modals across bundle boundaries (host + Iris apps)
720
+ * by listening for host modal count changes via postMessage.
721
+ *
722
+ * @param isOpen - Whether the modal is currently open
723
+ */
724
+ const useModalStack = (isOpen) => {
725
+ const modalId = react.useId();
726
+ // Subscribe to stack changes to re-render when stack updates
727
+ const localStackSize = react.useSyncExternalStore(modalStackRegistry.subscribe, modalStackRegistry.getStackSize, modalStackRegistry.getStackSize);
728
+ // Subscribe to host modal count (only relevant for Iris apps in iframes)
729
+ const hostModalCount = react.useSyncExternalStore(modalStackRegistry.subscribe, modalStackRegistry.getHostModalCount, modalStackRegistry.getHostModalCount);
730
+ // Subscribe to iframe modal count (only relevant for host)
731
+ const iframeModalCount = react.useSyncExternalStore(modalStackRegistry.subscribe, modalStackRegistry.getIframeModalCount, modalStackRegistry.getIframeModalCount);
732
+ const stackPosition = react.useSyncExternalStore(modalStackRegistry.subscribe, () => modalStackRegistry.getStackPosition(modalId), () => modalStackRegistry.getStackPosition(modalId));
733
+ // Register/unregister based on open state
734
+ // useLayoutEffect ensures registration happens before paint to avoid backdrop flash
735
+ react.useLayoutEffect(() => {
736
+ if (isOpen) {
737
+ // Capture total stack size before registering (includes cross-bundle modals for proper animation)
738
+ // - hostModalCount: modals in host (relevant for iframes)
739
+ // - iframeModalCount: modals in iframes (relevant for host)
740
+ const totalSizeAtOpen = modalStackRegistry.getStackSize() +
741
+ modalStackRegistry.getHostModalCount() +
742
+ modalStackRegistry.getIframeModalCount();
743
+ modalOpenState.set(modalId, totalSizeAtOpen);
744
+ modalStackRegistry.register(modalId);
745
+ return () => {
746
+ modalStackRegistry.unregister(modalId);
747
+ modalOpenState.delete(modalId);
748
+ };
749
+ }
750
+ return undefined;
751
+ }, [isOpen, modalId]);
752
+ // Total stack size includes local modals, host modals, and iframe modals
753
+ const totalStackSize = localStackSize + hostModalCount + iframeModalCount;
754
+ // Calculate depth from front (0 = frontmost)
755
+ // Host modals are always "on top" of Iris app modals, so if hostModalCount > 0,
756
+ // all Iris app modals have at least that many modals in front of them
757
+ const localDepthFromFront = stackPosition >= 0 ? localStackSize - 1 - stackPosition : 0;
758
+ const depthFromFront = localDepthFromFront + hostModalCount;
759
+ // Get the total stack size when this modal opened (stable once set)
760
+ const stackSizeAtOpen = modalOpenState.get(modalId) ?? 0;
761
+ return react.useMemo(() => ({ depthFromFront, stackSize: totalStackSize, stackSizeAtOpen }), [depthFromFront, totalStackSize, stackSizeAtOpen]);
762
+ };
763
+
766
764
  /**
767
765
  * A hook to handle the state and configuration of Modal components.
768
766
  *
@@ -858,7 +856,8 @@ const useModal = (props) => {
858
856
  whileElementsMounted: react$1.autoUpdate,
859
857
  middleware: [react$1.shift()],
860
858
  });
861
- const dismiss = react$1.useDismiss(context, dismissOptions);
859
+ const { depthFromFront, stackSizeAtOpen } = useModalStack(isOpen);
860
+ const dismiss = react$1.useDismiss(context, { ...dismissOptions, enabled: depthFromFront === 0 });
862
861
  const { getFloatingProps } = react$1.useInteractions([dismiss]);
863
862
  const open = react.useCallback(() => {
864
863
  onOpenRef.current?.();
@@ -914,7 +913,22 @@ const useModal = (props) => {
914
913
  getFloatingProps,
915
914
  },
916
915
  role,
917
- }), [isOpen, toggle, open, close, customRef, refs, rootElement, context, getFloatingProps, role]);
916
+ depthFromFront,
917
+ stackSizeAtOpen,
918
+ }), [
919
+ isOpen,
920
+ toggle,
921
+ open,
922
+ close,
923
+ customRef,
924
+ refs,
925
+ rootElement,
926
+ context,
927
+ getFloatingProps,
928
+ role,
929
+ depthFromFront,
930
+ stackSizeAtOpen,
931
+ ]);
918
932
  };
919
933
 
920
934
  /*
package/index.esm.js CHANGED
@@ -2,7 +2,7 @@ import { jsx, jsxs } from 'react/jsx-runtime';
2
2
  import { registerTranslations } from '@trackunit/i18n-library-translation';
3
3
  import { useMergeRefs, FloatingOverlay, FloatingFocusManager, useFloating, shift, autoUpdate, useDismiss, useInteractions } from '@floating-ui/react';
4
4
  import { Portal, Card, Button, Heading, Text, IconButton, Icon, useWatch } from '@trackunit/react-components';
5
- import { useLayoutEffect, useId, useSyncExternalStore, useMemo, useRef, forwardRef, useState, useContext, useEffect, useCallback } from 'react';
5
+ import { useLayoutEffect, useRef, forwardRef, useId, useSyncExternalStore, useMemo, useState, useContext, useEffect, useCallback } from 'react';
6
6
  import { cvaMerge } from '@trackunit/css-class-variance-utilities';
7
7
  import { ModalDialogContext } from '@trackunit/react-core-contexts-api';
8
8
 
@@ -264,238 +264,6 @@ const useModalFooterBorder = (rootRef, { footerClass = "border-t", enabled = tru
264
264
  }, [enabled, footerClass, bodySelector, footerSelector, rootRef]);
265
265
  };
266
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 useMemo(() => ({ depthFromFront, stackSize: totalStackSize, stackSizeAtOpen }), [depthFromFront, totalStackSize, stackSizeAtOpen]);
497
- };
498
-
499
267
  /** Scale factor per modal depth level (5% smaller per level back) */
500
268
  const SCALE_FACTOR_PER_LEVEL = 0.05;
501
269
  /**
@@ -533,7 +301,7 @@ const SCALE_FACTOR_PER_LEVEL = 0.05;
533
301
  * @param {ModalProps} props - The props for the Modal component
534
302
  * @returns {ReactElement} Modal component
535
303
  */
536
- const Modal = ({ children, isOpen, role = "dialog", "data-testid": dataTestId, className, size, floatingUi, ref, restoreFocus = true, }) => {
304
+ const Modal = ({ children, isOpen, role = "dialog", "data-testid": dataTestId, className, size, floatingUi, ref, restoreFocus = true, depthFromFront, stackSizeAtOpen, }) => {
537
305
  // For dialogs/modals, Floating UI recommends not using floatingStyles since the modal
538
306
  // is viewport-centered via CSS, not positioned relative to a reference element.
539
307
  // See: https://floating-ui.com/docs/dialog
@@ -542,8 +310,6 @@ const Modal = ({ children, isOpen, role = "dialog", "data-testid": dataTestId, c
542
310
  // Merge the custom ref from useModal with FloatingUI's setFloating ref
543
311
  const mergedRef = useMergeRefs([refs.setFloating, ref]);
544
312
  useModalFooterBorder(cardRef, { enabled: isOpen, footerClass: "border-t pt-4" });
545
- // Track modal stack position for stacked modal styling
546
- const { depthFromFront, stackSizeAtOpen } = useModalStack(isOpen);
547
313
  const isFrontmost = depthFromFront === 0;
548
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,
549
315
  // Scale down modals that are behind the frontmost
@@ -761,6 +527,238 @@ const ModalHeader = forwardRef(({ heading, subHeading, onClickClose, "data-testi
761
527
  }), "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" }) })] }));
762
528
  });
763
529
 
530
+ /**
531
+ * Modal Stack Registry - Cross-Bundle Modal Awareness
532
+ *
533
+ * ## Architecture Overview
534
+ *
535
+ * This module provides a registry for tracking open modals across bundle boundaries
536
+ * (host application + Iris app iframes). It enables proper modal stacking behavior
537
+ * where modals can visually indicate their depth (scale, backdrop visibility, animations).
538
+ *
539
+ * ## Why postMessage Instead of Penpal?
540
+ *
541
+ * While most host-iframe communication uses Penpal (RPC-style), modal stack changes
542
+ * use raw postMessage for these reasons:
543
+ *
544
+ * 1. **Package Independence**: This module is part of `@trackunit/react-modal`, a standalone
545
+ * UI component library. It should not depend on `iris-app-runtime` to avoid circular
546
+ * dependencies and keep the modal component reusable.
547
+ *
548
+ * 2. **Broadcast Semantics**: When a host modal opens, ALL iframe modals need to know
549
+ * simultaneously (not just one specific connection). postMessage with broadcast
550
+ * to all iframes fits this pattern naturally.
551
+ *
552
+ * 3. **Low Latency**: Stack count changes need real-time sync for smooth animations
553
+ * (scale transforms, backdrop fading). Raw postMessage has less overhead than Penpal.
554
+ *
555
+ * 4. **Module Isolation**: Each bundle (host + each iframe) gets its own instance of
556
+ * this registry. They need cross-window synchronization, not shared state.
557
+ *
558
+ * ## Communication Flow
559
+ *
560
+ * ```
561
+ * ┌─────────────────────────────────────────────────────────────────────┐
562
+ * │ HOST APPLICATION │
563
+ * │ │
564
+ * │ modalStackRegistry ←───────────────────────────────────────────┐ │
565
+ * │ │ │ │
566
+ * │ │ subscribe() │ │
567
+ * │ ▼ │ │
568
+ * │ ModalDialogProviderHost │ │
569
+ * │ │ │ │
570
+ * │ ├─► broadcasts MODAL_STACK_MESSAGE_TYPE to all iframes │ │
571
+ * │ │ │ │
572
+ * │ └─► listens for IFRAME_MODAL_STACK_MESSAGE_TYPE ──────────┘ │
573
+ * │ (aggregates per-iframe, cleans up on iframe removal) │
574
+ * └─────────────────────────────────────────────────────────────────────┘
575
+ * │
576
+ * postMessage (bidirectional)
577
+ * │
578
+ * ┌─────────────────────────────────────────────────────────────────────┐
579
+ * │ IFRAME (Iris App) │
580
+ * │ │
581
+ * │ modalStackRegistry (separate instance) │
582
+ * │ │ │
583
+ * │ ├─► listens for MODAL_STACK_MESSAGE_TYPE from host │
584
+ * │ │ (updates hostModalCount) │
585
+ * │ │ │
586
+ * │ └─► on register/unregister, broadcasts │
587
+ * │ IFRAME_MODAL_STACK_MESSAGE_TYPE to parent │
588
+ * └─────────────────────────────────────────────────────────────────────┘
589
+ * ```
590
+ *
591
+ * ## Usage with useModalStack Hook
592
+ *
593
+ * The `useModalStack` hook consumes this registry to calculate:
594
+ * - `depthFromFront`: How many modals are in front of this one (0 = frontmost)
595
+ * - `stackSize`: Total modals open (local + host + iframe)
596
+ * - `stackSizeAtOpen`: Stack size when this modal opened (for animation decisions)
597
+ *
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
+ * @module modalStackRegistry
604
+ */
605
+ let modalStack = [];
606
+ let hostModalCount = 0;
607
+ let iframeModalCount = 0;
608
+ const listeners = new Set();
609
+ const notify = () => listeners.forEach(listener => listener());
610
+ /**
611
+ * Message type for host → iframe modal stack communication.
612
+ * The host broadcasts this to all iframes when its modal count changes.
613
+ * Iframes listen for this to update their `hostModalCount`.
614
+ */
615
+ const MODAL_STACK_MESSAGE_TYPE = "IRIS_APP_HOST_MODAL_STACK_CHANGE";
616
+ /**
617
+ * Message type for iframe → host modal stack communication.
618
+ * Each iframe sends this to the parent when its modal count changes.
619
+ * The host aggregates these per-iframe for accurate total tracking.
620
+ */
621
+ const IFRAME_MODAL_STACK_MESSAGE_TYPE = "IRIS_APP_IFRAME_MODAL_STACK_CHANGE";
622
+ const isInIframe = typeof window !== "undefined" && window.parent !== window;
623
+ /**
624
+ * Broadcast this iframe's modal count to the parent (host).
625
+ * Only runs when in an iframe context. Called automatically on register/unregister.
626
+ */
627
+ const broadcastToParent = (modalCount) => {
628
+ if (isInIframe) {
629
+ window.parent.postMessage({ type: IFRAME_MODAL_STACK_MESSAGE_TYPE, data: { iframeModalCount: modalCount } }, "*");
630
+ }
631
+ };
632
+ /**
633
+ * Set up the global message listener for cross-bundle modal stack communication.
634
+ * This listener runs in the same bundle context as the Modal components, ensuring
635
+ * the correct registry instance is updated.
636
+ *
637
+ * Note: The host also has a separate listener in ModalDialogProviderHost that
638
+ * tracks per-iframe counts and handles cleanup when iframes are removed.
639
+ */
640
+ if (typeof window !== "undefined") {
641
+ window.addEventListener("message", event => {
642
+ // Host → Iframe: Update host modal count
643
+ if (event.data?.type === MODAL_STACK_MESSAGE_TYPE && typeof event.data?.data?.hostModalCount === "number") {
644
+ modalStackRegistry.setHostModalCount(event.data.data.hostModalCount);
645
+ }
646
+ // Iframe → Host: Update iframe modal count (fallback, primary handler is in ModalDialogProviderHost)
647
+ if (event.data?.type === IFRAME_MODAL_STACK_MESSAGE_TYPE &&
648
+ typeof event.data?.data?.iframeModalCount === "number") {
649
+ modalStackRegistry.setIframeModalCount(event.data.data.iframeModalCount);
650
+ }
651
+ });
652
+ }
653
+ /**
654
+ * Module-level registry to track the stack of open modals.
655
+ *
656
+ * This registry enables:
657
+ * - Tracking modal order within a single bundle (local stack)
658
+ * - Awareness of modals in other bundles (host ↔ iframe communication)
659
+ * - Subscriber notifications for React integration via useSyncExternalStore
660
+ *
661
+ * Cross-bundle counts:
662
+ * - `hostModalCount`: Number of modals open in the host (relevant for iframes)
663
+ * - `iframeModalCount`: Number of modals open in iframes (relevant for host)
664
+ *
665
+ * Note: This is intentionally NOT a React hook because:
666
+ * 1. It needs to be a singleton that persists across React component lifecycles
667
+ * 2. It must work with postMessage for cross-bundle communication (host ↔ iframe)
668
+ * 3. The useModalStack hook consumes this registry via useSyncExternalStore for React integration
669
+ */
670
+ const modalStackRegistry = {
671
+ register: (id) => {
672
+ modalStack.push(id);
673
+ notify();
674
+ // If in iframe, notify parent of our modal count
675
+ broadcastToParent(modalStack.length);
676
+ },
677
+ unregister: (id) => {
678
+ modalStack = modalStack.filter(modalId => modalId !== id);
679
+ notify();
680
+ // If in iframe, notify parent of our modal count
681
+ broadcastToParent(modalStack.length);
682
+ },
683
+ getStackPosition: (id) => modalStack.indexOf(id),
684
+ getStackSize: () => modalStack.length,
685
+ /** Get the number of modals open in the host (only relevant for Iris apps in iframes) */
686
+ getHostModalCount: () => hostModalCount,
687
+ /** Get the number of modals open in iframes (only relevant for host) */
688
+ getIframeModalCount: () => iframeModalCount,
689
+ /** Set the host modal count (called when receiving messages from host) */
690
+ setHostModalCount: (count) => {
691
+ if (hostModalCount !== count) {
692
+ hostModalCount = count;
693
+ notify();
694
+ }
695
+ },
696
+ /** Set the iframe modal count (called when receiving messages from iframes) */
697
+ setIframeModalCount: (count) => {
698
+ if (iframeModalCount !== count) {
699
+ iframeModalCount = count;
700
+ notify();
701
+ }
702
+ },
703
+ subscribe: (listener) => {
704
+ listeners.add(listener);
705
+ return () => {
706
+ listeners.delete(listener);
707
+ };
708
+ },
709
+ };
710
+
711
+ // Track the stack size when each modal opened (includes host modals for proper animation)
712
+ const modalOpenState = new Map();
713
+ /**
714
+ * Hook to track this modal's position in the stack of open modals.
715
+ * Returns the depth from front (0 = frontmost, 1 = one modal in front, etc.)
716
+ *
717
+ * This hook is aware of modals across bundle boundaries (host + Iris apps)
718
+ * by listening for host modal count changes via postMessage.
719
+ *
720
+ * @param isOpen - Whether the modal is currently open
721
+ */
722
+ const useModalStack = (isOpen) => {
723
+ const modalId = useId();
724
+ // Subscribe to stack changes to re-render when stack updates
725
+ const localStackSize = useSyncExternalStore(modalStackRegistry.subscribe, modalStackRegistry.getStackSize, modalStackRegistry.getStackSize);
726
+ // Subscribe to host modal count (only relevant for Iris apps in iframes)
727
+ const hostModalCount = useSyncExternalStore(modalStackRegistry.subscribe, modalStackRegistry.getHostModalCount, modalStackRegistry.getHostModalCount);
728
+ // Subscribe to iframe modal count (only relevant for host)
729
+ const iframeModalCount = useSyncExternalStore(modalStackRegistry.subscribe, modalStackRegistry.getIframeModalCount, modalStackRegistry.getIframeModalCount);
730
+ const stackPosition = useSyncExternalStore(modalStackRegistry.subscribe, () => modalStackRegistry.getStackPosition(modalId), () => modalStackRegistry.getStackPosition(modalId));
731
+ // Register/unregister based on open state
732
+ // useLayoutEffect ensures registration happens before paint to avoid backdrop flash
733
+ useLayoutEffect(() => {
734
+ if (isOpen) {
735
+ // Capture total stack size before registering (includes cross-bundle modals for proper animation)
736
+ // - hostModalCount: modals in host (relevant for iframes)
737
+ // - iframeModalCount: modals in iframes (relevant for host)
738
+ const totalSizeAtOpen = modalStackRegistry.getStackSize() +
739
+ modalStackRegistry.getHostModalCount() +
740
+ modalStackRegistry.getIframeModalCount();
741
+ modalOpenState.set(modalId, totalSizeAtOpen);
742
+ modalStackRegistry.register(modalId);
743
+ return () => {
744
+ modalStackRegistry.unregister(modalId);
745
+ modalOpenState.delete(modalId);
746
+ };
747
+ }
748
+ return undefined;
749
+ }, [isOpen, modalId]);
750
+ // Total stack size includes local modals, host modals, and iframe modals
751
+ const totalStackSize = localStackSize + hostModalCount + iframeModalCount;
752
+ // Calculate depth from front (0 = frontmost)
753
+ // Host modals are always "on top" of Iris app modals, so if hostModalCount > 0,
754
+ // all Iris app modals have at least that many modals in front of them
755
+ const localDepthFromFront = stackPosition >= 0 ? localStackSize - 1 - stackPosition : 0;
756
+ const depthFromFront = localDepthFromFront + hostModalCount;
757
+ // Get the total stack size when this modal opened (stable once set)
758
+ const stackSizeAtOpen = modalOpenState.get(modalId) ?? 0;
759
+ return useMemo(() => ({ depthFromFront, stackSize: totalStackSize, stackSizeAtOpen }), [depthFromFront, totalStackSize, stackSizeAtOpen]);
760
+ };
761
+
764
762
  /**
765
763
  * A hook to handle the state and configuration of Modal components.
766
764
  *
@@ -856,7 +854,8 @@ const useModal = (props) => {
856
854
  whileElementsMounted: autoUpdate,
857
855
  middleware: [shift()],
858
856
  });
859
- const dismiss = useDismiss(context, dismissOptions);
857
+ const { depthFromFront, stackSizeAtOpen } = useModalStack(isOpen);
858
+ const dismiss = useDismiss(context, { ...dismissOptions, enabled: depthFromFront === 0 });
860
859
  const { getFloatingProps } = useInteractions([dismiss]);
861
860
  const open = useCallback(() => {
862
861
  onOpenRef.current?.();
@@ -912,7 +911,22 @@ const useModal = (props) => {
912
911
  getFloatingProps,
913
912
  },
914
913
  role,
915
- }), [isOpen, toggle, open, close, customRef, refs, rootElement, context, getFloatingProps, role]);
914
+ depthFromFront,
915
+ stackSizeAtOpen,
916
+ }), [
917
+ isOpen,
918
+ toggle,
919
+ open,
920
+ close,
921
+ customRef,
922
+ refs,
923
+ rootElement,
924
+ context,
925
+ getFloatingProps,
926
+ role,
927
+ depthFromFront,
928
+ stackSizeAtOpen,
929
+ ]);
916
930
  };
917
931
 
918
932
  /*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-modal",
3
- "version": "1.20.10",
3
+ "version": "1.20.11",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "dependencies": {
10
10
  "@floating-ui/react": "^0.26.25",
11
- "@trackunit/react-components": "1.21.7",
11
+ "@trackunit/react-components": "1.21.8",
12
12
  "@trackunit/css-class-variance-utilities": "1.11.92",
13
13
  "@trackunit/shared-utils": "1.13.92",
14
14
  "@floating-ui/react-dom": "2.1.2",
@@ -16,6 +16,7 @@
16
16
  "@trackunit/i18n-library-translation": "1.17.6"
17
17
  },
18
18
  "peerDependencies": {
19
+ "@tanstack/react-router": "^1.114.29",
19
20
  "react": "^19.0.0"
20
21
  },
21
22
  "module": "./index.esm.js",
@@ -61,6 +61,6 @@ export type ModalProps = PropsWithChildren<UseModalReturnValue & {
61
61
  * @returns {ReactElement} Modal component
62
62
  */
63
63
  export declare const Modal: {
64
- ({ children, isOpen, role, "data-testid": dataTestId, className, size, floatingUi, ref, restoreFocus, }: ModalProps): ReactElement;
64
+ ({ children, isOpen, role, "data-testid": dataTestId, className, size, floatingUi, ref, restoreFocus, depthFromFront, stackSizeAtOpen, }: ModalProps): ReactElement;
65
65
  displayName: string;
66
66
  };
@@ -133,6 +133,16 @@ export type UseModalReturnValue = {
133
133
  * The aria role for the modal.
134
134
  */
135
135
  role?: AriaRole;
136
+ /**
137
+ * How many modals are stacked in front of this one (0 = frontmost).
138
+ * Used by Modal for visual stacking (scale, backdrop).
139
+ */
140
+ depthFromFront: number;
141
+ /**
142
+ * The total modal stack size when this modal opened.
143
+ * Used by Modal for animation decisions.
144
+ */
145
+ stackSizeAtOpen: number;
136
146
  };
137
147
  /**
138
148
  * A hook to handle the state and configuration of Modal components.