@trackunit/react-components 1.17.49 → 1.18.1

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
@@ -2208,7 +2208,7 @@ const Collapse = ({ id, variant = "primary", initialExpanded = false, onToggle,
2208
2208
  }
2209
2209
  setExpanded(!expanded);
2210
2210
  }, [expanded, onToggle]);
2211
- return (jsxRuntime.jsxs("div", { className: cvaCollapse({ variant: variant, className }), "data-testid": dataTestId, ref: ref, children: [jsxRuntime.jsx("div", { "aria-controls": id, "aria-expanded": expanded, className: cvaCollapseHeader({ expanded, variant, extraPadding, className: headerClassName }), onClick: handleClick, role: "button", children: jsxRuntime.jsxs("div", { className: cvaCollapseLabelContainer({ variant }), children: [jsxRuntime.jsx(Text, { className: cvaCollapseLabel({ variant }), id: LABEL_ID, size: variant === "secondary" ? "small" : "medium", type: "span", weight: "bold", children: label }), jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [headerAddon !== null && headerAddon !== undefined && variant !== "secondary" ? headerAddon : null, jsxRuntime.jsx(Icon, { ariaLabelledBy: LABEL_ID, className: cvaChevronIcon({ expanded }), name: "ChevronUp", size: variant === "secondary" ? "small" : "medium" })] })] }) }), jsxRuntime.jsx(Collapsible, { expanded: expanded, extraPadding: extraPadding, id: id, variant: variant, children: expanded || animate ? children : null })] }));
2211
+ return (jsxRuntime.jsxs("div", { className: cvaCollapse({ variant: variant, className }), "data-testid": dataTestId, ref: ref, children: [jsxRuntime.jsx("div", { "aria-controls": id, "aria-expanded": expanded, className: cvaCollapseHeader({ expanded, variant, extraPadding, className: headerClassName }), onClick: handleClick, role: "button", children: jsxRuntime.jsxs("div", { className: cvaCollapseLabelContainer({ variant }), children: [jsxRuntime.jsx(Text, { className: cvaCollapseLabel({ variant }), id: LABEL_ID, size: variant === "secondary" ? "small" : "medium", type: "span", weight: "bold", children: label }), jsxRuntime.jsxs("div", { className: "flex items-center gap-2", children: [headerAddon !== null && headerAddon !== undefined && variant !== "secondary" ? headerAddon : null, jsxRuntime.jsx(Icon, { ariaLabelledBy: LABEL_ID, className: cvaChevronIcon({ expanded }), name: "ChevronDown", size: variant === "secondary" ? "small" : "medium" })] })] }) }), jsxRuntime.jsx(Collapsible, { expanded: expanded, extraPadding: extraPadding, id: id, variant: variant, children: expanded || animate ? children : null })] }));
2212
2212
  };
2213
2213
  const Collapsible = ({ children, expanded, id, variant, extraPadding }) => {
2214
2214
  const { geometry, ref } = useMeasure();
@@ -5534,8 +5534,8 @@ const noPagination = {
5534
5534
  },
5535
5535
  };
5536
5536
 
5537
- const OVERSCAN = 10;
5538
- const DEFAULT_ROW_HEIGHT = 50;
5537
+ const OVERSCAN$1 = 10;
5538
+ const DEFAULT_ROW_HEIGHT$1 = 50;
5539
5539
  /**
5540
5540
  * Custom hook for implementing infinite scrolling in a table using TanStack Virtual.
5541
5541
  *
@@ -5565,14 +5565,14 @@ const useInfiniteScroll = ({ pagination, scrollElementRef, count, estimateSize,
5565
5565
  const handleChange = react.useCallback((virtualizer) => {
5566
5566
  onChange?.(virtualizer);
5567
5567
  }, [onChange]);
5568
- const handleEstimateSize = react.useCallback((index) => estimateSize?.(index) ?? DEFAULT_ROW_HEIGHT, [estimateSize]);
5568
+ const handleEstimateSize = react.useCallback((index) => estimateSize?.(index) ?? DEFAULT_ROW_HEIGHT$1, [estimateSize]);
5569
5569
  //! TODO: remove this once Tanstack Virtual is updated to support React Compiler
5570
5570
  // eslint-disable-next-line react-hooks/incompatible-library
5571
5571
  const rowVirtualizer = reactVirtual.useVirtualizer({
5572
5572
  count,
5573
5573
  getScrollElement: () => scrollElementRef.current,
5574
5574
  estimateSize: handleEstimateSize,
5575
- overscan: overscan ?? OVERSCAN,
5575
+ overscan: overscan ?? OVERSCAN$1,
5576
5576
  onChange: handleChange,
5577
5577
  // This option enables wrapping ResizeObserver measurements in requestAnimationFrame for smoother updates and reduced layout thrashing.
5578
5578
  // It helps prevent the "ResizeObserver loop completed with undelivered notifications" error by ensuring that measurements align with the rendering cycle
@@ -8415,6 +8415,111 @@ const useLocalStorageReducer = ({ key, defaultState, reducer, schema, onValidati
8415
8415
  return [state, dispatch];
8416
8416
  };
8417
8417
 
8418
+ const OVERSCAN = 10;
8419
+ const DEFAULT_ROW_HEIGHT = 50;
8420
+ /**
8421
+ * Bidirectional infinite scroll hook that supports both forward (nextPage)
8422
+ * and backward (previousPage) pagination with TanStack Virtual.
8423
+ *
8424
+ * Unlike `useInfiniteScroll` which only loads forward, this hook detects
8425
+ * scroll position at both ends and triggers the appropriate pagination
8426
+ * direction.
8427
+ */
8428
+ const useBidirectionalScroll = ({ pagination, scrollElementRef, count, estimateSize, overscan, onChange, skip = false, onTopItemChange, }) => {
8429
+ "use no memo"; //! TODO: remove this once Tanstack Virtual is updated to support React Compiler
8430
+ const onTopItemChangeRef = react.useRef(onTopItemChange);
8431
+ react.useEffect(() => {
8432
+ onTopItemChangeRef.current = onTopItemChange;
8433
+ }, [onTopItemChange]);
8434
+ const lastReportedTopIndex = react.useRef(-1);
8435
+ const handleChange = react.useCallback((virtualizer) => {
8436
+ onChange?.(virtualizer);
8437
+ const scrollOffset = virtualizer.scrollOffset ?? 0;
8438
+ const firstVisibleItem = virtualizer.getVirtualItems().find(item => item.start + item.size > scrollOffset);
8439
+ if (firstVisibleItem !== undefined && firstVisibleItem.index !== lastReportedTopIndex.current) {
8440
+ lastReportedTopIndex.current = firstVisibleItem.index;
8441
+ onTopItemChangeRef.current?.(firstVisibleItem.index);
8442
+ }
8443
+ }, [onChange]);
8444
+ const handleEstimateSize = react.useCallback((index) => estimateSize?.(index) ?? DEFAULT_ROW_HEIGHT, [estimateSize]);
8445
+ //! TODO: remove this once Tanstack Virtual is updated to support React Compiler
8446
+ // eslint-disable-next-line react-hooks/incompatible-library
8447
+ const rowVirtualizer = reactVirtual.useVirtualizer({
8448
+ count,
8449
+ getScrollElement: () => scrollElementRef.current,
8450
+ estimateSize: handleEstimateSize,
8451
+ overscan: overscan ?? OVERSCAN,
8452
+ onChange: handleChange,
8453
+ useAnimationFrameWithResizeObserver: true,
8454
+ });
8455
+ const virtualItems = rowVirtualizer.getVirtualItems();
8456
+ // Track count before backward fetch so we can offset scroll position
8457
+ // after items are prepended, preventing an infinite backward-load loop.
8458
+ const prevCountRef = react.useRef(count);
8459
+ const isLoadingBackwardRef = react.useRef(false);
8460
+ react.useEffect(() => {
8461
+ if (isLoadingBackwardRef.current && !pagination.isLoading && count > prevCountRef.current) {
8462
+ const prependedCount = count - prevCountRef.current;
8463
+ const container = scrollElementRef.current;
8464
+ if (container) {
8465
+ const estimatedRowHeight = estimateSize?.(0) ?? DEFAULT_ROW_HEIGHT;
8466
+ container.scrollTop += prependedCount * estimatedRowHeight;
8467
+ }
8468
+ isLoadingBackwardRef.current = false;
8469
+ }
8470
+ prevCountRef.current = count;
8471
+ }, [count, pagination.isLoading, scrollElementRef, estimateSize]);
8472
+ // Forward loading: scroll reached bottom or insufficient content
8473
+ react.useEffect(() => {
8474
+ if (skip || pagination.pageInfo?.hasNextPage !== true || pagination.isLoading) {
8475
+ return;
8476
+ }
8477
+ const container = scrollElementRef.current;
8478
+ if (!container) {
8479
+ return;
8480
+ }
8481
+ const [lastItem] = [...virtualItems].reverse();
8482
+ const shouldLoadFromScroll = lastItem !== undefined && lastItem.index >= count - 1;
8483
+ const shouldLoadFromInsufficientContent = container.scrollHeight <= container.clientHeight;
8484
+ if (shouldLoadFromScroll || shouldLoadFromInsufficientContent) {
8485
+ pagination.nextPage();
8486
+ }
8487
+ }, [
8488
+ skip,
8489
+ pagination.pageInfo?.hasNextPage,
8490
+ pagination.nextPage,
8491
+ pagination.isLoading,
8492
+ pagination,
8493
+ scrollElementRef,
8494
+ virtualItems,
8495
+ count,
8496
+ ]);
8497
+ // Backward loading: fires when the scroll container is at the very top.
8498
+ // Uses scrollTop instead of virtualItems[0].index because after data
8499
+ // prepends the scroll-offset effect adjusts scrollTop away from 0,
8500
+ // which correctly prevents re-triggering. virtualItems[0].index would
8501
+ // still be 0 (overscan always renders from the start) causing a loop.
8502
+ react.useEffect(() => {
8503
+ if (skip || pagination.pageInfo?.hasPreviousPage !== true || pagination.isLoading) {
8504
+ return;
8505
+ }
8506
+ const container = scrollElementRef.current;
8507
+ if (container !== null && container.scrollTop === 0) {
8508
+ isLoadingBackwardRef.current = true;
8509
+ pagination.previousPage();
8510
+ }
8511
+ }, [
8512
+ skip,
8513
+ pagination.pageInfo?.hasPreviousPage,
8514
+ pagination.previousPage,
8515
+ pagination.isLoading,
8516
+ pagination,
8517
+ scrollElementRef,
8518
+ virtualItems,
8519
+ ]);
8520
+ return rowVirtualizer;
8521
+ };
8522
+
8418
8523
  /**
8419
8524
  * Custom hook to handle click outside events.
8420
8525
  *
@@ -8603,6 +8708,53 @@ const useContinuousTimeout = ({ onTimeout, onMaxRetries, duration, maxRetries, }
8603
8708
  }), [startTimeouts, stopTimeouts, isRunning]);
8604
8709
  };
8605
8710
 
8711
+ const DEFAULT_PARAM_NAME = "cursor";
8712
+ const DEFAULT_DEBOUNCE_MS = 300;
8713
+ /**
8714
+ * Reads a cursor from the URL search params on mount and provides a debounced
8715
+ * updater for writing it back. Uses `replace: true` to avoid polluting browser
8716
+ * history with every scroll position change.
8717
+ *
8718
+ * Wire `initialCursor` into `usePaginationQuery` and `updateCursor` into
8719
+ * `useBidirectionalScroll`'s `onTopItemChange` callback (mapping index to cursor).
8720
+ */
8721
+ const useCursorUrlSync = ({ paramName = DEFAULT_PARAM_NAME, debounceMs = DEFAULT_DEBOUNCE_MS, } = {}) => {
8722
+ const search = reactRouter.useSearch({ strict: false });
8723
+ const navigate = reactRouter.useNavigate();
8724
+ const [initialCursor] = react.useState(() => {
8725
+ const entry = Object.entries(search).find(([key]) => key === paramName);
8726
+ return typeof entry?.[1] === "string" ? entry[1] : undefined;
8727
+ });
8728
+ const timerRef = react.useRef(undefined);
8729
+ const paramNameRef = react.useRef(paramName);
8730
+ react.useEffect(() => {
8731
+ paramNameRef.current = paramName;
8732
+ }, [paramName]);
8733
+ const updateCursor = react.useCallback((cursor) => {
8734
+ if (timerRef.current !== undefined) {
8735
+ clearTimeout(timerRef.current);
8736
+ }
8737
+ timerRef.current = setTimeout(() => {
8738
+ void navigate({
8739
+ to: ".",
8740
+ search: (prev) => ({
8741
+ ...prev,
8742
+ [paramNameRef.current]: cursor,
8743
+ }),
8744
+ replace: true,
8745
+ });
8746
+ }, debounceMs);
8747
+ }, [debounceMs, navigate]);
8748
+ react.useEffect(() => {
8749
+ return () => {
8750
+ if (timerRef.current !== undefined) {
8751
+ clearTimeout(timerRef.current);
8752
+ }
8753
+ };
8754
+ }, []);
8755
+ return react.useMemo(() => ({ initialCursor, updateCursor }), [initialCursor, updateCursor]);
8756
+ };
8757
+
8606
8758
  // Copied from https://github.com/rexxars/use-device-pixel-ratio
8607
8759
  /**
8608
8760
  * Get the device pixel ratio, potentially rounded and capped.
@@ -9263,9 +9415,12 @@ const defaultPageSize = 50;
9263
9415
  * @param {RelayPaginationProps} props - The props object containing pagination configuration.
9264
9416
  * @returns {RelayPaginationSupport} An object containing functions and state for managing Relay pagination.
9265
9417
  */
9266
- const useRelayPagination = ({ onReset, pageSize } = { pageSize: defaultPageSize }) => {
9267
- const [variables, setVariables] = react.useState({ first: pageSize });
9268
- const [pageInfo, setPageInfo] = react.useState();
9418
+ const useRelayPagination = ({ onReset, pageSize, initialCursor } = { pageSize: defaultPageSize }) => {
9419
+ const [variables, setVariables] = react.useState(() => ({
9420
+ first: pageSize,
9421
+ after: initialCursor,
9422
+ }));
9423
+ const [pageInfo, setPageInfo] = react.useState(() => initialCursor !== undefined ? { hasPreviousPage: true } : null);
9269
9424
  const [isLoading, setIsLoading] = react.useState(true);
9270
9425
  // Destructure pageInfo properties early to avoid depending on the entire object
9271
9426
  const { hasNextPage, endCursor, hasPreviousPage, startCursor } = pageInfo ?? {};
@@ -9725,10 +9880,12 @@ exports.iconColorNames = iconColorNames;
9725
9880
  exports.iconPalette = iconPalette;
9726
9881
  exports.noPagination = noPagination;
9727
9882
  exports.preferenceCardGrid = preferenceCardGrid;
9883
+ exports.useBidirectionalScroll = useBidirectionalScroll;
9728
9884
  exports.useClickOutside = useClickOutside;
9729
9885
  exports.useContainerBreakpoints = useContainerBreakpoints;
9730
9886
  exports.useContinuousTimeout = useContinuousTimeout;
9731
9887
  exports.useCopyToClipboard = useCopyToClipboard;
9888
+ exports.useCursorUrlSync = useCursorUrlSync;
9732
9889
  exports.useCustomEncoding = useCustomEncoding;
9733
9890
  exports.useDebounce = useDebounce;
9734
9891
  exports.useDevicePixelRatio = useDevicePixelRatio;
package/index.esm.js CHANGED
@@ -10,7 +10,7 @@ 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 { Slot, Slottable } from '@radix-ui/react-slot';
13
- import { Link, useBlocker } from '@tanstack/react-router';
13
+ import { Link, useBlocker, useSearch, useNavigate } from '@tanstack/react-router';
14
14
  import { isEqual, omit } from 'es-toolkit';
15
15
  import { twMerge } from 'tailwind-merge';
16
16
  import { useFloating, offset, flip, shift, size, autoUpdate, useClick, useDismiss, useHover as useHover$1, safePolygon, useRole, useInteractions, FloatingPortal, useMergeRefs as useMergeRefs$1, FloatingFocusManager, arrow, useTransitionStatus, FloatingArrow } from '@floating-ui/react';
@@ -2206,7 +2206,7 @@ const Collapse = ({ id, variant = "primary", initialExpanded = false, onToggle,
2206
2206
  }
2207
2207
  setExpanded(!expanded);
2208
2208
  }, [expanded, onToggle]);
2209
- return (jsxs("div", { className: cvaCollapse({ variant: variant, className }), "data-testid": dataTestId, ref: ref, children: [jsx("div", { "aria-controls": id, "aria-expanded": expanded, className: cvaCollapseHeader({ expanded, variant, extraPadding, className: headerClassName }), onClick: handleClick, role: "button", children: jsxs("div", { className: cvaCollapseLabelContainer({ variant }), children: [jsx(Text, { className: cvaCollapseLabel({ variant }), id: LABEL_ID, size: variant === "secondary" ? "small" : "medium", type: "span", weight: "bold", children: label }), jsxs("div", { className: "flex items-center gap-2", children: [headerAddon !== null && headerAddon !== undefined && variant !== "secondary" ? headerAddon : null, jsx(Icon, { ariaLabelledBy: LABEL_ID, className: cvaChevronIcon({ expanded }), name: "ChevronUp", size: variant === "secondary" ? "small" : "medium" })] })] }) }), jsx(Collapsible, { expanded: expanded, extraPadding: extraPadding, id: id, variant: variant, children: expanded || animate ? children : null })] }));
2209
+ return (jsxs("div", { className: cvaCollapse({ variant: variant, className }), "data-testid": dataTestId, ref: ref, children: [jsx("div", { "aria-controls": id, "aria-expanded": expanded, className: cvaCollapseHeader({ expanded, variant, extraPadding, className: headerClassName }), onClick: handleClick, role: "button", children: jsxs("div", { className: cvaCollapseLabelContainer({ variant }), children: [jsx(Text, { className: cvaCollapseLabel({ variant }), id: LABEL_ID, size: variant === "secondary" ? "small" : "medium", type: "span", weight: "bold", children: label }), jsxs("div", { className: "flex items-center gap-2", children: [headerAddon !== null && headerAddon !== undefined && variant !== "secondary" ? headerAddon : null, jsx(Icon, { ariaLabelledBy: LABEL_ID, className: cvaChevronIcon({ expanded }), name: "ChevronDown", size: variant === "secondary" ? "small" : "medium" })] })] }) }), jsx(Collapsible, { expanded: expanded, extraPadding: extraPadding, id: id, variant: variant, children: expanded || animate ? children : null })] }));
2210
2210
  };
2211
2211
  const Collapsible = ({ children, expanded, id, variant, extraPadding }) => {
2212
2212
  const { geometry, ref } = useMeasure();
@@ -5532,8 +5532,8 @@ const noPagination = {
5532
5532
  },
5533
5533
  };
5534
5534
 
5535
- const OVERSCAN = 10;
5536
- const DEFAULT_ROW_HEIGHT = 50;
5535
+ const OVERSCAN$1 = 10;
5536
+ const DEFAULT_ROW_HEIGHT$1 = 50;
5537
5537
  /**
5538
5538
  * Custom hook for implementing infinite scrolling in a table using TanStack Virtual.
5539
5539
  *
@@ -5563,14 +5563,14 @@ const useInfiniteScroll = ({ pagination, scrollElementRef, count, estimateSize,
5563
5563
  const handleChange = useCallback((virtualizer) => {
5564
5564
  onChange?.(virtualizer);
5565
5565
  }, [onChange]);
5566
- const handleEstimateSize = useCallback((index) => estimateSize?.(index) ?? DEFAULT_ROW_HEIGHT, [estimateSize]);
5566
+ const handleEstimateSize = useCallback((index) => estimateSize?.(index) ?? DEFAULT_ROW_HEIGHT$1, [estimateSize]);
5567
5567
  //! TODO: remove this once Tanstack Virtual is updated to support React Compiler
5568
5568
  // eslint-disable-next-line react-hooks/incompatible-library
5569
5569
  const rowVirtualizer = useVirtualizer({
5570
5570
  count,
5571
5571
  getScrollElement: () => scrollElementRef.current,
5572
5572
  estimateSize: handleEstimateSize,
5573
- overscan: overscan ?? OVERSCAN,
5573
+ overscan: overscan ?? OVERSCAN$1,
5574
5574
  onChange: handleChange,
5575
5575
  // This option enables wrapping ResizeObserver measurements in requestAnimationFrame for smoother updates and reduced layout thrashing.
5576
5576
  // It helps prevent the "ResizeObserver loop completed with undelivered notifications" error by ensuring that measurements align with the rendering cycle
@@ -8413,6 +8413,111 @@ const useLocalStorageReducer = ({ key, defaultState, reducer, schema, onValidati
8413
8413
  return [state, dispatch];
8414
8414
  };
8415
8415
 
8416
+ const OVERSCAN = 10;
8417
+ const DEFAULT_ROW_HEIGHT = 50;
8418
+ /**
8419
+ * Bidirectional infinite scroll hook that supports both forward (nextPage)
8420
+ * and backward (previousPage) pagination with TanStack Virtual.
8421
+ *
8422
+ * Unlike `useInfiniteScroll` which only loads forward, this hook detects
8423
+ * scroll position at both ends and triggers the appropriate pagination
8424
+ * direction.
8425
+ */
8426
+ const useBidirectionalScroll = ({ pagination, scrollElementRef, count, estimateSize, overscan, onChange, skip = false, onTopItemChange, }) => {
8427
+ "use no memo"; //! TODO: remove this once Tanstack Virtual is updated to support React Compiler
8428
+ const onTopItemChangeRef = useRef(onTopItemChange);
8429
+ useEffect(() => {
8430
+ onTopItemChangeRef.current = onTopItemChange;
8431
+ }, [onTopItemChange]);
8432
+ const lastReportedTopIndex = useRef(-1);
8433
+ const handleChange = useCallback((virtualizer) => {
8434
+ onChange?.(virtualizer);
8435
+ const scrollOffset = virtualizer.scrollOffset ?? 0;
8436
+ const firstVisibleItem = virtualizer.getVirtualItems().find(item => item.start + item.size > scrollOffset);
8437
+ if (firstVisibleItem !== undefined && firstVisibleItem.index !== lastReportedTopIndex.current) {
8438
+ lastReportedTopIndex.current = firstVisibleItem.index;
8439
+ onTopItemChangeRef.current?.(firstVisibleItem.index);
8440
+ }
8441
+ }, [onChange]);
8442
+ const handleEstimateSize = useCallback((index) => estimateSize?.(index) ?? DEFAULT_ROW_HEIGHT, [estimateSize]);
8443
+ //! TODO: remove this once Tanstack Virtual is updated to support React Compiler
8444
+ // eslint-disable-next-line react-hooks/incompatible-library
8445
+ const rowVirtualizer = useVirtualizer({
8446
+ count,
8447
+ getScrollElement: () => scrollElementRef.current,
8448
+ estimateSize: handleEstimateSize,
8449
+ overscan: overscan ?? OVERSCAN,
8450
+ onChange: handleChange,
8451
+ useAnimationFrameWithResizeObserver: true,
8452
+ });
8453
+ const virtualItems = rowVirtualizer.getVirtualItems();
8454
+ // Track count before backward fetch so we can offset scroll position
8455
+ // after items are prepended, preventing an infinite backward-load loop.
8456
+ const prevCountRef = useRef(count);
8457
+ const isLoadingBackwardRef = useRef(false);
8458
+ useEffect(() => {
8459
+ if (isLoadingBackwardRef.current && !pagination.isLoading && count > prevCountRef.current) {
8460
+ const prependedCount = count - prevCountRef.current;
8461
+ const container = scrollElementRef.current;
8462
+ if (container) {
8463
+ const estimatedRowHeight = estimateSize?.(0) ?? DEFAULT_ROW_HEIGHT;
8464
+ container.scrollTop += prependedCount * estimatedRowHeight;
8465
+ }
8466
+ isLoadingBackwardRef.current = false;
8467
+ }
8468
+ prevCountRef.current = count;
8469
+ }, [count, pagination.isLoading, scrollElementRef, estimateSize]);
8470
+ // Forward loading: scroll reached bottom or insufficient content
8471
+ useEffect(() => {
8472
+ if (skip || pagination.pageInfo?.hasNextPage !== true || pagination.isLoading) {
8473
+ return;
8474
+ }
8475
+ const container = scrollElementRef.current;
8476
+ if (!container) {
8477
+ return;
8478
+ }
8479
+ const [lastItem] = [...virtualItems].reverse();
8480
+ const shouldLoadFromScroll = lastItem !== undefined && lastItem.index >= count - 1;
8481
+ const shouldLoadFromInsufficientContent = container.scrollHeight <= container.clientHeight;
8482
+ if (shouldLoadFromScroll || shouldLoadFromInsufficientContent) {
8483
+ pagination.nextPage();
8484
+ }
8485
+ }, [
8486
+ skip,
8487
+ pagination.pageInfo?.hasNextPage,
8488
+ pagination.nextPage,
8489
+ pagination.isLoading,
8490
+ pagination,
8491
+ scrollElementRef,
8492
+ virtualItems,
8493
+ count,
8494
+ ]);
8495
+ // Backward loading: fires when the scroll container is at the very top.
8496
+ // Uses scrollTop instead of virtualItems[0].index because after data
8497
+ // prepends the scroll-offset effect adjusts scrollTop away from 0,
8498
+ // which correctly prevents re-triggering. virtualItems[0].index would
8499
+ // still be 0 (overscan always renders from the start) causing a loop.
8500
+ useEffect(() => {
8501
+ if (skip || pagination.pageInfo?.hasPreviousPage !== true || pagination.isLoading) {
8502
+ return;
8503
+ }
8504
+ const container = scrollElementRef.current;
8505
+ if (container !== null && container.scrollTop === 0) {
8506
+ isLoadingBackwardRef.current = true;
8507
+ pagination.previousPage();
8508
+ }
8509
+ }, [
8510
+ skip,
8511
+ pagination.pageInfo?.hasPreviousPage,
8512
+ pagination.previousPage,
8513
+ pagination.isLoading,
8514
+ pagination,
8515
+ scrollElementRef,
8516
+ virtualItems,
8517
+ ]);
8518
+ return rowVirtualizer;
8519
+ };
8520
+
8416
8521
  /**
8417
8522
  * Custom hook to handle click outside events.
8418
8523
  *
@@ -8601,6 +8706,53 @@ const useContinuousTimeout = ({ onTimeout, onMaxRetries, duration, maxRetries, }
8601
8706
  }), [startTimeouts, stopTimeouts, isRunning]);
8602
8707
  };
8603
8708
 
8709
+ const DEFAULT_PARAM_NAME = "cursor";
8710
+ const DEFAULT_DEBOUNCE_MS = 300;
8711
+ /**
8712
+ * Reads a cursor from the URL search params on mount and provides a debounced
8713
+ * updater for writing it back. Uses `replace: true` to avoid polluting browser
8714
+ * history with every scroll position change.
8715
+ *
8716
+ * Wire `initialCursor` into `usePaginationQuery` and `updateCursor` into
8717
+ * `useBidirectionalScroll`'s `onTopItemChange` callback (mapping index to cursor).
8718
+ */
8719
+ const useCursorUrlSync = ({ paramName = DEFAULT_PARAM_NAME, debounceMs = DEFAULT_DEBOUNCE_MS, } = {}) => {
8720
+ const search = useSearch({ strict: false });
8721
+ const navigate = useNavigate();
8722
+ const [initialCursor] = useState(() => {
8723
+ const entry = Object.entries(search).find(([key]) => key === paramName);
8724
+ return typeof entry?.[1] === "string" ? entry[1] : undefined;
8725
+ });
8726
+ const timerRef = useRef(undefined);
8727
+ const paramNameRef = useRef(paramName);
8728
+ useEffect(() => {
8729
+ paramNameRef.current = paramName;
8730
+ }, [paramName]);
8731
+ const updateCursor = useCallback((cursor) => {
8732
+ if (timerRef.current !== undefined) {
8733
+ clearTimeout(timerRef.current);
8734
+ }
8735
+ timerRef.current = setTimeout(() => {
8736
+ void navigate({
8737
+ to: ".",
8738
+ search: (prev) => ({
8739
+ ...prev,
8740
+ [paramNameRef.current]: cursor,
8741
+ }),
8742
+ replace: true,
8743
+ });
8744
+ }, debounceMs);
8745
+ }, [debounceMs, navigate]);
8746
+ useEffect(() => {
8747
+ return () => {
8748
+ if (timerRef.current !== undefined) {
8749
+ clearTimeout(timerRef.current);
8750
+ }
8751
+ };
8752
+ }, []);
8753
+ return useMemo(() => ({ initialCursor, updateCursor }), [initialCursor, updateCursor]);
8754
+ };
8755
+
8604
8756
  // Copied from https://github.com/rexxars/use-device-pixel-ratio
8605
8757
  /**
8606
8758
  * Get the device pixel ratio, potentially rounded and capped.
@@ -9261,9 +9413,12 @@ const defaultPageSize = 50;
9261
9413
  * @param {RelayPaginationProps} props - The props object containing pagination configuration.
9262
9414
  * @returns {RelayPaginationSupport} An object containing functions and state for managing Relay pagination.
9263
9415
  */
9264
- const useRelayPagination = ({ onReset, pageSize } = { pageSize: defaultPageSize }) => {
9265
- const [variables, setVariables] = useState({ first: pageSize });
9266
- const [pageInfo, setPageInfo] = useState();
9416
+ const useRelayPagination = ({ onReset, pageSize, initialCursor } = { pageSize: defaultPageSize }) => {
9417
+ const [variables, setVariables] = useState(() => ({
9418
+ first: pageSize,
9419
+ after: initialCursor,
9420
+ }));
9421
+ const [pageInfo, setPageInfo] = useState(() => initialCursor !== undefined ? { hasPreviousPage: true } : null);
9267
9422
  const [isLoading, setIsLoading] = useState(true);
9268
9423
  // Destructure pageInfo properties early to avoid depending on the entire object
9269
9424
  const { hasNextPage, endCursor, hasPreviousPage, startCursor } = pageInfo ?? {};
@@ -9595,4 +9750,4 @@ const useWindowActivity = ({ onFocus, onBlur, skip = false } = { onBlur: undefin
9595
9750
  return useMemo(() => ({ focused }), [focused]);
9596
9751
  };
9597
9752
 
9598
- export { ActionRenderer, Alert, Badge, Breadcrumb, BreadcrumbContainer, Button, Card, CardBody, CardFooter, CardHeader, Collapse, CompletionStatusIndicator, CopyableText, DEFAULT_SKELETON_PREFERENCE_CARD_PROPS, DetailsList, EmptyState, EmptyValue, ExternalLink, GridAreas, Heading, Highlight, HorizontalOverflowScroller, Icon, IconButton, Indicator, KPI, KPICard, KPICardSkeleton, KPISkeleton, List, ListItem, MenuDivider, MenuItem, MenuList, MoreMenu, Notice, PackageNameStoryComponent, Page, PageContent, PageHeader, PageHeaderKpiMetrics, PageHeaderSecondaryActions, PageHeaderTitle, Pagination, Polygon, Popover, PopoverContent, PopoverTitle, PopoverTrigger, Portal, PreferenceCard, PreferenceCardSkeleton, Prompt, ROLE_CARD, SectionHeader, SegmentedValueBar, Sidebar, SkeletonBlock, SkeletonLabel, SkeletonLines, Spacer, Spinner, StarButton, Tab, TabContent, TabList, Tabs, Tag, Text, ToggleGroup, Tooltip, TrendIndicator, TrendIndicators, ValueBar, ZStack, createGrid, cvaButton, cvaButtonPrefixSuffix, cvaButtonSpinner, cvaButtonSpinnerContainer, cvaClickable, cvaContainerStyles, cvaContentContainer, cvaContentWrapper, cvaDescriptionCard, cvaIconBackground, cvaIconButton, cvaImgStyles, cvaIndicator, cvaIndicatorIcon, cvaIndicatorIconBackground, cvaIndicatorLabel, cvaIndicatorPing, cvaInputContainer, cvaInteractableItem, cvaList, cvaListContainer, cvaListItem$1 as cvaListItem, cvaMenu, cvaMenuItem, cvaMenuItemLabel, cvaMenuItemPrefix, cvaMenuItemStyle, cvaMenuItemSuffix, cvaMenuList, cvaMenuListDivider, cvaMenuListItem, cvaMenuListMultiSelect, cvaPageHeader, cvaPageHeaderContainer, cvaPageHeaderHeading, cvaPreferenceCard, cvaTitleCard, cvaToggleGroup, cvaToggleGroupWithSlidingBackground, cvaToggleItem, cvaToggleItemContent, cvaToggleItemText, cvaZStackContainer, cvaZStackItem, defaultPageSize, docs, getDevicePixelRatio, getValueBarColorByValue, iconColorNames, iconPalette, noPagination, preferenceCardGrid, useClickOutside, useContainerBreakpoints, useContinuousTimeout, useCopyToClipboard, useCustomEncoding, useDebounce, useDevicePixelRatio, useElevatedReducer, useElevatedState, useGeolocation, useGridAreas, useHover, useInfiniteScroll, useIsFirstRender, useIsFullscreen, useIsTextTruncated, useKeyboardShortcut, useList, useListItemHeight, useLocalStorage, useLocalStorageReducer, useMeasure, useMergeRefs, useModifierKey, useOverflowItems, usePopoverContext, usePrevious, usePrompt, useRandomCSSLengths, useRelayPagination, useResize, useScrollBlock, useScrollDetection, useSelfUpdatingRef, useTextSearch, useTimeout, useViewportBreakpoints, useWatch, useWindowActivity };
9753
+ export { ActionRenderer, Alert, Badge, Breadcrumb, BreadcrumbContainer, Button, Card, CardBody, CardFooter, CardHeader, Collapse, CompletionStatusIndicator, CopyableText, DEFAULT_SKELETON_PREFERENCE_CARD_PROPS, DetailsList, EmptyState, EmptyValue, ExternalLink, GridAreas, Heading, Highlight, HorizontalOverflowScroller, Icon, IconButton, Indicator, KPI, KPICard, KPICardSkeleton, KPISkeleton, List, ListItem, MenuDivider, MenuItem, MenuList, MoreMenu, Notice, PackageNameStoryComponent, Page, PageContent, PageHeader, PageHeaderKpiMetrics, PageHeaderSecondaryActions, PageHeaderTitle, Pagination, Polygon, Popover, PopoverContent, PopoverTitle, PopoverTrigger, Portal, PreferenceCard, PreferenceCardSkeleton, Prompt, ROLE_CARD, SectionHeader, SegmentedValueBar, Sidebar, SkeletonBlock, SkeletonLabel, SkeletonLines, Spacer, Spinner, StarButton, Tab, TabContent, TabList, Tabs, Tag, Text, ToggleGroup, Tooltip, TrendIndicator, TrendIndicators, ValueBar, ZStack, createGrid, cvaButton, cvaButtonPrefixSuffix, cvaButtonSpinner, cvaButtonSpinnerContainer, cvaClickable, cvaContainerStyles, cvaContentContainer, cvaContentWrapper, cvaDescriptionCard, cvaIconBackground, cvaIconButton, cvaImgStyles, cvaIndicator, cvaIndicatorIcon, cvaIndicatorIconBackground, cvaIndicatorLabel, cvaIndicatorPing, cvaInputContainer, cvaInteractableItem, cvaList, cvaListContainer, cvaListItem$1 as cvaListItem, cvaMenu, cvaMenuItem, cvaMenuItemLabel, cvaMenuItemPrefix, cvaMenuItemStyle, cvaMenuItemSuffix, cvaMenuList, cvaMenuListDivider, cvaMenuListItem, cvaMenuListMultiSelect, cvaPageHeader, cvaPageHeaderContainer, cvaPageHeaderHeading, cvaPreferenceCard, cvaTitleCard, cvaToggleGroup, cvaToggleGroupWithSlidingBackground, cvaToggleItem, cvaToggleItemContent, cvaToggleItemText, cvaZStackContainer, cvaZStackItem, defaultPageSize, docs, getDevicePixelRatio, getValueBarColorByValue, iconColorNames, iconPalette, noPagination, preferenceCardGrid, useBidirectionalScroll, useClickOutside, useContainerBreakpoints, useContinuousTimeout, useCopyToClipboard, useCursorUrlSync, useCustomEncoding, useDebounce, useDevicePixelRatio, useElevatedReducer, useElevatedState, useGeolocation, useGridAreas, useHover, useInfiniteScroll, useIsFirstRender, useIsFullscreen, useIsTextTruncated, useKeyboardShortcut, useList, useListItemHeight, useLocalStorage, useLocalStorageReducer, useMeasure, useMergeRefs, useModifierKey, useOverflowItems, usePopoverContext, usePrevious, usePrompt, useRandomCSSLengths, useRelayPagination, useResize, useScrollBlock, useScrollDetection, useSelfUpdatingRef, useTextSearch, useTimeout, useViewportBreakpoints, useWatch, useWindowActivity };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-components",
3
- "version": "1.17.49",
3
+ "version": "1.18.1",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -0,0 +1,23 @@
1
+ import { type Virtualizer } from "@tanstack/react-virtual";
2
+ import { type RefObject } from "react";
3
+ import type { RelayPagination } from "./useRelayPagination";
4
+ interface BidirectionalScrollProps<TScrollElement extends Element, TItemElement extends Element> {
5
+ readonly pagination: RelayPagination;
6
+ readonly scrollElementRef: RefObject<TScrollElement | null>;
7
+ readonly count: number;
8
+ readonly estimateSize?: (index: number) => number;
9
+ readonly overscan?: number;
10
+ readonly onChange?: (virtualizer: Virtualizer<TScrollElement, TItemElement>) => void;
11
+ readonly skip?: boolean;
12
+ readonly onTopItemChange?: (index: number) => void;
13
+ }
14
+ /**
15
+ * Bidirectional infinite scroll hook that supports both forward (nextPage)
16
+ * and backward (previousPage) pagination with TanStack Virtual.
17
+ *
18
+ * Unlike `useInfiniteScroll` which only loads forward, this hook detects
19
+ * scroll position at both ends and triggers the appropriate pagination
20
+ * direction.
21
+ */
22
+ export declare const useBidirectionalScroll: <TScrollElement extends Element, TItemElement extends Element>({ pagination, scrollElementRef, count, estimateSize, overscan, onChange, skip, onTopItemChange, }: BidirectionalScrollProps<TScrollElement, TItemElement>) => Virtualizer<TScrollElement, TItemElement>;
23
+ export {};
@@ -0,0 +1,18 @@
1
+ type UseCursorUrlSyncOptions = {
2
+ readonly paramName?: string;
3
+ readonly debounceMs?: number;
4
+ };
5
+ type UseCursorUrlSyncResult = {
6
+ readonly initialCursor: string | undefined;
7
+ readonly updateCursor: (cursor: string | undefined) => void;
8
+ };
9
+ /**
10
+ * Reads a cursor from the URL search params on mount and provides a debounced
11
+ * updater for writing it back. Uses `replace: true` to avoid polluting browser
12
+ * history with every scroll position change.
13
+ *
14
+ * Wire `initialCursor` into `usePaginationQuery` and `updateCursor` into
15
+ * `useBidirectionalScroll`'s `onTopItemChange` callback (mapping index to cursor).
16
+ */
17
+ export declare const useCursorUrlSync: ({ paramName, debounceMs, }?: UseCursorUrlSyncOptions) => UseCursorUrlSyncResult;
18
+ export {};
@@ -2,6 +2,7 @@ import { Dispatch, SetStateAction } from "react";
2
2
  export interface RelayPaginationProps {
3
3
  pageSize?: number;
4
4
  onReset?: () => void;
5
+ initialCursor?: string;
5
6
  }
6
7
  export interface RelayPaginationQueryVariables {
7
8
  first?: number | null;
@@ -40,4 +41,4 @@ export declare const defaultPageSize = 50;
40
41
  * @param {RelayPaginationProps} props - The props object containing pagination configuration.
41
42
  * @returns {RelayPaginationSupport} An object containing functions and state for managing Relay pagination.
42
43
  */
43
- export declare const useRelayPagination: ({ onReset, pageSize }?: RelayPaginationProps) => RelayPaginationSupport;
44
+ export declare const useRelayPagination: ({ onReset, pageSize, initialCursor }?: RelayPaginationProps) => RelayPaginationSupport;
package/src/index.d.ts CHANGED
@@ -108,10 +108,12 @@ export * from "./hooks/encoding/useCustomEncoding";
108
108
  export * from "./hooks/localStorage/useLocalStorage";
109
109
  export * from "./hooks/localStorage/useLocalStorageReducer";
110
110
  export * from "./hooks/noPagination";
111
+ export * from "./hooks/useBidirectionalScroll";
111
112
  export * from "./hooks/useClickOutside";
112
113
  export * from "./hooks/useContainerBreakpoints";
113
114
  export * from "./hooks/useContinuousTimeout";
114
115
  export * from "./hooks/useCopyToClipboard";
116
+ export * from "./hooks/useCursorUrlSync";
115
117
  export * from "./hooks/useDebounce";
116
118
  export * from "./hooks/useDevicePixelRatio";
117
119
  export * from "./hooks/useElevatedReducer";