@trackunit/react-modal 1.8.67 → 1.8.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.cjs.js CHANGED
@@ -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.borderClass CSS class toggled on the footer when the body is scrollable. Defaults to "border-t".
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, { borderClass = "border-t", enabled = true, bodySelector = "[data-modal-body]", footerSelector = "[data-modal-footer]", } = {}) => {
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
- const root = rootRef.current;
132
- if (!root)
133
- return;
134
- const bodyEl = root.querySelector(bodySelector);
135
- const footerEl = root.querySelector(footerSelector);
136
- if (!bodyEl || !footerEl)
137
- return;
138
- const update = () => {
139
- const hasScrollbar = bodyEl.scrollHeight > bodyEl.clientHeight;
140
- footerEl.classList.toggle(borderClass, hasScrollbar);
141
- };
142
- update();
143
- const resizeObserver = new ResizeObserver(update);
144
- resizeObserver.observe(bodyEl);
145
- const mutationObserver = new MutationObserver(update);
146
- mutationObserver.observe(bodyEl, { childList: true, subtree: true, characterData: true });
147
- bodyEl.addEventListener("scroll", update);
148
- const images = Array.from(bodyEl.querySelectorAll("img"));
149
- images.forEach(img => {
150
- if (!img.complete) {
151
- const once = () => update();
152
- img.addEventListener("load", once, { once: true });
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
- resizeObserver.disconnect();
158
- mutationObserver.disconnect();
159
- bodyEl.removeEventListener("scroll", update);
160
- footerEl.classList.remove(borderClass);
206
+ cancelled = true;
207
+ cleanup?.();
161
208
  };
162
- }, [rootRef, borderClass, enabled, bodySelector, footerSelector]);
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, borderClass: "border-t" });
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.borderClass CSS class toggled on the footer when the body is scrollable. Defaults to "border-t".
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, { borderClass = "border-t", enabled = true, bodySelector = "[data-modal-body]", footerSelector = "[data-modal-footer]", } = {}) => {
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
- const root = rootRef.current;
130
- if (!root)
131
- return;
132
- const bodyEl = root.querySelector(bodySelector);
133
- const footerEl = root.querySelector(footerSelector);
134
- if (!bodyEl || !footerEl)
135
- return;
136
- const update = () => {
137
- const hasScrollbar = bodyEl.scrollHeight > bodyEl.clientHeight;
138
- footerEl.classList.toggle(borderClass, hasScrollbar);
139
- };
140
- update();
141
- const resizeObserver = new ResizeObserver(update);
142
- resizeObserver.observe(bodyEl);
143
- const mutationObserver = new MutationObserver(update);
144
- mutationObserver.observe(bodyEl, { childList: true, subtree: true, characterData: true });
145
- bodyEl.addEventListener("scroll", update);
146
- const images = Array.from(bodyEl.querySelectorAll("img"));
147
- images.forEach(img => {
148
- if (!img.complete) {
149
- const once = () => update();
150
- img.addEventListener("load", once, { once: true });
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
- resizeObserver.disconnect();
156
- mutationObserver.disconnect();
157
- bodyEl.removeEventListener("scroll", update);
158
- footerEl.classList.remove(borderClass);
204
+ cancelled = true;
205
+ cleanup?.();
159
206
  };
160
- }, [rootRef, borderClass, enabled, bodySelector, footerSelector]);
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, borderClass: "border-t" });
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.67",
3
+ "version": "1.8.72",
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.7",
12
- "@trackunit/react-core-hooks": "1.7.52",
13
- "@trackunit/css-class-variance-utilities": "1.7.44",
14
- "@trackunit/i18n-library-translation": "1.7.55",
15
- "@trackunit/react-test-setup": "1.4.44",
11
+ "@trackunit/react-components": "1.10.12",
12
+ "@trackunit/react-core-hooks": "1.7.55",
13
+ "@trackunit/css-class-variance-utilities": "1.7.47",
14
+ "@trackunit/i18n-library-translation": "1.7.58",
15
+ "@trackunit/react-test-setup": "1.4.47",
16
16
  "@floating-ui/react": "^0.26.25",
17
17
  "@floating-ui/react-dom": "2.1.2",
18
- "@trackunit/ui-design-tokens": "1.7.44"
18
+ "@trackunit/ui-design-tokens": "1.7.47"
19
19
  },
20
20
  "module": "./index.esm.js",
21
21
  "main": "./index.cjs.js",
@@ -7,6 +7,10 @@ export interface ModalBodyProps extends CommonProps {
7
7
  * When content overflows, the container becomes scrollable.
8
8
  */
9
9
  children: ReactNode | null;
10
+ /**
11
+ * The id of the element.
12
+ */
13
+ id?: string;
10
14
  }
11
15
  /**
12
16
  * Modal body container.
@@ -1,5 +1,7 @@
1
1
  import { CommonProps } from "@trackunit/react-components";
2
- export interface ModalFooterProps extends CommonProps {
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;
@@ -1,3 +1,6 @@
1
1
  export * from "./Modal";
2
2
  export * from "./ModalBackdrop";
3
+ export * from "./ModalBody/ModalBody";
4
+ export * from "./ModalFooter/ModalFooter";
5
+ export * from "./ModalHeader/ModalHeader";
3
6
  export * from "./useModal";
@@ -1,10 +1,12 @@
1
1
  import { RefObject } from "react";
2
2
  type Options = {
3
- borderClass?: string;
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.borderClass CSS class toggled on the footer when the body is scrollable. Defaults to "border-t".
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: RefObject<HTMLElement | null>, { borderClass, enabled, bodySelector, footerSelector, }?: Options) => void;
30
+ export declare const useModalFooterBorder: (rootRef: ElementRefLike, { footerClass, enabled, bodySelector, footerSelector, }?: Options) => void;
29
31
  export {};