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