@trackunit/react-modal 1.21.4 → 1.21.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 +264 -159
- package/index.esm.js +268 -163
- package/package.json +4 -4
- package/src/modal/Modal.d.ts +14 -7
- package/src/modal/Modal.variants.d.ts +24 -2
- package/src/modal/modal-mode-switch-reducer.d.ts +18 -0
- package/src/modal/modalStackRegistry.d.ts +0 -5
- package/src/modal/useModal.d.ts +46 -94
- package/src/modal/useModalFooterBorder.d.ts +3 -10
- package/src/modal/useModalStack.d.ts +4 -5
package/index.esm.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
2
2
|
import { registerTranslations } from '@trackunit/i18n-library-translation';
|
|
3
|
-
import { useMergeRefs,
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { useMergeRefs, FloatingFocusManager, FloatingOverlay, useFloating, shift, autoUpdate, useDismiss, useInteractions } from '@floating-ui/react';
|
|
4
|
+
import { useOverflowBorder, useSheetSnap, useWatch, Card, Portal, Sheet, Button, Heading, Text, IconButton, Icon, useContainerBreakpoints, useViewportBreakpoints, useTimeout, SHEET_TRANSITION_DURATION_MS } from '@trackunit/react-components';
|
|
5
|
+
import { useRef, useCallback, useState, useReducer, useMemo, forwardRef, useId, useSyncExternalStore, useLayoutEffect, useContext, useEffect } from 'react';
|
|
6
6
|
import { cvaMerge } from '@trackunit/css-class-variance-utilities';
|
|
7
|
-
import { ModalDialogContext } from '@trackunit/react-core-contexts-api';
|
|
7
|
+
import { ModalDialogContext, ErrorHandlingContext } from '@trackunit/react-core-contexts-api';
|
|
8
8
|
|
|
9
9
|
var defaultTranslations = {
|
|
10
10
|
|
|
@@ -90,30 +90,49 @@ const modalSizeConfigs = {
|
|
|
90
90
|
maxHeight: "min(80dvh, 1000px)",
|
|
91
91
|
},
|
|
92
92
|
};
|
|
93
|
+
const viewportPercentToContainerUnits = (viewportWidthPercent) => viewportWidthPercent.replaceAll("dvw", "cqw");
|
|
93
94
|
/**
|
|
94
95
|
* Returns the CSS properties for the modal card based on the size.
|
|
95
96
|
*/
|
|
96
|
-
const getModalCardCSSVariables = (size) => {
|
|
97
|
+
const getModalCardCSSVariables = (size, options) => {
|
|
97
98
|
const config = modalSizeConfigs[size];
|
|
99
|
+
const useContainerWidthUnits = options?.useContainerWidthUnits === true;
|
|
100
|
+
const viewportWidthPercent = useContainerWidthUnits
|
|
101
|
+
? viewportPercentToContainerUnits(config.viewportWidthPercent)
|
|
102
|
+
: config.viewportWidthPercent;
|
|
103
|
+
const viewportSpan = useContainerWidthUnits ? "100cqw" : "100dvw";
|
|
98
104
|
// eslint-disable-next-line @trackunit/no-typescript-assertion
|
|
99
105
|
return {
|
|
100
106
|
"--modal-min-width": config.minWidth,
|
|
101
|
-
"--modal-viewport-width-percent":
|
|
107
|
+
"--modal-viewport-width-percent": viewportWidthPercent,
|
|
102
108
|
"--modal-padding-offset": config.paddingOffset,
|
|
103
109
|
"--modal-breakpoint": config.breakpoint,
|
|
104
110
|
"--modal-transition-rate": config.transitionRate,
|
|
105
111
|
"--modal-max-width": config.maxWidth,
|
|
106
112
|
"--modal-max-height": config.maxHeight,
|
|
113
|
+
"--modal-viewport-span": viewportSpan,
|
|
107
114
|
};
|
|
108
115
|
};
|
|
116
|
+
/**
|
|
117
|
+
* Layout contract that modal children depend on. Applied to the children's
|
|
118
|
+
* immediate container in both card mode (the Card) and sheet mode (the
|
|
119
|
+
* aria-modal wrapper). Ensures ModalHeader/ModalBody/ModalFooter experience
|
|
120
|
+
* an identical flex/container-query environment regardless of rendering mode.
|
|
121
|
+
*
|
|
122
|
+
* Does NOT include overflow-y — scrolling is owned by the parent:
|
|
123
|
+
* in card mode by the Card element (`cvaModalCard`), in sheet mode by the
|
|
124
|
+
* Sheet's scroll area. A nested overflow here would absorb content height,
|
|
125
|
+
* preventing Sheet's fit-height measurement from seeing the true size.
|
|
126
|
+
*/
|
|
127
|
+
const cvaModalContentEnvironment = cvaMerge(["flex", "flex-col", "min-h-0", "@container"]);
|
|
109
128
|
const cvaModalCard = cvaMerge([
|
|
110
129
|
"m-auto",
|
|
111
130
|
"shadow-2xl",
|
|
131
|
+
"min-h-0",
|
|
112
132
|
"overflow-y-auto",
|
|
113
|
-
"w-[clamp(var(--modal-min-width),calc(var(--modal-viewport-width-percent)-var(--modal-padding-offset)-((
|
|
133
|
+
"w-[clamp(var(--modal-min-width),calc(var(--modal-viewport-width-percent)-var(--modal-padding-offset)-((var(--modal-viewport-span)-var(--modal-breakpoint))*var(--modal-transition-rate))),var(--modal-max-width))]",
|
|
114
134
|
"max-h-[var(--modal-max-height)]",
|
|
115
|
-
"@container",
|
|
116
|
-
// Re-enable pointer events for modal content (container has pointer-events-none)
|
|
135
|
+
"@container",
|
|
117
136
|
"pointer-events-auto",
|
|
118
137
|
], {
|
|
119
138
|
variants: {
|
|
@@ -122,25 +141,20 @@ const cvaModalCard = cvaMerge([
|
|
|
122
141
|
fade: "animate-fade-in-fast",
|
|
123
142
|
// Stacked modals: fade + rise from below
|
|
124
143
|
rise: "animate-fade-in-rising",
|
|
144
|
+
// No animation (used during formfactor transitions to avoid flicker)
|
|
145
|
+
none: [],
|
|
125
146
|
},
|
|
126
147
|
},
|
|
127
148
|
defaultVariants: {
|
|
128
149
|
animation: "fade",
|
|
129
150
|
},
|
|
130
151
|
});
|
|
131
|
-
const cvaModalBackdrop = cvaMerge([
|
|
132
|
-
"flex",
|
|
133
|
-
"justify-center",
|
|
134
|
-
// "items-center",
|
|
135
|
-
"fixed",
|
|
136
|
-
"inset-0",
|
|
137
|
-
"min-h-[100dvh]",
|
|
138
|
-
"min-w-[100dvw]",
|
|
139
|
-
"w-full",
|
|
140
|
-
"h-full",
|
|
141
|
-
"z-overlay",
|
|
142
|
-
], {
|
|
152
|
+
const cvaModalBackdrop = cvaMerge(["flex", "justify-center", "inset-0", "w-full", "h-full", "z-overlay"], {
|
|
143
153
|
variants: {
|
|
154
|
+
contained: {
|
|
155
|
+
true: ["absolute"],
|
|
156
|
+
false: ["fixed", "min-h-[100dvh]", "min-w-[100dvw]"],
|
|
157
|
+
},
|
|
144
158
|
isFrontmost: {
|
|
145
159
|
// Frontmost modal shows backdrop
|
|
146
160
|
true: ["bg-black/50", "backdrop-saturate-150"],
|
|
@@ -159,118 +173,72 @@ const cvaModalBackdrop = cvaMerge([
|
|
|
159
173
|
},
|
|
160
174
|
});
|
|
161
175
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
176
|
+
/** Reducer tracking formfactor transitions (card/sheet) while the modal is open. */
|
|
177
|
+
const modalModeSwitchReducer = (state, action) => {
|
|
178
|
+
if (action.mode === state.prevMode && action.isOpen === state.prevIsOpen) {
|
|
179
|
+
return state;
|
|
165
180
|
}
|
|
166
|
-
return
|
|
167
|
-
|
|
181
|
+
return {
|
|
182
|
+
prevMode: action.mode,
|
|
183
|
+
prevIsOpen: action.isOpen,
|
|
184
|
+
isModeSwitching: action.mode !== state.prevMode && action.isOpen && state.prevIsOpen
|
|
185
|
+
? true
|
|
186
|
+
: !action.isOpen
|
|
187
|
+
? false
|
|
188
|
+
: state.isModeSwitching,
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
|
|
168
192
|
/**
|
|
169
193
|
* Observes the modal body within the given root element and toggles a top border on the footer
|
|
170
194
|
* when the body becomes vertically scrollable.
|
|
171
195
|
*
|
|
172
|
-
*
|
|
173
|
-
* - Locates elements via stable data-attributes (by default: [data-modal-body], [data-modal-footer]).
|
|
174
|
-
* - Recomputes on resize, DOM mutations within the body, scroll events, and image load/error events.
|
|
175
|
-
* - Cleans up all observers/listeners on unmount or dependency change.
|
|
176
|
-
*
|
|
177
|
-
* Edge cases:
|
|
178
|
-
* - If either body or footer is missing, the hook does nothing.
|
|
179
|
-
* - No DOM mutations occur when `enabled` is false.
|
|
196
|
+
* Thin wrapper around `useOverflowBorder` with modal-specific defaults.
|
|
180
197
|
*
|
|
181
198
|
* @param rootRef Root element that contains both the modal body and footer.
|
|
182
199
|
* @param options Optional configuration.
|
|
183
|
-
* @param options.footerClass CSS class(es) toggled on the footer when the body is scrollable.
|
|
200
|
+
* @param options.footerClass CSS class(es) toggled on the footer when the body is scrollable. Defaults to "border-t".
|
|
184
201
|
* @param options.enabled Whether the hook is active. Defaults to true.
|
|
185
202
|
* @param options.bodySelector CSS selector to locate the body element. Defaults to "[data-modal-body]".
|
|
186
203
|
* @param options.footerSelector CSS selector to locate the footer element. Defaults to "[data-modal-footer]".
|
|
187
204
|
*/
|
|
188
205
|
const useModalFooterBorder = (rootRef, { footerClass = "border-t", enabled = true, bodySelector = "[data-modal-body]", footerSelector = "[data-modal-footer]", } = {}) => {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
? footerClass.filter(Boolean)
|
|
196
|
-
: String(footerClass).split(/\s+/).filter(Boolean);
|
|
197
|
-
let attempts = 0;
|
|
198
|
-
const MAX_ATTEMPTS = 20;
|
|
199
|
-
const tryInit = () => {
|
|
200
|
-
if (cancelled)
|
|
201
|
-
return;
|
|
202
|
-
const root = resolveRootEl(rootRef);
|
|
203
|
-
if (!root) {
|
|
204
|
-
if (attempts++ < MAX_ATTEMPTS)
|
|
205
|
-
requestAnimationFrame(tryInit);
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
const bodyEl = root.querySelector(bodySelector);
|
|
209
|
-
const footerEl = root.querySelector(footerSelector);
|
|
210
|
-
if (!bodyEl || !footerEl) {
|
|
211
|
-
if (attempts++ < MAX_ATTEMPTS)
|
|
212
|
-
requestAnimationFrame(tryInit);
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
const update = () => {
|
|
216
|
-
const hasScrollbar = bodyEl.scrollHeight > bodyEl.clientHeight;
|
|
217
|
-
classes.forEach(cls => footerEl.classList.toggle(cls, hasScrollbar));
|
|
218
|
-
};
|
|
219
|
-
update();
|
|
220
|
-
const ro = new ResizeObserver(update);
|
|
221
|
-
ro.observe(bodyEl);
|
|
222
|
-
const attachImgListeners = (node) => {
|
|
223
|
-
const imgs = node.querySelectorAll("img"); // NodeListOf<HTMLImageElement>
|
|
224
|
-
imgs.forEach(img => {
|
|
225
|
-
if (!img.complete) {
|
|
226
|
-
const once = () => update();
|
|
227
|
-
img.addEventListener("load", once, { once: true });
|
|
228
|
-
img.addEventListener("error", once, { once: true });
|
|
229
|
-
}
|
|
230
|
-
});
|
|
231
|
-
};
|
|
232
|
-
attachImgListeners(bodyEl);
|
|
233
|
-
const mo = new MutationObserver(muts => {
|
|
234
|
-
update();
|
|
235
|
-
for (const m of muts) {
|
|
236
|
-
m.addedNodes.forEach(n => {
|
|
237
|
-
if (n instanceof HTMLImageElement) {
|
|
238
|
-
if (!n.complete) {
|
|
239
|
-
const once = () => update();
|
|
240
|
-
n.addEventListener("load", once, { once: true });
|
|
241
|
-
n.addEventListener("error", once, { once: true });
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
else if (n instanceof HTMLElement) {
|
|
245
|
-
attachImgListeners(n);
|
|
246
|
-
}
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
mo.observe(bodyEl, { childList: true, subtree: true, characterData: true });
|
|
251
|
-
bodyEl.addEventListener("scroll", update, { passive: true });
|
|
252
|
-
cleanup = () => {
|
|
253
|
-
ro.disconnect();
|
|
254
|
-
mo.disconnect();
|
|
255
|
-
bodyEl.removeEventListener("scroll", update);
|
|
256
|
-
classes.forEach(cls => footerEl.classList.remove(cls));
|
|
257
|
-
};
|
|
258
|
-
};
|
|
259
|
-
requestAnimationFrame(tryInit);
|
|
260
|
-
return () => {
|
|
261
|
-
cancelled = true;
|
|
262
|
-
cleanup?.();
|
|
263
|
-
};
|
|
264
|
-
}, [enabled, footerClass, bodySelector, footerSelector, rootRef]);
|
|
206
|
+
useOverflowBorder(rootRef, {
|
|
207
|
+
scrollAreaSelector: bodySelector,
|
|
208
|
+
targetSelector: footerSelector,
|
|
209
|
+
toggleClass: footerClass,
|
|
210
|
+
enabled,
|
|
211
|
+
});
|
|
265
212
|
};
|
|
266
213
|
|
|
267
214
|
/** Scale factor per modal depth level (5% smaller per level back) */
|
|
268
215
|
const SCALE_FACTOR_PER_LEVEL = 0.05;
|
|
216
|
+
/** Maximum number of stacked modals visible behind the frontmost one. */
|
|
217
|
+
const MAX_VISIBLE_STACK_BEHIND = 2;
|
|
218
|
+
/**
|
|
219
|
+
* Inline styles for the Sheet container element in sheet mode.
|
|
220
|
+
* pointer-events: none lets interactions pass through when Sheet returns null
|
|
221
|
+
* (shouldRender=false). Sheet's inner panel has pointer-events-auto so it
|
|
222
|
+
* remains interactive when open.
|
|
223
|
+
*/
|
|
224
|
+
const SHEET_CONTAINER_STYLE = {
|
|
225
|
+
bottom: 0,
|
|
226
|
+
containerType: "size",
|
|
227
|
+
left: 0,
|
|
228
|
+
pointerEvents: "none",
|
|
229
|
+
position: "fixed",
|
|
230
|
+
right: 0,
|
|
231
|
+
top: 0,
|
|
232
|
+
zIndex: "var(--z-overlay)",
|
|
233
|
+
};
|
|
269
234
|
/**
|
|
270
235
|
* Modal presents critical information or requests user input in an overlay dialog that interrupts the current workflow.
|
|
271
236
|
* It renders inside a Portal with a backdrop overlay, focus trapping, and proper accessibility roles.
|
|
272
237
|
* Modals must always be used together with the `useModal` hook, which manages open/close state and Floating UI integration.
|
|
273
238
|
*
|
|
239
|
+
* When the container width is below the "sm" breakpoint (480px), the Modal
|
|
240
|
+
* automatically renders as a bottom Sheet with gesture support.
|
|
241
|
+
*
|
|
274
242
|
* Compose the modal body with `ModalHeader`, `ModalBody`, and `ModalFooter` for consistent structure.
|
|
275
243
|
*
|
|
276
244
|
* ### When to use
|
|
@@ -301,21 +269,99 @@ const SCALE_FACTOR_PER_LEVEL = 0.05;
|
|
|
301
269
|
* @param {ModalProps} props - The props for the Modal component
|
|
302
270
|
* @returns {ReactElement} Modal component
|
|
303
271
|
*/
|
|
304
|
-
const Modal = ({ children, isOpen, role = "dialog", "data-testid": dataTestId, className, size, floatingUi, ref, restoreFocus = true,
|
|
305
|
-
|
|
306
|
-
// is viewport-centered via CSS, not positioned relative to a reference element.
|
|
307
|
-
// See: https://floating-ui.com/docs/dialog
|
|
272
|
+
const Modal = ({ children, container, dismiss, isOpen, mode, requestClose, role = "dialog", "data-testid": dataTestId, className, size, stack, floatingUi, ref, sheetDefaultSize = "fit", restoreFocus = true, onCloseComplete, }) => {
|
|
273
|
+
const { depthFromFront, stackSizeAtOpen } = stack;
|
|
308
274
|
const { rootElement, refs, context, getFloatingProps } = floatingUi;
|
|
309
275
|
const cardRef = useRef(null);
|
|
310
|
-
// Merge the custom ref from useModal with FloatingUI's setFloating ref
|
|
311
276
|
const mergedRef = useMergeRefs([refs.setFloating, ref]);
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
277
|
+
const sheetRef = useRef(null);
|
|
278
|
+
const sheetRefCallback = useCallback((el) => {
|
|
279
|
+
sheetRef.current = el;
|
|
280
|
+
}, []);
|
|
281
|
+
const sheetPanelRef = useMergeRefs([refs.setFloating, sheetRefCallback]);
|
|
282
|
+
const handleSheetCloseComplete = useCallback(() => {
|
|
283
|
+
onCloseComplete();
|
|
284
|
+
}, [onCloseComplete]);
|
|
285
|
+
// Container element for the Sheet when no external container is provided.
|
|
286
|
+
// Uses useState as a callback ref so mounting triggers a re-render.
|
|
287
|
+
// The div is kept alive for the full duration mode is "sheet" so that
|
|
288
|
+
// the Sheet's close animation can complete inside it.
|
|
289
|
+
const [sheetContainer, setSheetContainer] = useState(null);
|
|
290
|
+
useModalFooterBorder(mode === "sheet" ? sheetRef : cardRef, { enabled: isOpen, footerClass: "border-t pt-4" });
|
|
291
|
+
const isVisibleInStack = depthFromFront <= MAX_VISIBLE_STACK_BEHIND;
|
|
292
|
+
// Detect formfactor transitions (card ↔ sheet) while the modal is already open.
|
|
293
|
+
const [modeState, dispatchMode] = useReducer(modalModeSwitchReducer, {
|
|
294
|
+
prevMode: mode,
|
|
295
|
+
prevIsOpen: isOpen,
|
|
296
|
+
isModeSwitching: false,
|
|
297
|
+
});
|
|
298
|
+
if (mode !== modeState.prevMode || isOpen !== modeState.prevIsOpen) {
|
|
299
|
+
dispatchMode({ type: "SYNC", mode, isOpen });
|
|
300
|
+
}
|
|
301
|
+
const { state: sheetState, snap: sheetSnap, onGeometryChange: sheetOnGeometryChange, onSnap: sheetOnSnap, onClickHandle: sheetOnClickHandle, } = useSheetSnap({ defaultSize: sheetDefaultSize, isOpen });
|
|
302
|
+
const onCloseGesture = useMemo(() => (dismiss.gesture ? () => requestClose(undefined, "gesture") : undefined), [dismiss.gesture, requestClose]);
|
|
303
|
+
// In card mode, there is no close animation so notify useModal immediately
|
|
304
|
+
// so it can call closeModal() without delay.
|
|
305
|
+
// In sheet mode, Sheet's onCloseComplete handles this instead.
|
|
306
|
+
useWatch({
|
|
307
|
+
value: isOpen,
|
|
308
|
+
onChange: (current, prev) => {
|
|
309
|
+
if (!current && prev && mode === "card") {
|
|
310
|
+
onCloseComplete();
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
const stackScale = 1 - Math.min(depthFromFront, MAX_VISIBLE_STACK_BEHIND) * SCALE_FACTOR_PER_LEVEL;
|
|
315
|
+
const sheetContainerStyle = useMemo(() => {
|
|
316
|
+
const style = {
|
|
317
|
+
...SHEET_CONTAINER_STYLE,
|
|
318
|
+
...(container !== undefined ? { position: "absolute" } : {}),
|
|
319
|
+
"--sheet-stack-scale": `${stackScale}`,
|
|
320
|
+
};
|
|
321
|
+
return style;
|
|
322
|
+
}, [container, stackScale]);
|
|
323
|
+
const isContained = container?.current !== null && container?.current !== undefined;
|
|
324
|
+
const cardModeInner = useMemo(() => {
|
|
325
|
+
if (mode !== "card" || !isOpen || !isVisibleInStack) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
return (jsx(FloatingFocusManager, { context: context, restoreFocus: restoreFocus, children: jsx("div", { "aria-modal": true, className: cvaModalContainer(), ref: mergedRef, role: role, style: depthFromFront === 0 ? undefined : { transform: `scale(${1 - depthFromFront * SCALE_FACTOR_PER_LEVEL})` }, ...getFloatingProps(), children: jsx(Card, { className: cvaModalCard({
|
|
329
|
+
className,
|
|
330
|
+
animation: modeState.isModeSwitching ? "none" : stackSizeAtOpen === 0 ? "fade" : "rise",
|
|
331
|
+
}), "data-modal-content": true, "data-testid": dataTestId, ref: cardRef, style: getModalCardCSSVariables(size, { useContainerWidthUnits: container !== undefined }), children: children }) }) }));
|
|
332
|
+
}, [
|
|
333
|
+
mode,
|
|
334
|
+
isOpen,
|
|
335
|
+
isVisibleInStack,
|
|
336
|
+
context,
|
|
337
|
+
restoreFocus,
|
|
338
|
+
mergedRef,
|
|
339
|
+
role,
|
|
340
|
+
depthFromFront,
|
|
341
|
+
getFloatingProps,
|
|
342
|
+
className,
|
|
343
|
+
modeState.isModeSwitching,
|
|
344
|
+
stackSizeAtOpen,
|
|
345
|
+
dataTestId,
|
|
346
|
+
size,
|
|
347
|
+
container,
|
|
348
|
+
children,
|
|
349
|
+
]);
|
|
350
|
+
if (mode === "sheet") {
|
|
351
|
+
return (jsxs(Portal, { root: rootElement, children: [isContained ? (jsx(Portal, { root: container.current, children: jsx("div", { ref: setSheetContainer, style: sheetContainerStyle }) })) : container === undefined ? (jsx("div", { ref: setSheetContainer, style: sheetContainerStyle })) : null, jsx(Sheet, { container: sheetContainer, "data-testid": dataTestId, entryAnimation: modeState.isModeSwitching ? "never" : "always", isOpen: isOpen, onClickHandle: sheetOnClickHandle, onCloseComplete: handleSheetCloseComplete, onCloseGesture: onCloseGesture, onGeometryChange: sheetOnGeometryChange, onSnap: sheetOnSnap, ref: sheetPanelRef, snap: sheetSnap, snapping: true, state: sheetState, trapFocus: false, variant: depthFromFront === 0 ? "modal" : "default", children: jsx(FloatingFocusManager, { context: context, restoreFocus: restoreFocus, children: jsx("div", { "aria-modal": true, className: cvaModalContentEnvironment({ className: "flex-grow" }), "data-modal-content": true, ref: ref, role: role, ...getFloatingProps(), children: children }) }) })] }));
|
|
352
|
+
}
|
|
353
|
+
if (cardModeInner === null) {
|
|
354
|
+
return jsx(Portal, { root: rootElement, children: null });
|
|
355
|
+
}
|
|
356
|
+
if (isContained) {
|
|
357
|
+
return (jsx(Portal, { root: rootElement, children: jsx(Portal, { root: container.current, children: jsx("div", { className: cvaModalBackdrop({
|
|
358
|
+
contained: true,
|
|
359
|
+
isFrontmost: depthFromFront === 0,
|
|
360
|
+
shouldAnimate: stackSizeAtOpen === 0,
|
|
361
|
+
}), children: cardModeInner }) }) }));
|
|
362
|
+
}
|
|
363
|
+
return (jsx(Portal, { root: rootElement, children: jsx(FloatingOverlay, { className: cvaModalBackdrop({ isFrontmost: depthFromFront === 0, shouldAnimate: stackSizeAtOpen === 0 }), lockScroll: true, children: cardModeInner }) }));
|
|
317
364
|
};
|
|
318
|
-
Modal.displayName = "Modal";
|
|
319
365
|
|
|
320
366
|
const cvaModalBodyContainer = cvaMerge([
|
|
321
367
|
"flex",
|
|
@@ -372,6 +418,7 @@ const ModalBody = forwardRef(({ children, id, "data-testid": dataTestId, classNa
|
|
|
372
418
|
|
|
373
419
|
const cvaModalFooterContainer = cvaMerge([
|
|
374
420
|
"flex",
|
|
421
|
+
"flex-none",
|
|
375
422
|
// flex-wrap
|
|
376
423
|
"justify-end",
|
|
377
424
|
"flex-row",
|
|
@@ -595,11 +642,6 @@ const ModalHeader = forwardRef(({ heading, subHeading, onClickClose, "data-testi
|
|
|
595
642
|
* - `stackSize`: Total modals open (local + host + iframe)
|
|
596
643
|
* - `stackSizeAtOpen`: Stack size when this modal opened (for animation decisions)
|
|
597
644
|
*
|
|
598
|
-
* This enables the Modal component to:
|
|
599
|
-
* - Scale down non-frontmost modals (visual stacking effect)
|
|
600
|
-
* - Show backdrop only on the frontmost modal
|
|
601
|
-
* - Choose appropriate animations based on whether it's opening over another modal
|
|
602
|
-
*
|
|
603
645
|
* @module modalStackRegistry
|
|
604
646
|
*/
|
|
605
647
|
let modalStack = [];
|
|
@@ -756,7 +798,11 @@ const useModalStack = (isOpen) => {
|
|
|
756
798
|
const depthFromFront = localDepthFromFront + hostModalCount;
|
|
757
799
|
// Get the total stack size when this modal opened (stable once set)
|
|
758
800
|
const stackSizeAtOpen = modalOpenState.get(modalId) ?? 0;
|
|
759
|
-
return useMemo(() => ({
|
|
801
|
+
return useMemo(() => ({
|
|
802
|
+
depthFromFront,
|
|
803
|
+
stackSize: totalStackSize,
|
|
804
|
+
stackSizeAtOpen,
|
|
805
|
+
}), [depthFromFront, totalStackSize, stackSizeAtOpen]);
|
|
760
806
|
};
|
|
761
807
|
|
|
762
808
|
/**
|
|
@@ -767,9 +813,18 @@ const useModalStack = (isOpen) => {
|
|
|
767
813
|
* - <Modal {...modal} size="medium" />
|
|
768
814
|
*/
|
|
769
815
|
const useModal = (props) => {
|
|
770
|
-
const { isOpen: controlledIsOpen, defaultOpen, rootElement, dismiss: dismissOptions, onClose, onOpen, onOpenChange, onBeforeClose, ref: customRef, role, } = props ?? {};
|
|
816
|
+
const { isOpen: controlledIsOpen, defaultOpen, rootElement, dismiss: dismissOptions, onClose, onOpen, onOpenChange, onBeforeClose, ref: customRef, role, container, } = props ?? {};
|
|
817
|
+
const nullRef = useRef(null);
|
|
818
|
+
const containerBreakpoints = useContainerBreakpoints(container ?? nullRef, { skip: !container });
|
|
819
|
+
const viewportBreakpoints = useViewportBreakpoints({ skip: !!container });
|
|
820
|
+
const viewportCanDetect = viewportBreakpoints.isXs;
|
|
821
|
+
const mode = (container ? containerBreakpoints.isSm : !viewportCanDetect || viewportBreakpoints.isSm)
|
|
822
|
+
? "card"
|
|
823
|
+
: "sheet";
|
|
771
824
|
const [internalIsOpen, setIsOpen] = useState(defaultOpen ?? false);
|
|
772
|
-
const
|
|
825
|
+
const [openDeferred, setOpenDeferred] = useState(false);
|
|
826
|
+
const openIdRef = useRef(0);
|
|
827
|
+
const isOpen = typeof controlledIsOpen === "boolean" ? controlledIsOpen && !openDeferred : internalIsOpen;
|
|
773
828
|
const ref = useRef(null);
|
|
774
829
|
// Prevents multiple confirmation dialogs from stacking when user rapidly triggers dismiss
|
|
775
830
|
const isPendingCloseRef = useRef(false);
|
|
@@ -792,39 +847,80 @@ const useModal = (props) => {
|
|
|
792
847
|
throw new Error("useModal must be used within the ModalDialogContextProvider");
|
|
793
848
|
}
|
|
794
849
|
const { openModal, closeModal } = modalDialogContext;
|
|
795
|
-
|
|
796
|
-
//
|
|
850
|
+
const errorHandling = useContext(ErrorHandlingContext);
|
|
851
|
+
// Tracks whether a close is in-flight waiting for the Sheet's close animation
|
|
852
|
+
// to complete before calling closeModal() to resize the iframe back.
|
|
853
|
+
const pendingCloseModalRef = useRef(false);
|
|
854
|
+
const { startTimeout: startCloseTimeout, stopTimeout: stopCloseTimeout } = useTimeout({
|
|
855
|
+
onTimeout: () => {
|
|
856
|
+
if (pendingCloseModalRef.current) {
|
|
857
|
+
pendingCloseModalRef.current = false;
|
|
858
|
+
void closeModal();
|
|
859
|
+
errorHandling?.captureException(new Error("Modal close animation safety timeout: transitionend callback never fired"), { level: "warning" });
|
|
860
|
+
}
|
|
861
|
+
},
|
|
862
|
+
duration: SHEET_TRANSITION_DURATION_MS + 100,
|
|
863
|
+
});
|
|
864
|
+
const startPendingCloseModal = useCallback(() => {
|
|
865
|
+
pendingCloseModalRef.current = true;
|
|
866
|
+
startCloseTimeout();
|
|
867
|
+
}, [startCloseTimeout]);
|
|
868
|
+
const onCloseComplete = useCallback(() => {
|
|
869
|
+
if (pendingCloseModalRef.current) {
|
|
870
|
+
pendingCloseModalRef.current = false;
|
|
871
|
+
stopCloseTimeout();
|
|
872
|
+
void closeModal();
|
|
873
|
+
}
|
|
874
|
+
}, [stopCloseTimeout, closeModal]);
|
|
875
|
+
// Sync controlled isOpen state with iframe size controller.
|
|
876
|
+
// Defers rendering until the iframe has finished resizing to fullscreen
|
|
877
|
+
// so the modal animation plays after the resize, not during it.
|
|
797
878
|
useWatch({
|
|
798
879
|
value: controlledIsOpen,
|
|
799
880
|
immediate: true,
|
|
800
881
|
skip: typeof controlledIsOpen !== "boolean",
|
|
801
882
|
onChange: (current, prev) => {
|
|
802
883
|
if (current && !prev) {
|
|
803
|
-
|
|
884
|
+
const id = ++openIdRef.current;
|
|
885
|
+
const result = openModal();
|
|
886
|
+
if (result instanceof Promise) {
|
|
887
|
+
setOpenDeferred(true);
|
|
888
|
+
void result.then(() => {
|
|
889
|
+
if (openIdRef.current === id) {
|
|
890
|
+
setOpenDeferred(false);
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
}
|
|
804
894
|
}
|
|
805
895
|
else if (!current && prev) {
|
|
806
|
-
|
|
896
|
+
openIdRef.current++;
|
|
897
|
+
setOpenDeferred(false);
|
|
898
|
+
startPendingCloseModal();
|
|
807
899
|
}
|
|
808
900
|
},
|
|
809
901
|
});
|
|
810
|
-
// Cleanup: notify context when component unmounts while modal is open
|
|
902
|
+
// Cleanup: notify context when component unmounts while modal is open.
|
|
903
|
+
// Calls closeModal() directly (no animation to wait for during unmount).
|
|
811
904
|
useEffect(() => {
|
|
812
905
|
return () => {
|
|
906
|
+
stopCloseTimeout();
|
|
907
|
+
pendingCloseModalRef.current = false;
|
|
813
908
|
if (isOpenRef.current) {
|
|
814
909
|
void closeModal();
|
|
815
910
|
}
|
|
816
911
|
};
|
|
817
|
-
}, [closeModal]);
|
|
912
|
+
}, [closeModal, stopCloseTimeout]);
|
|
818
913
|
const handleClose = useCallback((event, reason) => {
|
|
914
|
+
openIdRef.current++;
|
|
915
|
+
setOpenDeferred(false);
|
|
819
916
|
setIsOpen(false);
|
|
820
917
|
onCloseRef.current?.(event, reason);
|
|
821
918
|
onOpenChangeRef.current?.(false, event, reason);
|
|
822
|
-
|
|
823
|
-
}, [
|
|
919
|
+
startPendingCloseModal();
|
|
920
|
+
}, [startPendingCloseModal]);
|
|
824
921
|
const { refs, context } = useFloating({
|
|
825
922
|
open: isOpen,
|
|
826
923
|
onOpenChange: (newIsOpen, event, reason) => {
|
|
827
|
-
// Opening - just update state (same as original)
|
|
828
924
|
if (newIsOpen) {
|
|
829
925
|
setIsOpen(true);
|
|
830
926
|
return;
|
|
@@ -854,30 +950,33 @@ const useModal = (props) => {
|
|
|
854
950
|
whileElementsMounted: autoUpdate,
|
|
855
951
|
middleware: [shift()],
|
|
856
952
|
});
|
|
857
|
-
const
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
953
|
+
const resolvedDismiss = useMemo(() => ({
|
|
954
|
+
escapeKey: dismissOptions?.escapeKey ?? true,
|
|
955
|
+
outsidePress: dismissOptions?.outsidePress ?? true,
|
|
956
|
+
gesture: dismissOptions?.gesture ?? true,
|
|
957
|
+
}), [dismissOptions]);
|
|
958
|
+
const stack = useModalStack(isOpen);
|
|
959
|
+
const dismissInteraction = useDismiss(context, { ...dismissOptions, enabled: stack.depthFromFront === 0 });
|
|
960
|
+
const { getFloatingProps } = useInteractions([dismissInteraction]);
|
|
961
|
+
const open = useCallback(async () => {
|
|
962
|
+
const id = ++openIdRef.current;
|
|
861
963
|
onOpenRef.current?.();
|
|
862
964
|
onOpenChangeRef.current?.(true);
|
|
863
|
-
|
|
864
|
-
|
|
965
|
+
await openModal();
|
|
966
|
+
if (openIdRef.current === id) {
|
|
967
|
+
setIsOpen(true);
|
|
968
|
+
}
|
|
865
969
|
}, [openModal]);
|
|
866
|
-
const
|
|
867
|
-
// If onBeforeClose is provided, check it before closing
|
|
970
|
+
const requestClose = useCallback((event, reason) => {
|
|
868
971
|
if (onBeforeCloseRef.current) {
|
|
869
|
-
// Already handling a close attempt, ignore this one
|
|
870
972
|
if (isPendingCloseRef.current) {
|
|
871
973
|
return;
|
|
872
974
|
}
|
|
873
975
|
isPendingCloseRef.current = true;
|
|
874
|
-
void Promise.resolve(onBeforeCloseRef.current(
|
|
976
|
+
void Promise.resolve(onBeforeCloseRef.current(event, reason))
|
|
875
977
|
.then(shouldClose => {
|
|
876
978
|
if (shouldClose) {
|
|
877
|
-
|
|
878
|
-
onOpenChangeRef.current?.(false, undefined, "programmatic");
|
|
879
|
-
setIsOpen(false);
|
|
880
|
-
void closeModal();
|
|
979
|
+
handleClose(event, reason);
|
|
881
980
|
}
|
|
882
981
|
})
|
|
883
982
|
.finally(() => {
|
|
@@ -885,17 +984,15 @@ const useModal = (props) => {
|
|
|
885
984
|
});
|
|
886
985
|
return;
|
|
887
986
|
}
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
void closeModal();
|
|
892
|
-
}, [closeModal]);
|
|
987
|
+
handleClose(event, reason);
|
|
988
|
+
}, [handleClose]);
|
|
989
|
+
const close = useCallback(() => requestClose(undefined, "programmatic"), [requestClose]);
|
|
893
990
|
const toggle = useCallback(() => {
|
|
894
991
|
if (isOpen) {
|
|
895
992
|
close();
|
|
896
993
|
}
|
|
897
994
|
else {
|
|
898
|
-
open();
|
|
995
|
+
void open();
|
|
899
996
|
}
|
|
900
997
|
}, [isOpen, open, close]);
|
|
901
998
|
return useMemo(() => ({
|
|
@@ -903,6 +1000,7 @@ const useModal = (props) => {
|
|
|
903
1000
|
toggle,
|
|
904
1001
|
open,
|
|
905
1002
|
close,
|
|
1003
|
+
requestClose,
|
|
906
1004
|
ref: customRef ?? ref,
|
|
907
1005
|
floatingUi: {
|
|
908
1006
|
refs,
|
|
@@ -911,21 +1009,28 @@ const useModal = (props) => {
|
|
|
911
1009
|
getFloatingProps,
|
|
912
1010
|
},
|
|
913
1011
|
role,
|
|
914
|
-
|
|
915
|
-
|
|
1012
|
+
dismiss: resolvedDismiss,
|
|
1013
|
+
onCloseComplete,
|
|
1014
|
+
stack,
|
|
1015
|
+
mode,
|
|
1016
|
+
container,
|
|
916
1017
|
}), [
|
|
917
1018
|
isOpen,
|
|
918
1019
|
toggle,
|
|
919
1020
|
open,
|
|
920
1021
|
close,
|
|
1022
|
+
requestClose,
|
|
921
1023
|
customRef,
|
|
922
1024
|
refs,
|
|
923
1025
|
rootElement,
|
|
924
1026
|
context,
|
|
925
1027
|
getFloatingProps,
|
|
926
1028
|
role,
|
|
927
|
-
|
|
928
|
-
|
|
1029
|
+
resolvedDismiss,
|
|
1030
|
+
onCloseComplete,
|
|
1031
|
+
stack,
|
|
1032
|
+
mode,
|
|
1033
|
+
container,
|
|
929
1034
|
]);
|
|
930
1035
|
};
|
|
931
1036
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trackunit/react-modal",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.7",
|
|
4
4
|
"repository": "https://github.com/Trackunit/manager",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
6
|
"engines": {
|
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
10
|
"@floating-ui/react": "^0.26.25",
|
|
11
|
-
"@trackunit/react-components": "1.21.
|
|
11
|
+
"@trackunit/react-components": "1.21.18",
|
|
12
12
|
"@trackunit/css-class-variance-utilities": "1.11.97",
|
|
13
13
|
"@trackunit/shared-utils": "1.13.97",
|
|
14
14
|
"@floating-ui/react-dom": "2.1.2",
|
|
15
|
-
"@trackunit/react-core-contexts-api": "1.15.
|
|
16
|
-
"@trackunit/i18n-library-translation": "1.18.
|
|
15
|
+
"@trackunit/react-core-contexts-api": "1.15.11",
|
|
16
|
+
"@trackunit/i18n-library-translation": "1.18.3"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
19
|
"@tanstack/react-router": "^1.114.29",
|