@trackunit/react-components 1.10.5 → 1.10.11

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.esm.js CHANGED
@@ -10,11 +10,11 @@ import IconSpriteSolid from '@trackunit/ui-icons/icons-sprite-solid.svg';
10
10
  import { snakeCase, titleCase } from 'string-ts';
11
11
  import { cvaMerge } from '@trackunit/css-class-variance-utilities';
12
12
  import { Slottable, Slot } from '@radix-ui/react-slot';
13
+ import { isEqual, omit } from 'es-toolkit';
13
14
  import { useVirtualizer } from '@tanstack/react-virtual';
14
15
  import { useDebounceCallback, useCopyToClipboard } from 'usehooks-ts';
15
16
  import { Link, useBlocker } from '@tanstack/react-router';
16
17
  import { useFloating, autoUpdate, offset, flip, shift, size, useClick, useDismiss, useHover as useHover$1, useRole, useInteractions, FloatingPortal, useMergeRefs, FloatingFocusManager, arrow, useTransitionStatus, FloatingArrow } from '@floating-ui/react';
17
- import { omit } from 'es-toolkit';
18
18
  import { twMerge } from 'tailwind-merge';
19
19
  import { HelmetProvider, Helmet } from 'react-helmet-async';
20
20
  import { Trigger, Content, List as List$1, Root } from '@radix-ui/react-tabs';
@@ -252,14 +252,14 @@ const Tag = ({ className, dataTestId, children, size = "medium", onClose, color
252
252
  return false;
253
253
  }, [color]);
254
254
  const layout = useMemo(() => {
255
- if (onClose !== undefined && isSupportedDismissColor) {
255
+ if (onClose !== undefined && isSupportedDismissColor && !disabled) {
256
256
  return "containsDismiss";
257
257
  }
258
258
  if (icon !== null && icon !== undefined) {
259
259
  return "containsIcon";
260
260
  }
261
261
  return "default";
262
- }, [onClose, icon, isSupportedDismissColor]);
262
+ }, [onClose, isSupportedDismissColor, disabled, icon]);
263
263
  return (jsxs("div", { className: cvaTag({ className, size, color, layout, border: color === "white" ? "default" : "none" }), "data-testid": dataTestId, onMouseEnter: onMouseEnter, ref: ref, children: [icon !== null && icon !== undefined && size === "medium" ? (jsx("div", { className: cvaTagIconContainer(), children: icon })) : null, jsx("span", { className: cvaTagText(), children: children }), Boolean(onClose) && isSupportedDismissColor && size === "medium" && !disabled ? (
264
264
  // a fix for multiselect deselecting tags working together with fade out animation
265
265
  jsx("div", { className: cvaTagIconContainer(), onMouseDown: onClose, children: jsx(Icon, { className: cvaTagIcon(), dataTestId: dataTestId + "Icon", name: "XCircle", size: "small", style: { WebkitTransition: "-webkit-transform 0.150s" }, type: "solid" }) })) : null] }));
@@ -277,6 +277,8 @@ const PackageNameStoryComponent = ({ packageJSON }) => {
277
277
  * Hook to detect if text content is wrapping to multiple lines
278
278
  *
279
279
  * @template TElement - The type of the HTML element being observed (e.g., HTMLDivElement).
280
+ * @param {object} [options] - Configuration options
281
+ * @param {boolean} [options.skip] - Whether to skip observing for wrapping (default: false)
280
282
  * @returns {{
281
283
  * ref: RefObject<TElement | null>;
282
284
  * isTooltipVisible: boolean;
@@ -284,7 +286,8 @@ const PackageNameStoryComponent = ({ packageJSON }) => {
284
286
  * - `ref`: a ref to attach to the element you want to observe for truncation.
285
287
  * - `isTextWrapping`: a boolean indicating if the text is wrapping.
286
288
  */
287
- const useIsTextWrapping = () => {
289
+ const useIsTextWrapping = (options = {}) => {
290
+ const { skip = false } = options;
288
291
  const ref = useRef(null);
289
292
  const [isTextWrapping, setIsTextWrapping] = useState(false);
290
293
  const setTextWrappingState = useCallback(() => {
@@ -295,7 +298,7 @@ const useIsTextWrapping = () => {
295
298
  setIsTextWrapping(clientHeight > scrollHeight / 2);
296
299
  }, []);
297
300
  useEffect(() => {
298
- if (!ref.current) {
301
+ if (skip || !ref.current) {
299
302
  return;
300
303
  }
301
304
  // Perform an immediate measurement on mount.
@@ -309,8 +312,8 @@ const useIsTextWrapping = () => {
309
312
  observer.observe(ref.current);
310
313
  // Clean up on unmount
311
314
  return () => observer.disconnect();
312
- }, [setTextWrappingState]);
313
- return { ref, isTextWrapping };
315
+ }, [setTextWrappingState, skip]);
316
+ return useMemo(() => ({ ref, isTextWrapping }), [isTextWrapping]);
314
317
  };
315
318
 
316
319
  const cvaText = cvaMerge(["text-black", "m-0", "relative", "text-sm", "font-normal"], {
@@ -1072,6 +1075,8 @@ const createBreakpointState = ({ width }) => {
1072
1075
  * an object with boolean values indicating which breakpoints are currently active.
1073
1076
  *
1074
1077
  * @param {RefObject<HTMLElement>} ref - Reference to the container element to observe
1078
+ * @param {object} [options] - Configuration options
1079
+ * @param {boolean} [options.skip] - Whether to skip observing for breakpoint changes (default: false)
1075
1080
  * @returns {BreakpointState} An object containing boolean values for each container size breakpoint.
1076
1081
  * @example
1077
1082
  * const MyComponent = () => {
@@ -1091,7 +1096,8 @@ const createBreakpointState = ({ width }) => {
1091
1096
  * );
1092
1097
  * }
1093
1098
  */
1094
- const useContainerBreakpoints = (ref) => {
1099
+ const useContainerBreakpoints = (ref, options = {}) => {
1100
+ const { skip = false } = options;
1095
1101
  const [containerSize, setContainerSize] = useState(() => defaultBreakpointState);
1096
1102
  useEffect(() => {
1097
1103
  if (process.env.NODE_ENV === "development" && !ref.current) {
@@ -1108,6 +1114,9 @@ const useContainerBreakpoints = (ref) => {
1108
1114
  setContainerSize(createBreakpointState({ width }));
1109
1115
  }, [ref]);
1110
1116
  useEffect(() => {
1117
+ if (skip) {
1118
+ return;
1119
+ }
1111
1120
  const element = ref.current;
1112
1121
  if (!element) {
1113
1122
  return;
@@ -1120,7 +1129,7 @@ const useContainerBreakpoints = (ref) => {
1120
1129
  return () => {
1121
1130
  resizeObserver.disconnect();
1122
1131
  };
1123
- }, [updateContainerSize, ref]);
1132
+ }, [updateContainerSize, ref, skip]);
1124
1133
  return containerSize;
1125
1134
  };
1126
1135
 
@@ -1132,7 +1141,7 @@ const useContainerBreakpoints = (ref) => {
1132
1141
  * @param {number} options.duration - Duration of the timeout in milliseconds.
1133
1142
  * @returns {object} An object containing functions to start and stop the timeout.
1134
1143
  */
1135
- const useTimeout = ({ onTimeout, duration }) => {
1144
+ const useTimeout = ({ onTimeout, duration, }) => {
1136
1145
  const ready = useRef(false);
1137
1146
  const timeout = useRef(null);
1138
1147
  const callback = useRef(onTimeout);
@@ -1164,7 +1173,7 @@ const useTimeout = ({ onTimeout, duration }) => {
1164
1173
  }
1165
1174
  };
1166
1175
  }, []);
1167
- return { startTimeout, stopTimeout };
1176
+ return useMemo(() => ({ startTimeout, stopTimeout }), [startTimeout, stopTimeout]);
1168
1177
  };
1169
1178
 
1170
1179
  /**
@@ -1177,20 +1186,9 @@ const useTimeout = ({ onTimeout, duration }) => {
1177
1186
  * @param {number} options.maxRetries - Maximum number of retry attempts.
1178
1187
  * @returns {object} An object containing functions to start and stop the timeout, current retry count, and the timeout status.
1179
1188
  */
1180
- const useContinuousTimeout = ({ onTimeout, onMaxRetries, duration, maxRetries }) => {
1189
+ const useContinuousTimeout = ({ onTimeout, onMaxRetries, duration, maxRetries, }) => {
1181
1190
  const retries = useRef(0);
1182
1191
  const [isRunning, setIsRunning] = useState(false); // Track the timeout status
1183
- const stopTimeouts = () => {
1184
- stopTimeout();
1185
- setIsRunning(false); // Update the status when stopped
1186
- };
1187
- const startTimeouts = () => {
1188
- if (isRunning) {
1189
- return; // Prevent multiple timeouts from running
1190
- }
1191
- startTimeout();
1192
- setIsRunning(true); // Update the status when started
1193
- };
1194
1192
  const { startTimeout, stopTimeout } = useTimeout({
1195
1193
  duration,
1196
1194
  onTimeout: () => {
@@ -1207,14 +1205,25 @@ const useContinuousTimeout = ({ onTimeout, onMaxRetries, duration, maxRetries })
1207
1205
  }
1208
1206
  },
1209
1207
  });
1210
- return {
1208
+ const stopTimeouts = useCallback(() => {
1209
+ stopTimeout();
1210
+ setIsRunning(false); // Update the status when stopped
1211
+ }, [stopTimeout]);
1212
+ const startTimeouts = useCallback(() => {
1213
+ if (isRunning) {
1214
+ return; // Prevent multiple timeouts from running
1215
+ }
1216
+ startTimeout();
1217
+ setIsRunning(true); // Update the status when started
1218
+ }, [isRunning, startTimeout]);
1219
+ return useMemo(() => ({
1211
1220
  startTimeouts,
1212
1221
  stopTimeouts,
1213
1222
  isRunning,
1214
1223
  get retries() {
1215
1224
  return retries.current;
1216
1225
  },
1217
- };
1226
+ }), [startTimeouts, stopTimeouts, isRunning]);
1218
1227
  };
1219
1228
 
1220
1229
  /**
@@ -1262,8 +1271,11 @@ const useDebounce = (value, delay = 500, direction, onBounce) => {
1262
1271
  function useDevicePixelRatio(options) {
1263
1272
  const dpr = getDevicePixelRatio(options);
1264
1273
  const [currentDpr, setCurrentDpr] = useState(dpr);
1265
- const { defaultDpr, maxDpr, round } = options || {};
1274
+ const { defaultDpr, maxDpr, round, skip = false } = options || {};
1266
1275
  useEffect(() => {
1276
+ if (skip) {
1277
+ return;
1278
+ }
1267
1279
  const canListen = typeof window !== "undefined" && "matchMedia" in window;
1268
1280
  if (!canListen) {
1269
1281
  return;
@@ -1274,7 +1286,7 @@ function useDevicePixelRatio(options) {
1274
1286
  return () => {
1275
1287
  mediaMatcher.removeEventListener("change", updateDpr);
1276
1288
  };
1277
- }, [currentDpr, defaultDpr, maxDpr, round]);
1289
+ }, [currentDpr, defaultDpr, maxDpr, round, skip]);
1278
1290
  return currentDpr;
1279
1291
  }
1280
1292
  /**
@@ -1301,8 +1313,8 @@ function getDevicePixelRatio(options) {
1301
1313
  * const [state, dispatch] = useElevatedReducer(reducer, initialState, elevatedReducerState);
1302
1314
  */
1303
1315
  const useElevatedReducer = (reducer, initialState, customState) => {
1304
- const fallbackState = useReducer(reducer, initialState);
1305
- return customState ?? fallbackState;
1316
+ const [fallbackValue, fallbackDispatch] = useReducer(reducer, initialState);
1317
+ return useMemo(() => customState ?? [fallbackValue, fallbackDispatch], [customState, fallbackValue, fallbackDispatch]);
1306
1318
  };
1307
1319
 
1308
1320
  /**
@@ -1313,15 +1325,16 @@ const useElevatedReducer = (reducer, initialState, customState) => {
1313
1325
  * If no custom state is provided, the fallback state will be used and it works like a normal useState hook.
1314
1326
  */
1315
1327
  const useElevatedState = (initialState, customState) => {
1316
- const fallbackState = useState(initialState);
1317
- return useMemo(() => customState ?? fallbackState, [customState, fallbackState]);
1328
+ const [fallbackValue, fallbackSetter] = useState(initialState);
1329
+ return useMemo(() => customState ?? [fallbackValue, fallbackSetter], [customState, fallbackValue, fallbackSetter]);
1318
1330
  };
1319
1331
 
1332
+ const UNINITIALIZED = Symbol("UNINITIALIZED");
1320
1333
  /**
1321
1334
  * Custom hook to get the geometry of an element.
1322
1335
  * Size and position of the element relative to the viewport.
1323
1336
  */
1324
- const useGeometry = (ref, { skip = false } = {}) => {
1337
+ const useGeometry = (ref, { skip = false, onChange } = {}) => {
1325
1338
  const [geometry, setGeometry] = useState(() => {
1326
1339
  const rect = ref.current?.getBoundingClientRect();
1327
1340
  if (!rect) {
@@ -1349,6 +1362,7 @@ const useGeometry = (ref, { skip = false } = {}) => {
1349
1362
  });
1350
1363
  const resizeObserver = useRef(null);
1351
1364
  const [element, setElement] = useState(ref.current);
1365
+ const prevGeometry = useRef(UNINITIALIZED);
1352
1366
  // Track changes to ref.current on every render
1353
1367
  if (ref.current !== element) {
1354
1368
  setElement(ref.current);
@@ -1359,7 +1373,7 @@ const useGeometry = (ref, { skip = false } = {}) => {
1359
1373
  }
1360
1374
  // Update geometry immediately when element changes
1361
1375
  const elementRect = element.getBoundingClientRect();
1362
- setGeometry({
1376
+ const newGeometry = {
1363
1377
  width: elementRect.width,
1364
1378
  height: elementRect.height,
1365
1379
  top: elementRect.top,
@@ -1368,13 +1382,23 @@ const useGeometry = (ref, { skip = false } = {}) => {
1368
1382
  right: elementRect.right,
1369
1383
  x: elementRect.x,
1370
1384
  y: elementRect.y,
1371
- });
1385
+ };
1386
+ const prev = prevGeometry.current;
1387
+ const hasChanged = prev === UNINITIALIZED ? false : !isEqual(newGeometry, prev);
1388
+ if (!hasChanged && prev !== UNINITIALIZED) {
1389
+ return;
1390
+ }
1391
+ setGeometry(newGeometry);
1392
+ if (hasChanged && prev !== UNINITIALIZED) {
1393
+ onChange?.(newGeometry);
1394
+ }
1395
+ prevGeometry.current = newGeometry;
1372
1396
  const observe = () => {
1373
1397
  if (!resizeObserver.current) {
1374
1398
  resizeObserver.current = new ResizeObserver(entries => {
1375
1399
  for (const entry of entries) {
1376
1400
  const entryRect = entry.target.getBoundingClientRect();
1377
- setGeometry({
1401
+ const observedGeometry = {
1378
1402
  width: entryRect.width,
1379
1403
  height: entryRect.height,
1380
1404
  top: entryRect.top,
@@ -1383,7 +1407,16 @@ const useGeometry = (ref, { skip = false } = {}) => {
1383
1407
  right: entryRect.right,
1384
1408
  x: entryRect.x,
1385
1409
  y: entryRect.y,
1386
- });
1410
+ };
1411
+ const prevObserved = prevGeometry.current;
1412
+ const hasObservedChanged = prevObserved === UNINITIALIZED ? false : !isEqual(observedGeometry, prevObserved);
1413
+ if (hasObservedChanged || prevObserved === UNINITIALIZED) {
1414
+ setGeometry(observedGeometry);
1415
+ if (hasObservedChanged && prevObserved !== UNINITIALIZED) {
1416
+ onChange?.(observedGeometry);
1417
+ }
1418
+ prevGeometry.current = observedGeometry;
1419
+ }
1387
1420
  }
1388
1421
  });
1389
1422
  }
@@ -1395,7 +1428,7 @@ const useGeometry = (ref, { skip = false } = {}) => {
1395
1428
  resizeObserver.current.disconnect();
1396
1429
  }
1397
1430
  };
1398
- }, [element, skip]);
1431
+ }, [element, onChange, skip]);
1399
1432
  return geometry;
1400
1433
  };
1401
1434
 
@@ -1410,13 +1443,17 @@ const useGeometry = (ref, { skip = false } = {}) => {
1410
1443
  const useHover = ({ debounced = false, delay = 100, direction = "out" } = { debounced: false }) => {
1411
1444
  const [hovering, setHovering] = useState(false);
1412
1445
  const debouncedHovering = useDebounce(hovering, delay, direction);
1413
- const onMouseEnter = () => {
1446
+ const onMouseEnter = useCallback(() => {
1414
1447
  setHovering(true);
1415
- };
1416
- const onMouseLeave = () => {
1448
+ }, []);
1449
+ const onMouseLeave = useCallback(() => {
1417
1450
  setHovering(false);
1418
- };
1419
- return { onMouseEnter, onMouseLeave, hovering: debounced ? debouncedHovering : hovering };
1451
+ }, []);
1452
+ return useMemo(() => ({
1453
+ onMouseEnter,
1454
+ onMouseLeave,
1455
+ hovering: debounced ? debouncedHovering : hovering,
1456
+ }), [onMouseEnter, onMouseLeave, debounced, debouncedHovering, hovering]);
1420
1457
  };
1421
1458
 
1422
1459
  const OVERSCAN = 10;
@@ -1431,6 +1468,7 @@ const DEFAULT_ROW_HEIGHT = 50;
1431
1468
  * @param props.estimateSize - Optional function to estimate item height.
1432
1469
  * @param props.overscan - Optional number of items to render outside the visible area.
1433
1470
  * @param props.onChange - Optional callback when virtualizer changes.
1471
+ * @param props.skip - Whether to skip automatic loading of more data (default: false).
1434
1472
  * @returns {Virtualizer} The virtualizer instance with all its properties and methods.
1435
1473
  * @description
1436
1474
  * This hook is used to implement infinite scrolling in a table. It uses TanStack Virtual's
@@ -1444,7 +1482,7 @@ const DEFAULT_ROW_HEIGHT = 50;
1444
1482
  * estimateSize: () => 35,
1445
1483
  * });
1446
1484
  */
1447
- const useInfiniteScroll = ({ pagination, scrollElementRef, count, estimateSize, overscan, onChange, }) => {
1485
+ const useInfiniteScroll = ({ pagination, scrollElementRef, count, estimateSize, overscan, onChange, skip = false, }) => {
1448
1486
  const handleChange = useCallback((virtualizer) => {
1449
1487
  onChange?.(virtualizer);
1450
1488
  }, [onChange]);
@@ -1462,7 +1500,7 @@ const useInfiniteScroll = ({ pagination, scrollElementRef, count, estimateSize,
1462
1500
  const virtualItems = rowVirtualizer.getVirtualItems();
1463
1501
  // Auto-load more data based on scroll position and content height
1464
1502
  useEffect(() => {
1465
- if (pagination.pageInfo?.hasNextPage !== true || pagination.isLoading) {
1503
+ if (skip || pagination.pageInfo?.hasNextPage !== true || pagination.isLoading) {
1466
1504
  return;
1467
1505
  }
1468
1506
  const container = scrollElementRef.current;
@@ -1478,6 +1516,7 @@ const useInfiniteScroll = ({ pagination, scrollElementRef, count, estimateSize,
1478
1516
  pagination.nextPage();
1479
1517
  }
1480
1518
  }, [
1519
+ skip,
1481
1520
  pagination.pageInfo?.hasNextPage,
1482
1521
  pagination.nextPage,
1483
1522
  pagination.isLoading,
@@ -1525,6 +1564,8 @@ const useIsFullscreen = () => {
1525
1564
  * @template TElement - The type of the HTML element being observed (e.g., HTMLDivElement).
1526
1565
  * @param {string} [text] - (Optional) Text used to trigger a re-check of truncation,
1527
1566
  * especially if the text is dynamic (such as an input's value).
1567
+ * @param {object} [options] - Configuration options
1568
+ * @param {boolean} [options.skip] - Whether to skip observing for truncation (default: false)
1528
1569
  * @returns {{
1529
1570
  * ref: Ref<TElement | null>;
1530
1571
  * isTooltipVisible: boolean;
@@ -1532,7 +1573,7 @@ const useIsFullscreen = () => {
1532
1573
  * - `ref`: a ref to attach to the element you want to observe for truncation.
1533
1574
  * - `isTextTruncated`: a boolean indicating if the text is truncated.
1534
1575
  */
1535
- const useIsTextTruncated = (text) => {
1576
+ const useIsTextTruncated = (text, { skip = false } = {}) => {
1536
1577
  const ref = useRef(null);
1537
1578
  const [isTextTruncated, setIsTextTruncated] = useState(false);
1538
1579
  const updateTextVisibility = useCallback(() => {
@@ -1543,9 +1584,11 @@ const useIsTextTruncated = (text) => {
1543
1584
  setIsTextTruncated(scrollWidth > clientWidth);
1544
1585
  }, []);
1545
1586
  useEffect(() => {
1546
- if (!ref.current) {
1587
+ if (skip || !ref.current) {
1547
1588
  return;
1548
1589
  }
1590
+ // Perform an immediate measurement on mount
1591
+ updateTextVisibility();
1549
1592
  // Observe resizing to check if truncation changes
1550
1593
  const observer = new ResizeObserver(() => {
1551
1594
  updateTextVisibility();
@@ -1553,15 +1596,15 @@ const useIsTextTruncated = (text) => {
1553
1596
  observer.observe(ref.current);
1554
1597
  // Clean up on unmount
1555
1598
  return () => observer.disconnect();
1556
- }, [updateTextVisibility]);
1599
+ }, [updateTextVisibility, skip]);
1557
1600
  // Re-check whenever text changes
1558
1601
  useEffect(() => {
1559
- if (text === undefined || text === "") {
1602
+ if (skip || text === undefined || text === "") {
1560
1603
  return;
1561
1604
  }
1562
1605
  updateTextVisibility();
1563
- }, [text, updateTextVisibility]);
1564
- return { ref, isTextTruncated };
1606
+ }, [text, updateTextVisibility, skip]);
1607
+ return useMemo(() => ({ ref, isTextTruncated }), [isTextTruncated]);
1565
1608
  };
1566
1609
 
1567
1610
  /**
@@ -1690,11 +1733,11 @@ const SCROLL_DEBOUNCE_TIME = 50;
1690
1733
  * Hook for detecting scroll values in horizontal or vertical direction.
1691
1734
  *
1692
1735
  * @param {useRef} elementRef - Ref hook holding the element that needs to be observed during scrolling
1693
- * @param {object} options - Options object containing direction and onScrollStateChange callback
1736
+ * @param {object} options - Options object containing direction, onScrollStateChange callback, and skip
1694
1737
  * @returns {object} An object containing if the element is scrollable, is at the beginning, is at the end, and its current scroll position.
1695
1738
  */
1696
1739
  const useScrollDetection = (elementRef, options) => {
1697
- const { direction = "vertical", onScrollStateChange } = options ?? {};
1740
+ const { direction = "vertical", onScrollStateChange, skip = false } = options ?? {};
1698
1741
  const [isScrollable, setIsScrollable] = useState(false);
1699
1742
  const [isAtBeginning, setIsAtBeginning] = useState(true);
1700
1743
  const [isAtEnd, setIsAtEnd] = useState(false);
@@ -1755,6 +1798,9 @@ const useScrollDetection = (elementRef, options) => {
1755
1798
  });
1756
1799
  }, [isScrollable, isAtBeginning, isAtEnd, scrollPosition, onScrollStateChange, isFirstRender]);
1757
1800
  useEffect(() => {
1801
+ if (skip) {
1802
+ return;
1803
+ }
1758
1804
  const element = elementRef?.current;
1759
1805
  if (!element) {
1760
1806
  return;
@@ -1774,7 +1820,7 @@ const useScrollDetection = (elementRef, options) => {
1774
1820
  }
1775
1821
  element.removeEventListener("scroll", debouncedCheckScrollPosition);
1776
1822
  };
1777
- }, [elementRef, checkScrollable, checkScrollPosition, debouncedCheckScrollPosition]);
1823
+ }, [elementRef, checkScrollable, checkScrollPosition, debouncedCheckScrollPosition, skip]);
1778
1824
  return useMemo(() => ({ isScrollable, isAtBeginning, isAtEnd, scrollPosition }), [isScrollable, isAtBeginning, isAtEnd, scrollPosition]);
1779
1825
  };
1780
1826
 
@@ -1798,6 +1844,8 @@ const useSelfUpdatingRef = (initialState) => {
1798
1844
  * This hook listens to changes in the viewport size and returns an object with boolean values
1799
1845
  * indicating which breakpoints are currently active.
1800
1846
  *
1847
+ * @param {object} [options] - Configuration options
1848
+ * @param {boolean} [options.skip] - Whether to skip observing for viewport changes (default: false)
1801
1849
  * @returns {BreakpointState} An object containing boolean values for each viewport size breakpoint.
1802
1850
  * @example
1803
1851
  * const MyComponent = () => {
@@ -1810,7 +1858,8 @@ const useSelfUpdatingRef = (initialState) => {
1810
1858
  * return viewportSize.isMd ? <MediumScreenLayout /> : <SmallLayout />;
1811
1859
  * }
1812
1860
  */
1813
- const useViewportBreakpoints = () => {
1861
+ const useViewportBreakpoints = (options = {}) => {
1862
+ const { skip = false } = options;
1814
1863
  const [viewportSize, setViewportSize] = useState(() => {
1815
1864
  const newViewportSize = objectEntries(themeScreenSizeAsNumber).reduce((acc, [size, minWidth]) => ({
1816
1865
  ...acc,
@@ -1826,6 +1875,9 @@ const useViewportBreakpoints = () => {
1826
1875
  setViewportSize(newViewportSize);
1827
1876
  }, []);
1828
1877
  useEffect(() => {
1878
+ if (skip) {
1879
+ return;
1880
+ }
1829
1881
  // Initial check
1830
1882
  updateViewportSize();
1831
1883
  // Set up listeners for each breakpoint
@@ -1839,7 +1891,7 @@ const useViewportBreakpoints = () => {
1839
1891
  mql.removeEventListener("change", updateViewportSize);
1840
1892
  });
1841
1893
  };
1842
- }, [updateViewportSize]);
1894
+ }, [updateViewportSize, skip]);
1843
1895
  return viewportSize;
1844
1896
  };
1845
1897
 
@@ -1847,7 +1899,7 @@ const hasFocus = () => typeof document !== "undefined" && document.hasFocus();
1847
1899
  /**
1848
1900
  * Use this hook to disable functionality while the tab is hidden within the browser or to react to focus or blur events
1849
1901
  */
1850
- const useWindowActivity = ({ onFocus, onBlur } = { onBlur: undefined, onFocus: undefined }) => {
1902
+ const useWindowActivity = ({ onFocus, onBlur, skip = false } = { onBlur: undefined, onFocus: undefined }) => {
1851
1903
  const [focused, setFocused] = useState(hasFocus());
1852
1904
  const onFocusInternal = useCallback(() => {
1853
1905
  setFocused(hasFocus());
@@ -1858,6 +1910,9 @@ const useWindowActivity = ({ onFocus, onBlur } = { onBlur: undefined, onFocus: u
1858
1910
  onBlur?.();
1859
1911
  }, [onBlur]);
1860
1912
  useEffect(() => {
1913
+ if (skip) {
1914
+ return;
1915
+ }
1861
1916
  setFocused(hasFocus()); // Focus for additional renders
1862
1917
  window.addEventListener("focus", onFocusInternal);
1863
1918
  window.addEventListener("blur", onBlurInternal);
@@ -1865,7 +1920,7 @@ const useWindowActivity = ({ onFocus, onBlur } = { onBlur: undefined, onFocus: u
1865
1920
  window.removeEventListener("focus", onFocusInternal);
1866
1921
  window.removeEventListener("blur", onBlurInternal);
1867
1922
  };
1868
- }, [onBlurInternal, onFocusInternal]);
1923
+ }, [onBlurInternal, onFocusInternal, skip]);
1869
1924
  return useMemo(() => ({ focused }), [focused]);
1870
1925
  };
1871
1926
 
@@ -4482,7 +4537,7 @@ function ActionRenderer({ action, isMenuItem = false, externalOnClick }) {
4482
4537
  }, prefix: prefixIconName ? jsx(Icon, { name: prefixIconName, size: "medium" }) : null, variant: variant === "secondary-danger" ? "danger" : "primary" })) : (jsx(Button, { dataTestId: dataTestId, disabled: disabled, onClick: e => {
4483
4538
  actionCallback?.(e);
4484
4539
  externalOnClick?.();
4485
- }, prefix: prefixIconName ? jsx(Icon, { name: prefixIconName, size: "small" }) : undefined, size: "medium", variant: variant, children: actionText }));
4540
+ }, prefix: prefixIconName ? jsx(Icon, { name: prefixIconName, size: "small" }) : undefined, size: "small", variant: variant, children: actionText }));
4486
4541
  // Wrap `content` with Tooltip
4487
4542
  const wrappedWithTooltip = tooltipLabel ? (jsx(Tooltip, { className: "block", label: tooltipLabel, children: content })) : (content);
4488
4543
  // Finally, wrap with Link if `to` is provided
@@ -4494,12 +4549,17 @@ function ActionRenderer({ action, isMenuItem = false, externalOnClick }) {
4494
4549
  * @param {object} props - The props for the PageHeaderSecondaryActions component
4495
4550
  * @param {Array<PageHeaderSecondaryActionType>} props.actions - The secondary actions to render
4496
4551
  * @param {boolean} [props.hasPrimaryAction] - Whether there is a primary action present
4497
- * @returns {ReactElement} PageHeaderSecondaryActions component
4552
+ * @param {boolean} [props.groupActions] - Whether to group actions in a More Menu regardless of action count
4553
+ * @returns {ReactElement | null} PageHeaderSecondaryActions component
4498
4554
  */
4499
- const PageHeaderSecondaryActions = ({ actions, hasPrimaryAction = false, }) => {
4555
+ const PageHeaderSecondaryActions = ({ actions, hasPrimaryAction = false, groupActions = false, }) => {
4500
4556
  const enabledActions = useMemo(() => actions.filter(action => action.hidden === false || action.hidden === undefined), [actions]);
4501
- // If we need to render a "More Menu" because we have too many actions:
4502
- if (enabledActions.length > 2 || (hasPrimaryAction && enabledActions.length > 1)) {
4557
+ // If there are no enabled actions, don't render anything
4558
+ if (enabledActions.length === 0) {
4559
+ return null;
4560
+ }
4561
+ // If we need to render a "More Menu" because we have too many actions or grouping is requested:
4562
+ if (groupActions || enabledActions.length > 2 || (hasPrimaryAction && enabledActions.length > 1)) {
4503
4563
  // Separate them into danger vs. other
4504
4564
  const [dangerActions, otherActions] = enabledActions.reduce(([danger, others], action) => {
4505
4565
  if (action.variant === "secondary-danger") {
@@ -4559,7 +4619,7 @@ const PageHeaderTitle = ({ title, dataTestId }) => {
4559
4619
  * @param {PageHeaderProps} props - The props for the PageHeader component
4560
4620
  * @returns {ReactElement} PageHeader component
4561
4621
  */
4562
- const PageHeader = ({ className, dataTestId, secondaryActions, showLoading = false, description, title, tagLabel, backTo, tagColor, tabsList, descriptionIcon = "QuestionMarkCircle", kpiMetrics, tagTooltipLabel, primaryAction, }) => {
4622
+ const PageHeader = ({ className, dataTestId, showLoading = false, description, title, tagLabel, backTo, tagColor, tabsList, descriptionIcon = "QuestionMarkCircle", tagTooltipLabel, ...discriminatedProps }) => {
4563
4623
  const tagRenderer = useMemo(() => {
4564
4624
  if (tagLabel === undefined || tagLabel === "" || showLoading) {
4565
4625
  return null;
@@ -4570,10 +4630,14 @@ const PageHeader = ({ className, dataTestId, secondaryActions, showLoading = fal
4570
4630
  return (jsxs("div", { className: cvaPageHeaderContainer({
4571
4631
  className,
4572
4632
  withBorder: tabsList === undefined,
4573
- }), "data-testid": dataTestId, children: [jsxs("div", { className: cvaPageHeader(), children: [backTo ? (jsx(Link, { to: backTo, children: jsx(Button, { className: "mr-4 bg-black/5 hover:bg-black/10", prefix: jsx(Icon, { name: "ArrowLeft", size: "small" }), size: "medium", square: true, variant: "ghost-neutral" }) })) : undefined, typeof title === "string" ? jsx(PageHeaderTitle, { dataTestId: dataTestId, title: title }) : title, tagRenderer || (description !== null && description !== undefined) ? (jsxs("div", { className: "mx-2 flex items-center gap-2", children: [description !== null && description !== undefined && !showLoading ? (jsx(Tooltip, { dataTestId: dataTestId ? `${dataTestId}-description-tooltip` : undefined, iconProps: {
4633
+ }), "data-testid": dataTestId, children: [jsxs("div", { className: cvaPageHeader(), children: [backTo ? (jsx(Link, { to: backTo, children: jsx(Button, { className: "mr-4 bg-black/5 hover:bg-black/10", prefix: jsx(Icon, { name: "ArrowLeft", size: "small" }), size: "small", square: true, variant: "ghost-neutral" }) })) : undefined, typeof title === "string" ? jsx(PageHeaderTitle, { dataTestId: dataTestId, title: title }) : title, tagRenderer || (description !== null && description !== undefined) ? (jsxs("div", { className: "mx-2 flex items-center gap-2", children: [description !== null && description !== undefined && !showLoading ? (jsx(Tooltip, { dataTestId: dataTestId ? `${dataTestId}-description-tooltip` : undefined, iconProps: {
4574
4634
  name: descriptionIcon,
4575
4635
  dataTestId: "page-header-description-icon",
4576
- }, label: description, placement: "bottom" })) : undefined, tagRenderer] })) : null, jsxs("div", { className: "ml-auto flex gap-2", children: [kpiMetrics ? jsx(PageHeaderKpiMetrics, { kpiMetrics: kpiMetrics }) : null, Array.isArray(secondaryActions) ? (jsx(PageHeaderSecondaryActions, { actions: secondaryActions, hasPrimaryAction: !!primaryAction })) : secondaryActions !== null && secondaryActions !== undefined ? (secondaryActions) : null, primaryAction !== undefined && (primaryAction.hidden === false || primaryAction.hidden === undefined) ? (jsx(Tooltip, { disabled: primaryAction.tooltipLabel === undefined || primaryAction.tooltipLabel === "", label: primaryAction.tooltipLabel, children: jsx(Button, { dataTestId: primaryAction.dataTestId, disabled: primaryAction.disabled, loading: primaryAction.loading, onClick: () => primaryAction.actionCallback?.(), prefix: primaryAction.prefixIconName !== undefined ? (jsx(Icon, { name: primaryAction.prefixIconName, size: "small" })) : undefined, size: "medium", variant: primaryAction.variant, children: primaryAction.actionText }) })) : null] })] }), tabsList] }));
4636
+ }, label: description, placement: "bottom" })) : undefined, tagRenderer] })) : null, jsxs("div", { className: "ml-auto flex gap-2", children: [discriminatedProps.accessoryType === "kpi-metrics" ? (jsx(PageHeaderKpiMetrics, { kpiMetrics: discriminatedProps.kpiMetrics })) : null, discriminatedProps.accessoryType === "actions" ? (Array.isArray(discriminatedProps.secondaryActions) ? (jsx(PageHeaderSecondaryActions, { actions: discriminatedProps.secondaryActions, groupActions: discriminatedProps.groupSecondaryActions ?? false, hasPrimaryAction: !!discriminatedProps.primaryAction })) : discriminatedProps.secondaryActions !== null && discriminatedProps.secondaryActions !== undefined ? (discriminatedProps.secondaryActions) : null) : null, discriminatedProps.accessoryType === "actions" &&
4637
+ discriminatedProps.primaryAction !== undefined &&
4638
+ (discriminatedProps.primaryAction.hidden === false ||
4639
+ discriminatedProps.primaryAction.hidden === undefined) ? (jsx(Tooltip, { disabled: discriminatedProps.primaryAction.tooltipLabel === undefined ||
4640
+ discriminatedProps.primaryAction.tooltipLabel === "", label: discriminatedProps.primaryAction.tooltipLabel, children: jsx(Button, { dataTestId: discriminatedProps.primaryAction.dataTestId, disabled: discriminatedProps.primaryAction.disabled, loading: discriminatedProps.primaryAction.loading, onClick: () => discriminatedProps.primaryAction?.actionCallback?.(), prefix: discriminatedProps.primaryAction.prefixIconName !== undefined ? (jsx(Icon, { name: discriminatedProps.primaryAction.prefixIconName, size: "small" })) : undefined, size: "small", variant: discriminatedProps.primaryAction.variant, children: discriminatedProps.primaryAction.actionText }) })) : null] })] }), tabsList] }));
4577
4641
  };
4578
4642
 
4579
4643
  const cvaPagination = cvaMerge(["flex", "items-center", "gap-1"]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-components",
3
- "version": "1.10.5",
3
+ "version": "1.10.11",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -16,11 +16,11 @@
16
16
  "@floating-ui/react": "^0.26.25",
17
17
  "string-ts": "^2.0.0",
18
18
  "tailwind-merge": "^2.0.0",
19
- "@trackunit/ui-design-tokens": "1.7.44",
20
- "@trackunit/css-class-variance-utilities": "1.7.44",
21
- "@trackunit/shared-utils": "1.9.44",
22
- "@trackunit/ui-icons": "1.7.45",
23
- "@trackunit/react-test-setup": "1.4.44",
19
+ "@trackunit/ui-design-tokens": "1.7.46",
20
+ "@trackunit/css-class-variance-utilities": "1.7.46",
21
+ "@trackunit/shared-utils": "1.9.46",
22
+ "@trackunit/ui-icons": "1.7.47",
23
+ "@trackunit/react-test-setup": "1.4.46",
24
24
  "@tanstack/react-router": "1.114.29",
25
25
  "es-toolkit": "^1.39.10",
26
26
  "@tanstack/react-virtual": "3.13.12"
@@ -10,4 +10,4 @@ import { PageHeaderProps } from "./types";
10
10
  * @param {PageHeaderProps} props - The props for the PageHeader component
11
11
  * @returns {ReactElement} PageHeader component
12
12
  */
13
- export declare const PageHeader: ({ className, dataTestId, secondaryActions, showLoading, description, title, tagLabel, backTo, tagColor, tabsList, descriptionIcon, kpiMetrics, tagTooltipLabel, primaryAction, }: PageHeaderProps) => ReactElement;
13
+ export declare const PageHeader: ({ className, dataTestId, showLoading, description, title, tagLabel, backTo, tagColor, tabsList, descriptionIcon, tagTooltipLabel, ...discriminatedProps }: PageHeaderProps) => ReactElement;
@@ -1,5 +1,10 @@
1
1
  import { ReactElement } from "react";
2
2
  import { PageHeaderSecondaryActionType } from "../types";
3
+ export type PageHeaderSecondaryActionsProps = {
4
+ actions: Array<PageHeaderSecondaryActionType>;
5
+ hasPrimaryAction?: boolean;
6
+ groupActions?: boolean;
7
+ };
3
8
  type ActionRendererProps = {
4
9
  action: PageHeaderSecondaryActionType;
5
10
  /**
@@ -26,10 +31,8 @@ export declare function ActionRenderer({ action, isMenuItem, externalOnClick }:
26
31
  * @param {object} props - The props for the PageHeaderSecondaryActions component
27
32
  * @param {Array<PageHeaderSecondaryActionType>} props.actions - The secondary actions to render
28
33
  * @param {boolean} [props.hasPrimaryAction] - Whether there is a primary action present
29
- * @returns {ReactElement} PageHeaderSecondaryActions component
34
+ * @param {boolean} [props.groupActions] - Whether to group actions in a More Menu regardless of action count
35
+ * @returns {ReactElement | null} PageHeaderSecondaryActions component
30
36
  */
31
- export declare const PageHeaderSecondaryActions: ({ actions, hasPrimaryAction, }: {
32
- actions: Array<PageHeaderSecondaryActionType>;
33
- hasPrimaryAction?: boolean;
34
- }) => ReactElement;
37
+ export declare const PageHeaderSecondaryActions: ({ actions, hasPrimaryAction, groupActions, }: PageHeaderSecondaryActionsProps) => ReactElement | null;
35
38
  export {};