@trackunit/react-modal 1.12.0 → 1.12.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.cjs.js CHANGED
@@ -4,7 +4,6 @@ var jsxRuntime = require('react/jsx-runtime');
4
4
  var i18nLibraryTranslation = require('@trackunit/i18n-library-translation');
5
5
  var react$1 = require('@floating-ui/react');
6
6
  var reactComponents = require('@trackunit/react-components');
7
- var uiDesignTokens = require('@trackunit/ui-design-tokens');
8
7
  var react = require('react');
9
8
  var cssClassVarianceUtilities = require('@trackunit/css-class-variance-utilities');
10
9
  var reactCoreContextsApi = require('@trackunit/react-core-contexts-api');
@@ -52,15 +51,14 @@ const setupLibraryTranslations = () => {
52
51
  };
53
52
 
54
53
  const cvaModalContainer = cssClassVarianceUtilities.cvaMerge([
55
- "h-full",
56
- "w-full",
57
54
  "flex",
58
- "fixed",
59
- "inset-0",
60
- "min-h-[100dvh]",
61
- "min-w-[100dvw]",
62
- "overflow-auto",
63
55
  "items-center",
56
+ // Allow clicks to pass through to FloatingOverlay for dismiss behavior
57
+ "pointer-events-none",
58
+ // Animate scale changes when stacking modals
59
+ "transition-transform",
60
+ "duration-200",
61
+ "ease-out",
64
62
  ]);
65
63
  const modalSizeConfigs = {
66
64
  small: {
@@ -117,10 +115,25 @@ const cvaModalCard = cssClassVarianceUtilities.cvaMerge([
117
115
  "w-[clamp(var(--modal-min-width),calc(var(--modal-viewport-width-percent)-var(--modal-padding-offset)-((100dvw-var(--modal-breakpoint))*var(--modal-transition-rate))),var(--modal-max-width))]",
118
116
  "max-h-[var(--modal-max-height)]",
119
117
  "@container", // This is used to target the container size for all children
120
- ]);
118
+ // Re-enable pointer events for modal content (container has pointer-events-none)
119
+ "pointer-events-auto",
120
+ ], {
121
+ variants: {
122
+ animation: {
123
+ // First modal in stack: simple fade
124
+ fade: "animate-fade-in-fast",
125
+ // Stacked modals: fade + rise from below
126
+ rise: "animate-fade-in-rising",
127
+ },
128
+ },
129
+ defaultVariants: {
130
+ animation: "fade",
131
+ },
132
+ });
121
133
  const cvaModalBackdrop = cssClassVarianceUtilities.cvaMerge([
134
+ "flex",
122
135
  "justify-center",
123
- "items-center",
136
+ // "items-center",
124
137
  "fixed",
125
138
  "inset-0",
126
139
  "min-h-[100dvh]",
@@ -128,28 +141,25 @@ const cvaModalBackdrop = cssClassVarianceUtilities.cvaMerge([
128
141
  "w-full",
129
142
  "h-full",
130
143
  "z-overlay",
131
- "invisible",
132
- "opacity-0",
133
- "transition-all",
134
- "bg-black/50",
135
- "animate-modal-fade",
136
- ]);
137
-
138
- /**
139
- * The Backdrop for the modal.
140
- *
141
- * @param {ModalBackdropProps} props - The props for the ModalBackdrop component
142
- * @returns {ReactElement} ModalBackdrop component
143
- */
144
- const ModalBackdrop = ({ children, onClick }) => {
145
- const ref = react.useRef(null);
146
- return (jsxRuntime.jsx("div", { className: cvaModalBackdrop(), onMouseDown: e => {
147
- // only react on click on backdrop - accessing first child to get around styled element
148
- if (ref.current !== null && e.target === ref.current.children[0]) {
149
- onClick();
150
- }
151
- }, ref: ref, children: children }));
152
- };
144
+ ], {
145
+ variants: {
146
+ isFrontmost: {
147
+ // Frontmost modal shows backdrop
148
+ true: "bg-black/50",
149
+ // Non-frontmost modals have transparent backdrop with no transition (avoids flash)
150
+ false: ["bg-transparent", "transition-none"],
151
+ },
152
+ shouldAnimate: {
153
+ // Only animate backdrop when this is the first/only modal
154
+ true: "animate-fade-in-fast",
155
+ false: [],
156
+ },
157
+ },
158
+ defaultVariants: {
159
+ isFrontmost: true,
160
+ shouldAnimate: true,
161
+ },
162
+ });
153
163
 
154
164
  function resolveRootEl(refLike) {
155
165
  if (refLike && typeof refLike === "object" && "current" in refLike) {
@@ -256,6 +266,244 @@ const useModalFooterBorder = (rootRef, { footerClass = "border-t", enabled = tru
256
266
  }, [enabled, footerClass, bodySelector, footerSelector, rootRef]);
257
267
  };
258
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 {
499
+ depthFromFront,
500
+ stackSize: totalStackSize,
501
+ stackSizeAtOpen,
502
+ };
503
+ };
504
+
505
+ /** Scale factor per modal depth level (5% smaller per level back) */
506
+ const SCALE_FACTOR_PER_LEVEL = 0.05;
259
507
  /**
260
508
  * - Modals are used to present critical information or request user input needed to complete a user's workflow.
261
509
  * - Modals interrupt a user's workflow by design.
@@ -272,9 +520,9 @@ const useModalFooterBorder = (rootRef, { footerClass = "border-t", enabled = tru
272
520
  * );
273
521
  * };
274
522
  *
275
- * const childComponent = ({...modalProps}: UseModalReturnValue) => {
523
+ * const ChildComponent = ({...modalProps}: UseModalReturnValue) => {
276
524
  * return (* <Modal {...modalProps}>
277
- * <ModalHeader onClose={modalProps.close} heading="My Modal" />
525
+ * <ModalHeader onClickClose={modalProps.close} heading="My Modal" />
278
526
  * <ModalBody>
279
527
  * <p>This is a modal</p>
280
528
  * </ModalBody>
@@ -283,14 +531,21 @@ const useModalFooterBorder = (rootRef, { footerClass = "border-t", enabled = tru
283
531
  * };
284
532
  * ```
285
533
  */
286
- const Modal = ({ children, isOpen, role = "dialog", "data-testid": dataTestId, className, size, onBackdropClick, floatingUi, ref, }) => {
534
+ const Modal = ({ children, isOpen, role = "dialog", "data-testid": dataTestId, className, size, floatingUi, ref, }) => {
287
535
  // For dialogs/modals, Floating UI recommends not using floatingStyles since the modal
288
536
  // is viewport-centered via CSS, not positioned relative to a reference element.
289
537
  // See: https://floating-ui.com/docs/dialog
290
538
  const { rootElement, refs, context, getFloatingProps } = floatingUi;
291
539
  const cardRef = react.useRef(null);
540
+ // Merge the custom ref from useModal with FloatingUI's setFloating ref
541
+ const mergedRef = react$1.useMergeRefs([refs.setFloating, ref]);
292
542
  useModalFooterBorder(cardRef, { enabled: isOpen, footerClass: "border-t pt-4" });
293
- return (jsxRuntime.jsx(reactComponents.Portal, { root: rootElement, children: isOpen ? (jsxRuntime.jsx(react$1.FloatingFocusManager, { context: context, children: jsxRuntime.jsx("div", { ref: refs.setFloating, style: { zIndex: uiDesignTokens.zIndex.overlay }, ...getFloatingProps(), children: jsxRuntime.jsx(ModalBackdrop, { onClick: onBackdropClick, children: jsxRuntime.jsx("div", { "aria-modal": true, className: cvaModalContainer(), ref: ref, role: role, children: jsxRuntime.jsx(reactComponents.Card, { className: cvaModalCard({ className }), "data-testid": dataTestId, ref: cardRef, style: getModalCardCSSVariables(size), children: children }) }) }) }) })) : null }));
543
+ // Track modal stack position for stacked modal styling
544
+ const { depthFromFront, stackSizeAtOpen } = useModalStack(isOpen);
545
+ const isFrontmost = depthFromFront === 0;
546
+ 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, children: jsxRuntime.jsx("div", { "aria-modal": true, className: cvaModalContainer(), ref: mergedRef, role: role,
547
+ // Scale down modals that are behind the frontmost
548
+ style: isFrontmost ? undefined : { transform: `scale(${1 - depthFromFront * SCALE_FACTOR_PER_LEVEL})` }, ...getFloatingProps(), children: jsxRuntime.jsx(reactComponents.Card, { className: cvaModalCard({ className, animation: stackSizeAtOpen === 0 ? "fade" : "rise" }), "data-testid": dataTestId, ref: cardRef, style: getModalCardCSSVariables(size), children: children }) }) }) })) : null }));
294
549
  };
295
550
  Modal.displayName = "Modal";
296
551
 
@@ -344,7 +599,7 @@ const cvaModalBodyContainer = cssClassVarianceUtilities.cvaMerge([
344
599
  * @returns {ReactElement} Modal body wrapper element.
345
600
  */
346
601
  const ModalBody = react.forwardRef(({ children, id, "data-testid": dataTestId, className }, ref) => {
347
- return (jsxRuntime.jsx("div", { className: cvaModalBodyContainer({ className }), "data-modal-body": true, "data-testid": dataTestId, id: id, children: children }));
602
+ return (jsxRuntime.jsx("div", { className: cvaModalBodyContainer({ className }), "data-modal-body": true, "data-testid": dataTestId, id: id, ref: ref, children: children }));
348
603
  });
349
604
 
350
605
  const cvaModalFooterContainer = cssClassVarianceUtilities.cvaMerge([
@@ -489,15 +744,15 @@ const cvaIconContainer = cssClassVarianceUtilities.cvaMerge(["flex", "place-item
489
744
  * @param {ModalHeaderProps} props Component props.
490
745
  * @param {string} [props.heading] Main heading text.
491
746
  * @param {string} [props.subHeading] Optional subheading content.
492
- * @param {() => void} props.onClose Close button click handler.
747
+ * @param {() => void} props.onClickClose Close button click handler.
493
748
  * @param {string} [props."data-testid"] Optional test id for the container.
494
749
  * @param {string} [props.className] Optional additional class name(s).
495
- * @returns {ReactElement} The modal header element.
750
+ * @returns The modal header element.
496
751
  */
497
- const ModalHeader = react.forwardRef(({ heading, subHeading, onClose, "data-testid": dataTestId, className, id, children, accessories }, ref) => {
752
+ const ModalHeader = react.forwardRef(({ heading, subHeading, onClickClose, "data-testid": dataTestId, className, id, children, accessories, }, ref) => {
498
753
  return (jsxRuntime.jsxs("div", { className: cvaContainer({
499
754
  className,
500
- }), "data-testid": dataTestId, id: id, 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: onClose, size: "small", variant: "ghost-neutral" }) })] }));
755
+ }), "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" }) })] }));
501
756
  });
502
757
 
503
758
  /**
@@ -508,89 +763,142 @@ const ModalHeader = react.forwardRef(({ heading, subHeading, onClose, "data-test
508
763
  * - <Modal {...modal} size="medium" />
509
764
  */
510
765
  const useModal = (props) => {
511
- const { isOpen: controlledIsOpen, onOpenChange, rootElement, closeOnOutsideClick = true, closeOnEsc = true, onClose, onOpen, ref: customRef, role, } = props ?? {};
512
- const [internalIsOpen, setIsOpen] = react.useState(Boolean(controlledIsOpen));
766
+ const { isOpen: controlledIsOpen, defaultOpen, rootElement, dismiss: dismissOptions, onClose, onOpen, onOpenChange, onBeforeClose, ref: customRef, role, } = props ?? {};
767
+ const [internalIsOpen, setIsOpen] = react.useState(defaultOpen ?? false);
513
768
  const isOpen = typeof controlledIsOpen === "boolean" ? controlledIsOpen : internalIsOpen;
514
- const { blockScroll, restoreScroll } = reactComponents.useScrollBlock();
515
769
  const ref = react.useRef(null);
770
+ // Prevents multiple confirmation dialogs from stacking when user rapidly triggers dismiss
771
+ const isPendingCloseRef = react.useRef(false);
772
+ // Store callbacks and state in refs to keep open/close/toggle stable across renders
773
+ // This prevents cascading re-renders when consumers pass inline callbacks
774
+ const onCloseRef = react.useRef(onClose);
775
+ const onOpenRef = react.useRef(onOpen);
776
+ const onOpenChangeRef = react.useRef(onOpenChange);
777
+ const onBeforeCloseRef = react.useRef(onBeforeClose);
778
+ const isOpenRef = react.useRef(isOpen);
779
+ react.useLayoutEffect(() => {
780
+ onCloseRef.current = onClose;
781
+ onOpenRef.current = onOpen;
782
+ onOpenChangeRef.current = onOpenChange;
783
+ onBeforeCloseRef.current = onBeforeClose;
784
+ isOpenRef.current = isOpen;
785
+ });
516
786
  const modalDialogContext = react.useContext(reactCoreContextsApi.ModalDialogContext);
517
787
  if (!modalDialogContext) {
518
788
  throw new Error("useModal must be used within the ModalDialogContextProvider");
519
789
  }
520
790
  const { openModal, closeModal } = modalDialogContext;
791
+ // Sync controlled isOpen state with iframe size controller
792
+ // This ensures the iframe resizes correctly when using controlled mode
793
+ reactComponents.useWatch({
794
+ value: controlledIsOpen,
795
+ immediate: true,
796
+ skip: typeof controlledIsOpen !== "boolean",
797
+ onChange: (current, prev) => {
798
+ if (current && !prev) {
799
+ void openModal();
800
+ }
801
+ else if (!current && prev) {
802
+ void closeModal();
803
+ }
804
+ },
805
+ });
806
+ // Cleanup: notify context when component unmounts while modal is open
807
+ react.useEffect(() => {
808
+ return () => {
809
+ if (isOpenRef.current) {
810
+ void closeModal();
811
+ }
812
+ };
813
+ }, [closeModal]);
814
+ const handleClose = react.useCallback((event, reason) => {
815
+ setIsOpen(false);
816
+ onCloseRef.current?.(event, reason);
817
+ onOpenChangeRef.current?.(false, event, reason);
818
+ void closeModal();
819
+ }, [closeModal]);
521
820
  const { refs, context } = react$1.useFloating({
522
821
  open: isOpen,
523
- onOpenChange: (newIsOpen, event, openChangeReason) => {
822
+ onOpenChange: (newIsOpen, event, reason) => {
823
+ // Opening - just update state (same as original)
524
824
  if (newIsOpen) {
525
- blockScroll();
526
- }
527
- else {
528
- restoreScroll();
825
+ setIsOpen(true);
826
+ return;
529
827
  }
530
- if (onOpenChange) {
531
- return onOpenChange(newIsOpen, event, openChangeReason);
828
+ // Only pass through the modal-relevant reasons
829
+ const modalReason = reason === "outside-press" || reason === "escape-key" ? reason : undefined;
830
+ if (modalReason && onBeforeCloseRef.current) {
831
+ // Already handling a close attempt, ignore this dismiss event
832
+ if (isPendingCloseRef.current) {
833
+ return;
834
+ }
835
+ isPendingCloseRef.current = true;
836
+ void Promise.resolve(onBeforeCloseRef.current(event, modalReason))
837
+ .then(shouldClose => {
838
+ if (shouldClose) {
839
+ handleClose(event, modalReason);
840
+ }
841
+ })
842
+ .finally(() => {
843
+ isPendingCloseRef.current = false;
844
+ });
845
+ return;
532
846
  }
533
- setIsOpen(newIsOpen);
847
+ // Normal close flow (no onBeforeClose, or not a dismiss reason)
848
+ handleClose(event, modalReason);
534
849
  },
535
850
  whileElementsMounted: react$1.autoUpdate,
536
851
  middleware: [react$1.shift()],
537
852
  });
538
- const dismiss = react$1.useDismiss(context, {
539
- escapeKey: closeOnEsc,
540
- outsidePress: closeOnOutsideClick,
541
- });
853
+ const dismiss = react$1.useDismiss(context, dismissOptions);
542
854
  const { getFloatingProps } = react$1.useInteractions([dismiss]);
543
- // Handles closing and opening the modal outside of Iris apps/outside an iframe.
544
- react.useEffect(() => {
545
- if (isOpen) {
546
- openModal();
547
- }
548
- else {
549
- closeModal();
550
- }
551
- // Return cleanup function that ensures modal is closed when component unmounts
552
- return () => {
553
- closeModal();
554
- };
555
- }, [isOpen, openModal, closeModal]);
556
- const open = react.useCallback((...args) => {
557
- onOpen?.(...args);
558
- blockScroll();
855
+ const open = react.useCallback(() => {
856
+ onOpenRef.current?.();
857
+ onOpenChangeRef.current?.(true);
559
858
  setIsOpen(true);
560
- }, [onOpen, blockScroll]);
561
- const close = react.useCallback((...args) => {
562
- for (const arg of args) {
563
- if (!arg || typeof arg !== "object") {
564
- continue;
565
- }
566
- // @ts-ignore
567
- if ("preventDefault" in arg) {
568
- arg.preventDefault();
569
- }
570
- // @ts-ignore
571
- if ("stopPropagation" in arg) {
572
- arg.stopPropagation();
859
+ void openModal();
860
+ }, [openModal]);
861
+ const close = react.useCallback(() => {
862
+ // If onBeforeClose is provided, check it before closing
863
+ if (onBeforeCloseRef.current) {
864
+ // Already handling a close attempt, ignore this one
865
+ if (isPendingCloseRef.current) {
866
+ return;
573
867
  }
868
+ isPendingCloseRef.current = true;
869
+ void Promise.resolve(onBeforeCloseRef.current(undefined, "programmatic"))
870
+ .then(shouldClose => {
871
+ if (shouldClose) {
872
+ onCloseRef.current?.(undefined, "programmatic");
873
+ onOpenChangeRef.current?.(false, undefined, "programmatic");
874
+ setIsOpen(false);
875
+ void closeModal();
876
+ }
877
+ })
878
+ .finally(() => {
879
+ isPendingCloseRef.current = false;
880
+ });
881
+ return;
574
882
  }
575
- onClose?.(...args);
576
- restoreScroll();
883
+ onCloseRef.current?.(undefined, "programmatic");
884
+ onOpenChangeRef.current?.(false, undefined, "programmatic");
577
885
  setIsOpen(false);
578
- }, [onClose, restoreScroll]);
579
- const toggle = react.useCallback((...args) => {
886
+ void closeModal();
887
+ }, [closeModal]);
888
+ const toggle = react.useCallback(() => {
580
889
  if (isOpen) {
581
- return close(...args);
890
+ close();
891
+ }
892
+ else {
893
+ open();
582
894
  }
583
- return open(...args);
584
- }, [close, isOpen, open]);
895
+ }, [isOpen, open, close]);
585
896
  return react.useMemo(() => ({
586
897
  isOpen,
587
- closeOnOutsideClick,
588
898
  toggle,
589
899
  open,
590
900
  close,
591
901
  ref: customRef ?? ref,
592
- // @ts-ignore TOnCloseArgs could be instantiated with a different subtype of constraint BaseUseModalActionArgs
593
- onBackdropClick: () => (closeOnOutsideClick ? close() : null),
594
902
  floatingUi: {
595
903
  refs,
596
904
  rootElement,
@@ -598,7 +906,7 @@ const useModal = (props) => {
598
906
  getFloatingProps,
599
907
  },
600
908
  role,
601
- }), [isOpen, closeOnOutsideClick, toggle, open, close, customRef, refs, rootElement, context, getFloatingProps, role]);
909
+ }), [isOpen, toggle, open, close, customRef, refs, rootElement, context, getFloatingProps, role]);
602
910
  };
603
911
 
604
912
  /*
@@ -610,9 +918,11 @@ const useModal = (props) => {
610
918
  */
611
919
  setupLibraryTranslations();
612
920
 
921
+ exports.IFRAME_MODAL_STACK_MESSAGE_TYPE = IFRAME_MODAL_STACK_MESSAGE_TYPE;
922
+ exports.MODAL_STACK_MESSAGE_TYPE = MODAL_STACK_MESSAGE_TYPE;
613
923
  exports.Modal = Modal;
614
- exports.ModalBackdrop = ModalBackdrop;
615
924
  exports.ModalBody = ModalBody;
616
925
  exports.ModalFooter = ModalFooter;
617
926
  exports.ModalHeader = ModalHeader;
927
+ exports.modalStackRegistry = modalStackRegistry;
618
928
  exports.useModal = useModal;