@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.cjs.js +407 -97
- package/index.esm.js +408 -100
- package/package.json +7 -8
- package/src/index.d.ts +1 -1
- package/src/modal/Modal.d.ts +5 -5
- package/src/modal/Modal.stories.d.ts +3 -19
- package/src/modal/Modal.variants.d.ts +7 -2
- package/src/modal/ModalHeader/ModalHeader.d.ts +6 -6
- package/src/modal/ModalHeader/ModalHeader.stories.d.ts +1 -1
- package/src/modal/modalStackRegistry.d.ts +119 -0
- package/src/modal/useModal.d.ts +97 -27
- package/src/modal/useModalStack.d.ts +25 -0
- package/src/translation.d.ts +4 -7
- package/src/modal/ModalBackdrop.d.ts +0 -12
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
523
|
+
* const ChildComponent = ({...modalProps}: UseModalReturnValue) => {
|
|
276
524
|
* return (* <Modal {...modalProps}>
|
|
277
|
-
* <ModalHeader
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
|
750
|
+
* @returns The modal header element.
|
|
496
751
|
*/
|
|
497
|
-
const ModalHeader = react.forwardRef(({ heading, subHeading,
|
|
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:
|
|
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,
|
|
512
|
-
const [internalIsOpen, setIsOpen] = react.useState(
|
|
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,
|
|
822
|
+
onOpenChange: (newIsOpen, event, reason) => {
|
|
823
|
+
// Opening - just update state (same as original)
|
|
524
824
|
if (newIsOpen) {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
else {
|
|
528
|
-
restoreScroll();
|
|
825
|
+
setIsOpen(true);
|
|
826
|
+
return;
|
|
529
827
|
}
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
576
|
-
|
|
883
|
+
onCloseRef.current?.(undefined, "programmatic");
|
|
884
|
+
onOpenChangeRef.current?.(false, undefined, "programmatic");
|
|
577
885
|
setIsOpen(false);
|
|
578
|
-
|
|
579
|
-
|
|
886
|
+
void closeModal();
|
|
887
|
+
}, [closeModal]);
|
|
888
|
+
const toggle = react.useCallback(() => {
|
|
580
889
|
if (isOpen) {
|
|
581
|
-
|
|
890
|
+
close();
|
|
891
|
+
}
|
|
892
|
+
else {
|
|
893
|
+
open();
|
|
582
894
|
}
|
|
583
|
-
|
|
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,
|
|
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;
|