@trackunit/react-modal 1.8.66 → 1.8.71
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 +172 -32
- package/index.esm.js +173 -36
- package/package.json +7 -7
- package/src/modal/ModalBody/ModalBody.d.ts +4 -0
- package/src/modal/ModalFooter/ModalFooter.d.ts +44 -5
- package/src/modal/ModalHeader/ModalHeader.d.ts +14 -1
- package/src/modal/ModalHeader/ModalHeader.stories.d.ts +1 -0
- package/src/modal/ModalHeader/ModalHeader.variants.d.ts +1 -0
- package/src/modal/index.d.ts +3 -0
- package/src/modal/useModalFooterBorder.d.ts +5 -3
package/index.cjs.js
CHANGED
|
@@ -104,6 +104,12 @@ const ModalBackdrop = ({ children, onClick }) => {
|
|
|
104
104
|
}, ref: ref, children: children }));
|
|
105
105
|
};
|
|
106
106
|
|
|
107
|
+
function resolveRootEl(refLike) {
|
|
108
|
+
if (refLike && typeof refLike === "object" && "current" in refLike) {
|
|
109
|
+
return refLike.current;
|
|
110
|
+
}
|
|
111
|
+
return refLike;
|
|
112
|
+
}
|
|
107
113
|
/**
|
|
108
114
|
* Observes the modal body within the given root element and toggles a top border on the footer
|
|
109
115
|
* when the body becomes vertically scrollable.
|
|
@@ -119,47 +125,88 @@ const ModalBackdrop = ({ children, onClick }) => {
|
|
|
119
125
|
*
|
|
120
126
|
* @param rootRef Root element that contains both the modal body and footer.
|
|
121
127
|
* @param options Optional configuration.
|
|
122
|
-
* @param options.
|
|
128
|
+
* @param options.footerClass CSS class(es) toggled on the footer when the body is scrollable. Accepts string or string[]. Defaults to "border-t".
|
|
123
129
|
* @param options.enabled Whether the hook is active. Defaults to true.
|
|
124
130
|
* @param options.bodySelector CSS selector to locate the body element. Defaults to "[data-modal-body]".
|
|
125
131
|
* @param options.footerSelector CSS selector to locate the footer element. Defaults to "[data-modal-footer]".
|
|
126
132
|
*/
|
|
127
|
-
const useModalFooterBorder = (rootRef, {
|
|
133
|
+
const useModalFooterBorder = (rootRef, { footerClass = "border-t", enabled = true, bodySelector = "[data-modal-body]", footerSelector = "[data-modal-footer]", } = {}) => {
|
|
128
134
|
react.useLayoutEffect(() => {
|
|
129
135
|
if (!enabled)
|
|
130
136
|
return;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
img.addEventListener("error", once, { once: true });
|
|
137
|
+
let cleanup;
|
|
138
|
+
let cancelled = false;
|
|
139
|
+
const classes = Array.isArray(footerClass)
|
|
140
|
+
? footerClass.filter(Boolean)
|
|
141
|
+
: String(footerClass).split(/\s+/).filter(Boolean);
|
|
142
|
+
let attempts = 0;
|
|
143
|
+
const MAX_ATTEMPTS = 20;
|
|
144
|
+
const tryInit = () => {
|
|
145
|
+
if (cancelled)
|
|
146
|
+
return;
|
|
147
|
+
const root = resolveRootEl(rootRef);
|
|
148
|
+
if (!root) {
|
|
149
|
+
if (attempts++ < MAX_ATTEMPTS)
|
|
150
|
+
requestAnimationFrame(tryInit);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const bodyEl = root.querySelector(bodySelector);
|
|
154
|
+
const footerEl = root.querySelector(footerSelector);
|
|
155
|
+
if (!bodyEl || !footerEl) {
|
|
156
|
+
if (attempts++ < MAX_ATTEMPTS)
|
|
157
|
+
requestAnimationFrame(tryInit);
|
|
158
|
+
return;
|
|
154
159
|
}
|
|
155
|
-
|
|
160
|
+
const update = () => {
|
|
161
|
+
const hasScrollbar = bodyEl.scrollHeight > bodyEl.clientHeight;
|
|
162
|
+
classes.forEach(cls => footerEl.classList.toggle(cls, hasScrollbar));
|
|
163
|
+
};
|
|
164
|
+
update();
|
|
165
|
+
const ro = new ResizeObserver(update);
|
|
166
|
+
ro.observe(bodyEl);
|
|
167
|
+
const attachImgListeners = (node) => {
|
|
168
|
+
const imgs = node.querySelectorAll("img"); // NodeListOf<HTMLImageElement>
|
|
169
|
+
imgs.forEach(img => {
|
|
170
|
+
if (!img.complete) {
|
|
171
|
+
const once = () => update();
|
|
172
|
+
img.addEventListener("load", once, { once: true });
|
|
173
|
+
img.addEventListener("error", once, { once: true });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
attachImgListeners(bodyEl);
|
|
178
|
+
const mo = new MutationObserver(muts => {
|
|
179
|
+
update();
|
|
180
|
+
for (const m of muts) {
|
|
181
|
+
m.addedNodes.forEach(n => {
|
|
182
|
+
if (n instanceof HTMLImageElement) {
|
|
183
|
+
if (!n.complete) {
|
|
184
|
+
const once = () => update();
|
|
185
|
+
n.addEventListener("load", once, { once: true });
|
|
186
|
+
n.addEventListener("error", once, { once: true });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
else if (n instanceof HTMLElement) {
|
|
190
|
+
attachImgListeners(n);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
mo.observe(bodyEl, { childList: true, subtree: true, characterData: true });
|
|
196
|
+
bodyEl.addEventListener("scroll", update, { passive: true });
|
|
197
|
+
cleanup = () => {
|
|
198
|
+
ro.disconnect();
|
|
199
|
+
mo.disconnect();
|
|
200
|
+
bodyEl.removeEventListener("scroll", update);
|
|
201
|
+
classes.forEach(cls => footerEl.classList.remove(cls));
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
requestAnimationFrame(tryInit);
|
|
156
205
|
return () => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
bodyEl.removeEventListener("scroll", update);
|
|
160
|
-
footerEl.classList.remove(borderClass);
|
|
206
|
+
cancelled = true;
|
|
207
|
+
cleanup?.();
|
|
161
208
|
};
|
|
162
|
-
}, [
|
|
209
|
+
}, [enabled, footerClass, bodySelector, footerSelector, rootRef]);
|
|
163
210
|
};
|
|
164
211
|
|
|
165
212
|
/**
|
|
@@ -192,11 +239,101 @@ const useModalFooterBorder = (rootRef, { borderClass = "border-t", enabled = tru
|
|
|
192
239
|
const Modal = ({ children, isOpen, role = "dialog", dataTestId, className, containerClassName, onBackdropClick, floatingUi, ref, }) => {
|
|
193
240
|
const { rootElement, refs, floatingStyles, context, getFloatingProps } = floatingUi;
|
|
194
241
|
const cardRef = react.useRef(null);
|
|
195
|
-
useModalFooterBorder(cardRef, { enabled: isOpen,
|
|
242
|
+
useModalFooterBorder(cardRef, { enabled: isOpen, footerClass: "border-t pt-4" });
|
|
196
243
|
return (jsxRuntime.jsx(reactComponents.Portal, { root: rootElement, children: isOpen ? (jsxRuntime.jsx(react$1.FloatingFocusManager, { context: context, children: jsxRuntime.jsx("div", { ref: refs.setFloating, style: { ...floatingStyles, zIndex: uiDesignTokens.zIndex.overlay }, ...getFloatingProps(), children: jsxRuntime.jsx(ModalBackdrop, { onClick: onBackdropClick, children: jsxRuntime.jsx("div", { "aria-modal": true, className: cvaModalContainer({ className: containerClassName }), ref: ref, role: role, children: jsxRuntime.jsx(reactComponents.Card, { className: cvaModalCard({ className }), dataTestId: dataTestId, ref: cardRef, children: children }) }) }) }) })) : null }));
|
|
197
244
|
};
|
|
198
245
|
Modal.displayName = "Modal";
|
|
199
246
|
|
|
247
|
+
const cvaModalBodyContainer = cssClassVarianceUtilities.cvaMerge([
|
|
248
|
+
"flex",
|
|
249
|
+
"flex-grow",
|
|
250
|
+
"min-h-0",
|
|
251
|
+
"overflow-auto",
|
|
252
|
+
"bg-neutral-50",
|
|
253
|
+
"flex-col",
|
|
254
|
+
"component-card-spacing",
|
|
255
|
+
"@sm:component-card-spacing-sm",
|
|
256
|
+
"@md:component-card-spacing-md",
|
|
257
|
+
]);
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Modal body container.
|
|
261
|
+
*
|
|
262
|
+
* Renders children inside a scrollable flex container.
|
|
263
|
+
*
|
|
264
|
+
* @param {ModalBodyProps} props Component props.
|
|
265
|
+
* @param {ReactNode | null} props.children Content to render inside the modal body.
|
|
266
|
+
* @param {string} [props.dataTestId] Optional test id for the container.
|
|
267
|
+
* @returns {ReactElement} Modal body wrapper element.
|
|
268
|
+
*/
|
|
269
|
+
const ModalBody = react.forwardRef(({ children, id, dataTestId, className }, ref) => {
|
|
270
|
+
return (jsxRuntime.jsx("div", { className: cvaModalBodyContainer({ className }), "data-modal-body": true, "data-testid": dataTestId, id: id, children: children }));
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const cvaModalFooterContainer = cssClassVarianceUtilities.cvaMerge([
|
|
274
|
+
"flex",
|
|
275
|
+
// flex-wrap
|
|
276
|
+
"justify-end",
|
|
277
|
+
"flex-row",
|
|
278
|
+
"px-4",
|
|
279
|
+
"pb-4",
|
|
280
|
+
"gap-3",
|
|
281
|
+
"bg-neutral-50",
|
|
282
|
+
"border-neutral-200",
|
|
283
|
+
"modal-footer",
|
|
284
|
+
]);
|
|
285
|
+
|
|
286
|
+
function isCustom(props) {
|
|
287
|
+
return props.children !== null && props.children !== undefined;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Modal footer with action buttons.
|
|
291
|
+
*
|
|
292
|
+
* @param props Component props.
|
|
293
|
+
* @param props.cancelLabel Text for the Cancel button.
|
|
294
|
+
* @param props.onCancel Optional handler invoked when the Cancel button is clicked (alias to onClose, if provided).
|
|
295
|
+
* @param props.secondaryLabel Text for the optional Secondary action button.
|
|
296
|
+
* @param props.secondaryAction Click handler for the optional Secondary action button.
|
|
297
|
+
* @param props.primaryLabel Text for the Primary action button.
|
|
298
|
+
* @param props.primaryAction Click handler for the Primary action button.
|
|
299
|
+
* @param props.primaryVariant Visual variant for the Primary button ("primary" | "primary-danger").
|
|
300
|
+
* @param props.primaryLoading Optional loading state for the Primary button.
|
|
301
|
+
* @returns {ReactElement} Modal footer container element.
|
|
302
|
+
*/
|
|
303
|
+
const ModalFooter = react.forwardRef((props, ref) => {
|
|
304
|
+
const { dataTestId, className } = props;
|
|
305
|
+
if (isCustom(props)) {
|
|
306
|
+
// Branch with children: everything else is not required
|
|
307
|
+
return (jsxRuntime.jsx("div", { className: cvaModalFooterContainer({ className }), "data-modal-footer": true, "data-testid": dataTestId, ref: ref, children: props.children }));
|
|
308
|
+
}
|
|
309
|
+
// Branch with built-in buttons
|
|
310
|
+
const { onCancel, cancelLabel, primaryLabel, primaryAction, primaryVariant = "primary", primaryLoading = false, primaryDisabled = false, primaryFormId, secondaryLabel, secondaryAction, secondaryVariant = "secondary", secondaryLoading = false, secondaryDisabled = false, primaryName, secondaryName, cancelName, } = props;
|
|
311
|
+
return (jsxRuntime.jsxs("div", { className: cvaModalFooterContainer({ className }), "data-modal-footer": true, "data-testid": dataTestId, children: [jsxRuntime.jsx(reactComponents.Button, { dataTestId: dataTestId ? `${dataTestId}-cancel` : undefined, disabled: secondaryLoading || primaryLoading, name: cancelName, onClick: onCancel, variant: "ghost-neutral", children: cancelLabel }), secondaryLabel && secondaryAction ? (jsxRuntime.jsx(reactComponents.Button, { dataTestId: dataTestId ? `${dataTestId}-secondary` : undefined, disabled: primaryLoading || secondaryDisabled, loading: secondaryLoading, name: secondaryName, onClick: secondaryAction, role: "button", variant: secondaryVariant, children: secondaryLabel })) : null, primaryLabel ? (jsxRuntime.jsx(reactComponents.Button, { dataTestId: dataTestId ? `${dataTestId}-primary` : undefined, disabled: secondaryLoading || primaryDisabled, form: primaryFormId, loading: primaryLoading, name: primaryName, onClick: primaryAction, role: "button", type: "submit", variant: primaryVariant, children: primaryLabel })) : null] }));
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const cvaContainer = cssClassVarianceUtilities.cvaMerge(["flex", "justify-between", "border-b", "border-neutral-200", "p-4", "flex-none"]);
|
|
315
|
+
const cvaHeadingContainer = cssClassVarianceUtilities.cvaMerge(["flex", "flex-col", "justify-center", "gap-1"]);
|
|
316
|
+
const cvaTitleContainer = cssClassVarianceUtilities.cvaMerge(["flex", "items-center", "gap-1"]);
|
|
317
|
+
const cvaIconContainer = cssClassVarianceUtilities.cvaMerge(["flex", "place-items-center"]);
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Modal header section.
|
|
321
|
+
* Displays a main heading, optional subheading, and a close button.
|
|
322
|
+
*
|
|
323
|
+
* @param {ModalHeaderProps} props Component props.
|
|
324
|
+
* @param {string} [props.heading] Main heading text.
|
|
325
|
+
* @param {string} [props.subHeading] Optional subheading content.
|
|
326
|
+
* @param {() => void} props.onClose Close button click handler.
|
|
327
|
+
* @param {string} [props.dataTestId] Optional test id for the container.
|
|
328
|
+
* @param {string} [props.className] Optional additional class name(s).
|
|
329
|
+
* @returns {ReactElement} The modal header element.
|
|
330
|
+
*/
|
|
331
|
+
const ModalHeader = react.forwardRef(({ heading, subHeading, onClose, dataTestId, className, id, children, accessories }, ref) => {
|
|
332
|
+
return (jsxRuntime.jsxs("div", { className: cvaContainer({
|
|
333
|
+
className,
|
|
334
|
+
}), "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", dataTestId: dataTestId ? `${dataTestId}-close-button` : "modal-close-button", icon: jsxRuntime.jsx(reactComponents.Icon, { name: "XMark", size: "small" }), onClick: onClose, size: "small", variant: "ghost-neutral" }) })] }));
|
|
335
|
+
});
|
|
336
|
+
|
|
200
337
|
/**
|
|
201
338
|
* A hook to handle the state and configuration of Modal components.
|
|
202
339
|
*
|
|
@@ -313,4 +450,7 @@ setupLibraryTranslations();
|
|
|
313
450
|
|
|
314
451
|
exports.Modal = Modal;
|
|
315
452
|
exports.ModalBackdrop = ModalBackdrop;
|
|
453
|
+
exports.ModalBody = ModalBody;
|
|
454
|
+
exports.ModalFooter = ModalFooter;
|
|
455
|
+
exports.ModalHeader = ModalHeader;
|
|
316
456
|
exports.useModal = useModal;
|
package/index.esm.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { jsx } from 'react/jsx-runtime';
|
|
1
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
2
2
|
import { registerTranslations } from '@trackunit/i18n-library-translation';
|
|
3
3
|
import { FloatingFocusManager, useFloating, autoUpdate, shift, useDismiss, useInteractions } from '@floating-ui/react';
|
|
4
|
-
import { Portal, Card } from '@trackunit/react-components';
|
|
4
|
+
import { Portal, Card, Button, Heading, Text, IconButton, Icon } from '@trackunit/react-components';
|
|
5
5
|
import { zIndex } from '@trackunit/ui-design-tokens';
|
|
6
|
-
import { useRef, useLayoutEffect, useState, useEffect, useCallback, useMemo } from 'react';
|
|
6
|
+
import { useRef, useLayoutEffect, forwardRef, useState, useEffect, useCallback, useMemo } from 'react';
|
|
7
7
|
import { cvaMerge } from '@trackunit/css-class-variance-utilities';
|
|
8
8
|
import { useModalDialogContext } from '@trackunit/react-core-hooks';
|
|
9
9
|
|
|
@@ -102,6 +102,12 @@ const ModalBackdrop = ({ children, onClick }) => {
|
|
|
102
102
|
}, ref: ref, children: children }));
|
|
103
103
|
};
|
|
104
104
|
|
|
105
|
+
function resolveRootEl(refLike) {
|
|
106
|
+
if (refLike && typeof refLike === "object" && "current" in refLike) {
|
|
107
|
+
return refLike.current;
|
|
108
|
+
}
|
|
109
|
+
return refLike;
|
|
110
|
+
}
|
|
105
111
|
/**
|
|
106
112
|
* Observes the modal body within the given root element and toggles a top border on the footer
|
|
107
113
|
* when the body becomes vertically scrollable.
|
|
@@ -117,47 +123,88 @@ const ModalBackdrop = ({ children, onClick }) => {
|
|
|
117
123
|
*
|
|
118
124
|
* @param rootRef Root element that contains both the modal body and footer.
|
|
119
125
|
* @param options Optional configuration.
|
|
120
|
-
* @param options.
|
|
126
|
+
* @param options.footerClass CSS class(es) toggled on the footer when the body is scrollable. Accepts string or string[]. Defaults to "border-t".
|
|
121
127
|
* @param options.enabled Whether the hook is active. Defaults to true.
|
|
122
128
|
* @param options.bodySelector CSS selector to locate the body element. Defaults to "[data-modal-body]".
|
|
123
129
|
* @param options.footerSelector CSS selector to locate the footer element. Defaults to "[data-modal-footer]".
|
|
124
130
|
*/
|
|
125
|
-
const useModalFooterBorder = (rootRef, {
|
|
131
|
+
const useModalFooterBorder = (rootRef, { footerClass = "border-t", enabled = true, bodySelector = "[data-modal-body]", footerSelector = "[data-modal-footer]", } = {}) => {
|
|
126
132
|
useLayoutEffect(() => {
|
|
127
133
|
if (!enabled)
|
|
128
134
|
return;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
img.addEventListener("error", once, { once: true });
|
|
135
|
+
let cleanup;
|
|
136
|
+
let cancelled = false;
|
|
137
|
+
const classes = Array.isArray(footerClass)
|
|
138
|
+
? footerClass.filter(Boolean)
|
|
139
|
+
: String(footerClass).split(/\s+/).filter(Boolean);
|
|
140
|
+
let attempts = 0;
|
|
141
|
+
const MAX_ATTEMPTS = 20;
|
|
142
|
+
const tryInit = () => {
|
|
143
|
+
if (cancelled)
|
|
144
|
+
return;
|
|
145
|
+
const root = resolveRootEl(rootRef);
|
|
146
|
+
if (!root) {
|
|
147
|
+
if (attempts++ < MAX_ATTEMPTS)
|
|
148
|
+
requestAnimationFrame(tryInit);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const bodyEl = root.querySelector(bodySelector);
|
|
152
|
+
const footerEl = root.querySelector(footerSelector);
|
|
153
|
+
if (!bodyEl || !footerEl) {
|
|
154
|
+
if (attempts++ < MAX_ATTEMPTS)
|
|
155
|
+
requestAnimationFrame(tryInit);
|
|
156
|
+
return;
|
|
152
157
|
}
|
|
153
|
-
|
|
158
|
+
const update = () => {
|
|
159
|
+
const hasScrollbar = bodyEl.scrollHeight > bodyEl.clientHeight;
|
|
160
|
+
classes.forEach(cls => footerEl.classList.toggle(cls, hasScrollbar));
|
|
161
|
+
};
|
|
162
|
+
update();
|
|
163
|
+
const ro = new ResizeObserver(update);
|
|
164
|
+
ro.observe(bodyEl);
|
|
165
|
+
const attachImgListeners = (node) => {
|
|
166
|
+
const imgs = node.querySelectorAll("img"); // NodeListOf<HTMLImageElement>
|
|
167
|
+
imgs.forEach(img => {
|
|
168
|
+
if (!img.complete) {
|
|
169
|
+
const once = () => update();
|
|
170
|
+
img.addEventListener("load", once, { once: true });
|
|
171
|
+
img.addEventListener("error", once, { once: true });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
attachImgListeners(bodyEl);
|
|
176
|
+
const mo = new MutationObserver(muts => {
|
|
177
|
+
update();
|
|
178
|
+
for (const m of muts) {
|
|
179
|
+
m.addedNodes.forEach(n => {
|
|
180
|
+
if (n instanceof HTMLImageElement) {
|
|
181
|
+
if (!n.complete) {
|
|
182
|
+
const once = () => update();
|
|
183
|
+
n.addEventListener("load", once, { once: true });
|
|
184
|
+
n.addEventListener("error", once, { once: true });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
else if (n instanceof HTMLElement) {
|
|
188
|
+
attachImgListeners(n);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
mo.observe(bodyEl, { childList: true, subtree: true, characterData: true });
|
|
194
|
+
bodyEl.addEventListener("scroll", update, { passive: true });
|
|
195
|
+
cleanup = () => {
|
|
196
|
+
ro.disconnect();
|
|
197
|
+
mo.disconnect();
|
|
198
|
+
bodyEl.removeEventListener("scroll", update);
|
|
199
|
+
classes.forEach(cls => footerEl.classList.remove(cls));
|
|
200
|
+
};
|
|
201
|
+
};
|
|
202
|
+
requestAnimationFrame(tryInit);
|
|
154
203
|
return () => {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
bodyEl.removeEventListener("scroll", update);
|
|
158
|
-
footerEl.classList.remove(borderClass);
|
|
204
|
+
cancelled = true;
|
|
205
|
+
cleanup?.();
|
|
159
206
|
};
|
|
160
|
-
}, [
|
|
207
|
+
}, [enabled, footerClass, bodySelector, footerSelector, rootRef]);
|
|
161
208
|
};
|
|
162
209
|
|
|
163
210
|
/**
|
|
@@ -190,11 +237,101 @@ const useModalFooterBorder = (rootRef, { borderClass = "border-t", enabled = tru
|
|
|
190
237
|
const Modal = ({ children, isOpen, role = "dialog", dataTestId, className, containerClassName, onBackdropClick, floatingUi, ref, }) => {
|
|
191
238
|
const { rootElement, refs, floatingStyles, context, getFloatingProps } = floatingUi;
|
|
192
239
|
const cardRef = useRef(null);
|
|
193
|
-
useModalFooterBorder(cardRef, { enabled: isOpen,
|
|
240
|
+
useModalFooterBorder(cardRef, { enabled: isOpen, footerClass: "border-t pt-4" });
|
|
194
241
|
return (jsx(Portal, { root: rootElement, children: isOpen ? (jsx(FloatingFocusManager, { context: context, children: jsx("div", { ref: refs.setFloating, style: { ...floatingStyles, zIndex: zIndex.overlay }, ...getFloatingProps(), children: jsx(ModalBackdrop, { onClick: onBackdropClick, children: jsx("div", { "aria-modal": true, className: cvaModalContainer({ className: containerClassName }), ref: ref, role: role, children: jsx(Card, { className: cvaModalCard({ className }), dataTestId: dataTestId, ref: cardRef, children: children }) }) }) }) })) : null }));
|
|
195
242
|
};
|
|
196
243
|
Modal.displayName = "Modal";
|
|
197
244
|
|
|
245
|
+
const cvaModalBodyContainer = cvaMerge([
|
|
246
|
+
"flex",
|
|
247
|
+
"flex-grow",
|
|
248
|
+
"min-h-0",
|
|
249
|
+
"overflow-auto",
|
|
250
|
+
"bg-neutral-50",
|
|
251
|
+
"flex-col",
|
|
252
|
+
"component-card-spacing",
|
|
253
|
+
"@sm:component-card-spacing-sm",
|
|
254
|
+
"@md:component-card-spacing-md",
|
|
255
|
+
]);
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Modal body container.
|
|
259
|
+
*
|
|
260
|
+
* Renders children inside a scrollable flex container.
|
|
261
|
+
*
|
|
262
|
+
* @param {ModalBodyProps} props Component props.
|
|
263
|
+
* @param {ReactNode | null} props.children Content to render inside the modal body.
|
|
264
|
+
* @param {string} [props.dataTestId] Optional test id for the container.
|
|
265
|
+
* @returns {ReactElement} Modal body wrapper element.
|
|
266
|
+
*/
|
|
267
|
+
const ModalBody = forwardRef(({ children, id, dataTestId, className }, ref) => {
|
|
268
|
+
return (jsx("div", { className: cvaModalBodyContainer({ className }), "data-modal-body": true, "data-testid": dataTestId, id: id, children: children }));
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const cvaModalFooterContainer = cvaMerge([
|
|
272
|
+
"flex",
|
|
273
|
+
// flex-wrap
|
|
274
|
+
"justify-end",
|
|
275
|
+
"flex-row",
|
|
276
|
+
"px-4",
|
|
277
|
+
"pb-4",
|
|
278
|
+
"gap-3",
|
|
279
|
+
"bg-neutral-50",
|
|
280
|
+
"border-neutral-200",
|
|
281
|
+
"modal-footer",
|
|
282
|
+
]);
|
|
283
|
+
|
|
284
|
+
function isCustom(props) {
|
|
285
|
+
return props.children !== null && props.children !== undefined;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Modal footer with action buttons.
|
|
289
|
+
*
|
|
290
|
+
* @param props Component props.
|
|
291
|
+
* @param props.cancelLabel Text for the Cancel button.
|
|
292
|
+
* @param props.onCancel Optional handler invoked when the Cancel button is clicked (alias to onClose, if provided).
|
|
293
|
+
* @param props.secondaryLabel Text for the optional Secondary action button.
|
|
294
|
+
* @param props.secondaryAction Click handler for the optional Secondary action button.
|
|
295
|
+
* @param props.primaryLabel Text for the Primary action button.
|
|
296
|
+
* @param props.primaryAction Click handler for the Primary action button.
|
|
297
|
+
* @param props.primaryVariant Visual variant for the Primary button ("primary" | "primary-danger").
|
|
298
|
+
* @param props.primaryLoading Optional loading state for the Primary button.
|
|
299
|
+
* @returns {ReactElement} Modal footer container element.
|
|
300
|
+
*/
|
|
301
|
+
const ModalFooter = forwardRef((props, ref) => {
|
|
302
|
+
const { dataTestId, className } = props;
|
|
303
|
+
if (isCustom(props)) {
|
|
304
|
+
// Branch with children: everything else is not required
|
|
305
|
+
return (jsx("div", { className: cvaModalFooterContainer({ className }), "data-modal-footer": true, "data-testid": dataTestId, ref: ref, children: props.children }));
|
|
306
|
+
}
|
|
307
|
+
// Branch with built-in buttons
|
|
308
|
+
const { onCancel, cancelLabel, primaryLabel, primaryAction, primaryVariant = "primary", primaryLoading = false, primaryDisabled = false, primaryFormId, secondaryLabel, secondaryAction, secondaryVariant = "secondary", secondaryLoading = false, secondaryDisabled = false, primaryName, secondaryName, cancelName, } = props;
|
|
309
|
+
return (jsxs("div", { className: cvaModalFooterContainer({ className }), "data-modal-footer": true, "data-testid": dataTestId, children: [jsx(Button, { dataTestId: dataTestId ? `${dataTestId}-cancel` : undefined, disabled: secondaryLoading || primaryLoading, name: cancelName, onClick: onCancel, variant: "ghost-neutral", children: cancelLabel }), secondaryLabel && secondaryAction ? (jsx(Button, { dataTestId: dataTestId ? `${dataTestId}-secondary` : undefined, disabled: primaryLoading || secondaryDisabled, loading: secondaryLoading, name: secondaryName, onClick: secondaryAction, role: "button", variant: secondaryVariant, children: secondaryLabel })) : null, primaryLabel ? (jsx(Button, { dataTestId: dataTestId ? `${dataTestId}-primary` : undefined, disabled: secondaryLoading || primaryDisabled, form: primaryFormId, loading: primaryLoading, name: primaryName, onClick: primaryAction, role: "button", type: "submit", variant: primaryVariant, children: primaryLabel })) : null] }));
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const cvaContainer = cvaMerge(["flex", "justify-between", "border-b", "border-neutral-200", "p-4", "flex-none"]);
|
|
313
|
+
const cvaHeadingContainer = cvaMerge(["flex", "flex-col", "justify-center", "gap-1"]);
|
|
314
|
+
const cvaTitleContainer = cvaMerge(["flex", "items-center", "gap-1"]);
|
|
315
|
+
const cvaIconContainer = cvaMerge(["flex", "place-items-center"]);
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Modal header section.
|
|
319
|
+
* Displays a main heading, optional subheading, and a close button.
|
|
320
|
+
*
|
|
321
|
+
* @param {ModalHeaderProps} props Component props.
|
|
322
|
+
* @param {string} [props.heading] Main heading text.
|
|
323
|
+
* @param {string} [props.subHeading] Optional subheading content.
|
|
324
|
+
* @param {() => void} props.onClose Close button click handler.
|
|
325
|
+
* @param {string} [props.dataTestId] Optional test id for the container.
|
|
326
|
+
* @param {string} [props.className] Optional additional class name(s).
|
|
327
|
+
* @returns {ReactElement} The modal header element.
|
|
328
|
+
*/
|
|
329
|
+
const ModalHeader = forwardRef(({ heading, subHeading, onClose, dataTestId, className, id, children, accessories }, ref) => {
|
|
330
|
+
return (jsxs("div", { className: cvaContainer({
|
|
331
|
+
className,
|
|
332
|
+
}), "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", dataTestId: dataTestId ? `${dataTestId}-close-button` : "modal-close-button", icon: jsx(Icon, { name: "XMark", size: "small" }), onClick: onClose, size: "small", variant: "ghost-neutral" }) })] }));
|
|
333
|
+
});
|
|
334
|
+
|
|
198
335
|
/**
|
|
199
336
|
* A hook to handle the state and configuration of Modal components.
|
|
200
337
|
*
|
|
@@ -309,4 +446,4 @@ const useModalInner = ({ isOpen, setIsOpen, onOpenChange, rootElement, closeOnOu
|
|
|
309
446
|
*/
|
|
310
447
|
setupLibraryTranslations();
|
|
311
448
|
|
|
312
|
-
export { Modal, ModalBackdrop, useModal };
|
|
449
|
+
export { Modal, ModalBackdrop, ModalBody, ModalFooter, ModalHeader, useModal };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trackunit/react-modal",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.71",
|
|
4
4
|
"repository": "https://github.com/Trackunit/manager",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
6
|
"engines": {
|
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
10
|
"react": "19.0.0",
|
|
11
|
-
"@trackunit/react-components": "1.10.
|
|
12
|
-
"@trackunit/react-core-hooks": "1.7.
|
|
13
|
-
"@trackunit/css-class-variance-utilities": "1.7.
|
|
14
|
-
"@trackunit/i18n-library-translation": "1.7.
|
|
15
|
-
"@trackunit/react-test-setup": "1.4.
|
|
11
|
+
"@trackunit/react-components": "1.10.11",
|
|
12
|
+
"@trackunit/react-core-hooks": "1.7.54",
|
|
13
|
+
"@trackunit/css-class-variance-utilities": "1.7.46",
|
|
14
|
+
"@trackunit/i18n-library-translation": "1.7.57",
|
|
15
|
+
"@trackunit/react-test-setup": "1.4.46",
|
|
16
16
|
"@floating-ui/react": "^0.26.25",
|
|
17
17
|
"@floating-ui/react-dom": "2.1.2",
|
|
18
|
-
"@trackunit/ui-design-tokens": "1.7.
|
|
18
|
+
"@trackunit/ui-design-tokens": "1.7.46"
|
|
19
19
|
},
|
|
20
20
|
"module": "./index.esm.js",
|
|
21
21
|
"main": "./index.cjs.js",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { CommonProps } from "@trackunit/react-components";
|
|
2
|
-
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
interface ModalFooterBuiltInProps extends CommonProps {
|
|
4
|
+
children?: null | undefined;
|
|
3
5
|
/**
|
|
4
6
|
* Click handler for the Cancel button.
|
|
5
7
|
*/
|
|
@@ -8,7 +10,7 @@ export interface ModalFooterProps extends CommonProps {
|
|
|
8
10
|
* Text for the Primary action button.
|
|
9
11
|
* When omitted, the Primary button is not rendered.
|
|
10
12
|
*/
|
|
11
|
-
primaryLabel?: string;
|
|
13
|
+
primaryLabel?: string | null;
|
|
12
14
|
/**
|
|
13
15
|
* Visual variant for the Primary button.
|
|
14
16
|
* Defaults to "primary".
|
|
@@ -16,19 +18,26 @@ export interface ModalFooterProps extends CommonProps {
|
|
|
16
18
|
primaryVariant?: "primary" | "primary-danger";
|
|
17
19
|
/**
|
|
18
20
|
* Click handler for the Primary action button.
|
|
19
|
-
* The Primary button is rendered only if both primaryLabel and primaryAction are provided.
|
|
20
21
|
*/
|
|
21
|
-
primaryAction?: () => void;
|
|
22
|
+
primaryAction?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
|
22
23
|
/**
|
|
23
24
|
* Loading state for the Primary button.
|
|
24
25
|
* Defaults to false.
|
|
25
26
|
*/
|
|
26
27
|
primaryLoading?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Disabled state for the Primary button.
|
|
30
|
+
* Defaults to false.
|
|
31
|
+
* Note: the Primary button is also disabled while `secondaryLoading` is true.
|
|
32
|
+
*/
|
|
33
|
+
primaryDisabled?: boolean;
|
|
34
|
+
/** Optional form id to bind the Primary button to an external form */
|
|
35
|
+
primaryFormId?: string;
|
|
27
36
|
/**
|
|
28
37
|
* Text for the Secondary action button.
|
|
29
38
|
* The Secondary button is rendered only if both secondaryLabel and secondaryAction are provided.
|
|
30
39
|
*/
|
|
31
|
-
secondaryLabel?: string;
|
|
40
|
+
secondaryLabel?: string | null;
|
|
32
41
|
/**
|
|
33
42
|
* Click handler for the Secondary action button.
|
|
34
43
|
*/
|
|
@@ -43,11 +52,40 @@ export interface ModalFooterProps extends CommonProps {
|
|
|
43
52
|
* Defaults to false.
|
|
44
53
|
*/
|
|
45
54
|
secondaryLoading?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Disabled state for the Secondary button.
|
|
57
|
+
* Defaults to false.
|
|
58
|
+
* Note: the Secondary button is also disabled while `primaryLoading` is true.
|
|
59
|
+
*/
|
|
60
|
+
secondaryDisabled?: boolean;
|
|
46
61
|
/**
|
|
47
62
|
* Text for the Cancel button.
|
|
48
63
|
*/
|
|
49
64
|
cancelLabel: string;
|
|
65
|
+
primaryName?: string;
|
|
66
|
+
secondaryName?: string;
|
|
67
|
+
cancelName?: string;
|
|
68
|
+
}
|
|
69
|
+
interface ModalFooterCustomProps extends CommonProps {
|
|
70
|
+
children: ReactNode;
|
|
71
|
+
onCancel?: never;
|
|
72
|
+
cancelLabel?: never;
|
|
73
|
+
primaryLabel?: never;
|
|
74
|
+
primaryVariant?: never;
|
|
75
|
+
primaryAction?: never;
|
|
76
|
+
primaryLoading?: never;
|
|
77
|
+
primaryDisabled?: never;
|
|
78
|
+
primaryFormId?: never;
|
|
79
|
+
secondaryLabel?: never;
|
|
80
|
+
secondaryAction?: never;
|
|
81
|
+
secondaryVariant?: never;
|
|
82
|
+
secondaryLoading?: never;
|
|
83
|
+
secondaryDisabled?: never;
|
|
84
|
+
primaryName?: never;
|
|
85
|
+
secondaryName?: never;
|
|
86
|
+
cancelName?: never;
|
|
50
87
|
}
|
|
88
|
+
export type ModalFooterProps = ModalFooterBuiltInProps | ModalFooterCustomProps;
|
|
51
89
|
/**
|
|
52
90
|
* Modal footer with action buttons.
|
|
53
91
|
*
|
|
@@ -63,3 +101,4 @@ export interface ModalFooterProps extends CommonProps {
|
|
|
63
101
|
* @returns {ReactElement} Modal footer container element.
|
|
64
102
|
*/
|
|
65
103
|
export declare const ModalFooter: import("react").ForwardRefExoticComponent<ModalFooterProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
104
|
+
export {};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CommonProps } from "@trackunit/react-components";
|
|
2
|
+
import { ReactElement, ReactNode } from "react";
|
|
2
3
|
export interface ModalHeaderProps extends CommonProps {
|
|
3
4
|
/**
|
|
4
5
|
* Main title displayed in the modal header.
|
|
@@ -8,11 +9,23 @@ export interface ModalHeaderProps extends CommonProps {
|
|
|
8
9
|
* Optional subtitle displayed below the main title.
|
|
9
10
|
* Not rendered when undefined.
|
|
10
11
|
*/
|
|
11
|
-
subHeading?: string;
|
|
12
|
+
subHeading?: string | null | ReactElement;
|
|
12
13
|
/**
|
|
13
14
|
* Click handler for the close button.
|
|
14
15
|
*/
|
|
15
16
|
onClose: () => void;
|
|
17
|
+
/**
|
|
18
|
+
* The id of the element.
|
|
19
|
+
*/
|
|
20
|
+
id?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Children to render in the header
|
|
23
|
+
*/
|
|
24
|
+
children?: ReactNode | null;
|
|
25
|
+
/**
|
|
26
|
+
* Items to render next to the heading
|
|
27
|
+
*/
|
|
28
|
+
accessories?: ReactNode;
|
|
16
29
|
}
|
|
17
30
|
/**
|
|
18
31
|
* Modal header section.
|
|
@@ -23,5 +23,6 @@ export default meta;
|
|
|
23
23
|
export declare const PackageName: () => ReactElement;
|
|
24
24
|
export declare const Default: Story;
|
|
25
25
|
export declare const WithSubHeading: Story;
|
|
26
|
+
export declare const WithAccessories: Story;
|
|
26
27
|
export declare const LongHeadings: Story;
|
|
27
28
|
export declare const InContextWithinModal: () => ReactElement;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export declare const cvaContainer: (props?: import("class-variance-authority/dist/types").ClassProp | undefined) => string;
|
|
2
2
|
export declare const cvaHeadingContainer: (props?: import("class-variance-authority/dist/types").ClassProp | undefined) => string;
|
|
3
|
+
export declare const cvaTitleContainer: (props?: import("class-variance-authority/dist/types").ClassProp | undefined) => string;
|
|
3
4
|
export declare const cvaIconContainer: (props?: import("class-variance-authority/dist/types").ClassProp | undefined) => string;
|
package/src/modal/index.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { RefObject } from "react";
|
|
2
2
|
type Options = {
|
|
3
|
-
|
|
3
|
+
footerClass?: string | Array<string>;
|
|
4
4
|
enabled?: boolean;
|
|
5
5
|
bodySelector?: string;
|
|
6
6
|
footerSelector?: string;
|
|
7
7
|
};
|
|
8
|
+
type ElementRef = HTMLElement | null;
|
|
9
|
+
type ElementRefLike = ElementRef | RefObject<ElementRef>;
|
|
8
10
|
/**
|
|
9
11
|
* Observes the modal body within the given root element and toggles a top border on the footer
|
|
10
12
|
* when the body becomes vertically scrollable.
|
|
@@ -20,10 +22,10 @@ type Options = {
|
|
|
20
22
|
*
|
|
21
23
|
* @param rootRef Root element that contains both the modal body and footer.
|
|
22
24
|
* @param options Optional configuration.
|
|
23
|
-
* @param options.
|
|
25
|
+
* @param options.footerClass CSS class(es) toggled on the footer when the body is scrollable. Accepts string or string[]. Defaults to "border-t".
|
|
24
26
|
* @param options.enabled Whether the hook is active. Defaults to true.
|
|
25
27
|
* @param options.bodySelector CSS selector to locate the body element. Defaults to "[data-modal-body]".
|
|
26
28
|
* @param options.footerSelector CSS selector to locate the footer element. Defaults to "[data-modal-footer]".
|
|
27
29
|
*/
|
|
28
|
-
export declare const useModalFooterBorder: (rootRef:
|
|
30
|
+
export declare const useModalFooterBorder: (rootRef: ElementRefLike, { footerClass, enabled, bodySelector, footerSelector, }?: Options) => void;
|
|
29
31
|
export {};
|