@trackunit/react-components 1.9.19 → 1.9.21

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
@@ -12,12 +12,12 @@ var IconSpriteSolid = require('@trackunit/ui-icons/icons-sprite-solid.svg');
12
12
  var stringTs = require('string-ts');
13
13
  var cssClassVarianceUtilities = require('@trackunit/css-class-variance-utilities');
14
14
  var reactSlot = require('@radix-ui/react-slot');
15
+ var reactVirtual = require('@tanstack/react-virtual');
15
16
  var usehooksTs = require('usehooks-ts');
16
17
  var reactRouter = require('@tanstack/react-router');
17
18
  var react$1 = require('@floating-ui/react');
18
19
  var esToolkit = require('es-toolkit');
19
20
  var tailwindMerge = require('tailwind-merge');
20
- var reactTablePagination = require('@trackunit/react-table-pagination');
21
21
  var reactHelmetAsync = require('react-helmet-async');
22
22
  var reactTabs = require('@radix-ui/react-tabs');
23
23
 
@@ -993,6 +993,21 @@ const Badge = ({ color = "primary", size = "default", compact = false, className
993
993
  return (jsxRuntime.jsx("span", { className: cvaBadge({ color, size, className, compact, isSingleChar }), "data-testid": dataTestId, children: compact ? null : displayedCount }));
994
994
  };
995
995
 
996
+ const noPagination = {
997
+ isLoading: false,
998
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
999
+ nextPage: () => { },
1000
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
1001
+ previousPage: () => { },
1002
+ pageInfo: {
1003
+ count: null,
1004
+ hasNextPage: false,
1005
+ hasPreviousPage: false,
1006
+ endCursor: null,
1007
+ startCursor: null,
1008
+ },
1009
+ };
1010
+
996
1011
  /**
997
1012
  * Custom hook to handle click outside events.
998
1013
  *
@@ -1381,6 +1396,73 @@ const useHover = ({ debounced = false, delay = 100, direction = "out" } = { debo
1381
1396
  return { onMouseEnter, onMouseLeave, hovering: debounced ? debouncedHovering : hovering };
1382
1397
  };
1383
1398
 
1399
+ const OVERSCAN = 10;
1400
+ const DEFAULT_ROW_HEIGHT = 50;
1401
+ /**
1402
+ * Custom hook for implementing infinite scrolling in a table using TanStack Virtual.
1403
+ *
1404
+ * @param props - The configuration object for the infinite scroll hook.
1405
+ * @param props.pagination - The relay pagination object for managing data loading.
1406
+ * @param props.scrollElementRef - Reference to the scrollable container element.
1407
+ * @param props.count - Total number of items to virtualize.
1408
+ * @param props.estimateSize - Optional function to estimate item height.
1409
+ * @param props.overscan - Optional number of items to render outside the visible area.
1410
+ * @param props.onChange - Optional callback when virtualizer changes.
1411
+ * @returns {Virtualizer} The virtualizer instance with all its properties and methods.
1412
+ * @description
1413
+ * This hook is used to implement infinite scrolling in a table. It uses TanStack Virtual's
1414
+ * built-in capabilities for virtualization and automatically loads more data when scrolling
1415
+ * approaches the end of the available content.
1416
+ * @example
1417
+ * const virtualizer = useInfiniteScroll<HTMLDivElement, HTMLDivElement>({
1418
+ * pagination: relayPaginationObject,
1419
+ * scrollElementRef: tableScrollElementRef,
1420
+ * count: 50,
1421
+ * estimateSize: () => 35,
1422
+ * });
1423
+ */
1424
+ const useInfiniteScroll = ({ pagination, scrollElementRef, count, estimateSize, overscan, onChange, }) => {
1425
+ const handleChange = react.useCallback((virtualizer) => {
1426
+ onChange?.(virtualizer);
1427
+ }, [onChange]);
1428
+ const handleEstimateSize = react.useCallback((index) => estimateSize?.(index) ?? DEFAULT_ROW_HEIGHT, [estimateSize]);
1429
+ const rowVirtualizer = reactVirtual.useVirtualizer({
1430
+ count,
1431
+ getScrollElement: () => scrollElementRef.current,
1432
+ estimateSize: handleEstimateSize,
1433
+ overscan: overscan ?? OVERSCAN,
1434
+ onChange: handleChange,
1435
+ });
1436
+ const virtualItems = rowVirtualizer.getVirtualItems();
1437
+ // Auto-load more data based on scroll position and content height
1438
+ react.useEffect(() => {
1439
+ if (pagination.pageInfo?.hasNextPage !== true || pagination.isLoading) {
1440
+ return;
1441
+ }
1442
+ const container = scrollElementRef.current;
1443
+ if (!container) {
1444
+ return;
1445
+ }
1446
+ // Check if we should load more data based on virtual items (scroll-based loading)
1447
+ const [lastItem] = [...virtualItems].reverse();
1448
+ const shouldLoadFromScroll = lastItem !== undefined && lastItem.index >= count - 1;
1449
+ // Check if content is insufficient to fill container (initial loading)
1450
+ const shouldLoadFromInsufficientContent = container.scrollHeight <= container.clientHeight;
1451
+ if (shouldLoadFromScroll || shouldLoadFromInsufficientContent) {
1452
+ pagination.nextPage();
1453
+ }
1454
+ }, [
1455
+ pagination.pageInfo?.hasNextPage,
1456
+ pagination.nextPage,
1457
+ pagination.isLoading,
1458
+ pagination,
1459
+ scrollElementRef,
1460
+ virtualItems,
1461
+ count,
1462
+ ]);
1463
+ return rowVirtualizer;
1464
+ };
1465
+
1384
1466
  /**
1385
1467
  * Differentiate between the first and subsequent renders.
1386
1468
  *
@@ -1483,6 +1565,60 @@ const useModifierKey = ({ exclude = [] } = {}) => {
1483
1565
  return isModifierPressed;
1484
1566
  };
1485
1567
 
1568
+ const defaultPageSize = 50;
1569
+ /**
1570
+ * Custom hook for handling Relay pagination in tables.
1571
+ *
1572
+ * @param {RelayPaginationProps} props - The props object containing pagination configuration.
1573
+ * @returns {RelayPaginationSupport} An object containing functions and state for managing Relay pagination.
1574
+ */
1575
+ const useRelayPagination = ({ onReset, pageSize } = { pageSize: defaultPageSize }) => {
1576
+ const [variables, setVariables] = react.useState({ first: pageSize });
1577
+ const [pageInfo, setPageInfo] = react.useState();
1578
+ const [isLoading, setIsLoading] = react.useState(true);
1579
+ const nextPage = react.useCallback(() => {
1580
+ if (pageInfo?.hasNextPage === true) {
1581
+ setVariables({
1582
+ after: pageInfo.endCursor === null ? undefined : pageInfo.endCursor,
1583
+ first: pageSize,
1584
+ });
1585
+ }
1586
+ }, [pageInfo?.endCursor, pageInfo?.hasNextPage, pageSize]);
1587
+ const previousPage = react.useCallback(() => {
1588
+ if (pageInfo?.hasPreviousPage === true) {
1589
+ setVariables({
1590
+ before: pageInfo.startCursor === null ? undefined : pageInfo.startCursor,
1591
+ last: pageSize,
1592
+ });
1593
+ }
1594
+ }, [pageInfo?.hasPreviousPage, pageInfo?.startCursor, pageSize]);
1595
+ const reset = react.useCallback(() => {
1596
+ setVariables({
1597
+ last: undefined,
1598
+ before: undefined,
1599
+ after: undefined,
1600
+ first: pageSize,
1601
+ });
1602
+ if (onReset) {
1603
+ onReset();
1604
+ }
1605
+ }, [onReset, pageSize, setVariables]);
1606
+ return react.useMemo(() => {
1607
+ return {
1608
+ variables,
1609
+ table: {
1610
+ nextPage,
1611
+ previousPage,
1612
+ isLoading,
1613
+ setIsLoading,
1614
+ reset,
1615
+ pageInfo: pageInfo === null ? undefined : pageInfo,
1616
+ setPageInfo,
1617
+ },
1618
+ };
1619
+ }, [variables, nextPage, previousPage, isLoading, reset, pageInfo]);
1620
+ };
1621
+
1486
1622
  /**
1487
1623
  * Custom hook to handle window resize events and provide the current window size.
1488
1624
  *
@@ -3333,7 +3469,7 @@ const ListItemSkeleton = ({ hasThumbnail = DEFAULT_SKELETON_LIST_ITEM_PROPS.hasT
3333
3469
  return (jsxRuntime.jsxs("div", { className: cvaListItem$1({ className: "w-full" }), children: [jsxRuntime.jsxs("div", { className: cvaMainInformationClass({ hasThumbnail, className: "w-full" }), children: [hasThumbnail ? (jsxRuntime.jsx("div", { className: cvaThumbnailContainer({ className: "bg-gray-200" }), children: jsxRuntime.jsx("div", { className: tailwindMerge.twMerge("bg-gray-300", thumbnailShape === "circle" ? "rounded-full" : "rounded"), style: { width: 20, height: 20 } }) })) : null, jsxRuntime.jsxs("div", { className: "grid-rows-min-fr grid w-full items-center gap-1 text-sm", children: [jsxRuntime.jsx(SkeletonLines, { height: "0.875em", lines: 1, width: lineWidths.title }), hasDescription ? jsxRuntime.jsx(SkeletonLines, { height: "0.75em", lines: 1, width: lineWidths.description }) : null, hasMeta ? jsxRuntime.jsx(SkeletonLines, { height: "0.75em", lines: 1, width: lineWidths.meta }) : null] })] }), hasDetails ? (jsxRuntime.jsx("div", { className: "pl-2 text-sm", children: jsxRuntime.jsx(SkeletonLines, { height: "0.875em", lines: 1, width: lineWidths.details }) })) : null] }));
3334
3470
  };
3335
3471
 
3336
- const cvaListContainer = cssClassVarianceUtilities.cvaMerge(["overflow-auto", "h-full"], {
3472
+ const cvaListContainer = cssClassVarianceUtilities.cvaMerge(["overflow-y-auto", "overflow-x-hidden", "h-full"], {
3337
3473
  variants: {
3338
3474
  withTopSeparator: {
3339
3475
  true: ["border-t", "border-neutral-200", "transition-colors duration-200 ease-in"],
@@ -3376,12 +3512,14 @@ const ListLoadingIndicator = ({ type, hasThumbnail, thumbnailShape, hasDescripti
3376
3512
  }
3377
3513
  };
3378
3514
 
3379
- const DEFAULT_ROW_HEIGHT = 61; // 61px is the height of the ListItem component as of 2025-09-26
3380
3515
  /**
3381
3516
  * A performant virtualized list component with infinite scrolling support.
3382
3517
  *
3383
3518
  * ⚠️ **Important**: Requires a container with defined height to work properly.
3384
3519
  *
3520
+ * **Usage Pattern**: Always use the `useList` hook in your component and spread the result into this component.
3521
+ * This gives you access to the virtualizer state (scroll position, isScrolling, etc.) in the parent.
3522
+ *
3385
3523
  * Features:
3386
3524
  * - Virtualized rendering using TanStack Virtual for performance with large datasets
3387
3525
  * - Automatic infinite scroll loading when approaching the end of the list
@@ -3390,24 +3528,143 @@ const DEFAULT_ROW_HEIGHT = 61; // 61px is the height of the ListItem component a
3390
3528
  * - Configurable loading indicators (skeleton, spinner, or custom)
3391
3529
  * - Scroll state detection and callbacks
3392
3530
  * - Variable-height item support via `estimateItemSize`
3531
+ * - Dynamic measurement for accurate positioning of variable-height items
3393
3532
  *
3394
3533
  * The component automatically loads more data when:
3395
3534
  * - User scrolls to the last visible item
3396
3535
  * - Content height is insufficient to fill the container
3536
+ *
3537
+ * **Headers with Different Heights**: When using a header that differs in height from list items,
3538
+ * provide `estimateHeaderSize` for optimal initial rendering. The list automatically measures
3539
+ * actual heights on mount to ensure correct positioning.
3540
+ *
3541
+ * @example Basic usage
3542
+ * ```tsx
3543
+ * const list = useList({
3544
+ * count: items.length,
3545
+ * getItem: index => items[index],
3546
+ * estimateItemSize: () => 61,
3547
+ * });
3548
+ *
3549
+ * return (
3550
+ * <List {...list}>
3551
+ * {({ key, listItemProps, item }) => (
3552
+ * <ListItem key={key} {...listItemProps} title={item?.name} />
3553
+ * )}
3554
+ * </List>
3555
+ * );
3556
+ * ```
3557
+ * @example With header
3558
+ * ```tsx
3559
+ * const list = useList({
3560
+ * count: items.length,
3561
+ * getItem: index => items[index],
3562
+ * header: <KPI title="Total" value={42} />,
3563
+ * estimateHeaderSize: () => 72, // Actual KPI height
3564
+ * estimateItemSize: () => 61, // Actual ListItem height
3565
+ * });
3566
+ *
3567
+ * // Access virtualizer state in parent
3568
+ * console.log('Scroll position:', list.scrollOffset);
3569
+ * console.log('Is scrolling:', list.isScrolling);
3570
+ *
3571
+ * return (
3572
+ * <List {...list}>
3573
+ * {({ key, listItemProps, item }) => (
3574
+ * <ListItem key={key} {...listItemProps} title={item?.name} />
3575
+ * )}
3576
+ * </List>
3577
+ * );
3578
+ * ```
3397
3579
  */
3398
- const List = ({ count, pagination, children, className, dataTestId, separator = "line", loadingIndicator = { type: "skeleton", ...DEFAULT_SKELETON_LIST_ITEM_PROPS }, onRowClick, onScrollStateChange, topSeparatorOnScroll = false, estimateItemSize, header, getItem, }) => {
3399
- const parentRef = react.useRef(null);
3400
- // Calculate the actual count including header
3401
- const actualCount = count + (header ? 1 : 0);
3402
- // Helper function to get item for a given data index
3403
- const getItemAtIndex = react.useCallback((dataIndex) => {
3404
- return getItem(dataIndex);
3405
- }, [getItem]);
3580
+ const List = ({ children, className, dataTestId, topSeparatorOnScroll = false, separator = "line",
3581
+ // UseListResult properties
3582
+ containerRef, listRef, rows, getListItemProps, header, loadingIndicator, shouldShowLoaderAtIndex, count, isScrolling, scrollOffset,
3583
+ // Unused but part of UseListResult interface
3584
+ getTotalSize: _getTotalSize, getVirtualItems: _getVirtualItems, scrollToOffset: _scrollToOffset, scrollToIndex: _scrollToIndex, measure: _measure, }) => {
3585
+ return (jsxRuntime.jsx("div", { className: cvaListContainer({
3586
+ withTopSeparator: topSeparatorOnScroll && (scrollOffset ?? 0) > 0,
3587
+ className,
3588
+ }), "data-is-scrolling": isScrolling, "data-testid": dataTestId, ref: containerRef, children: jsxRuntime.jsx("ul", { className: cvaList(), ref: listRef, children: rows.map(row => {
3589
+ // Generate list item props with separator styling
3590
+ const listItemProps = getListItemProps(row, {
3591
+ className: cvaListItem({ separator }),
3592
+ });
3593
+ const key = row.virtualRow.key;
3594
+ // Render loading row
3595
+ if (row.type === "loader") {
3596
+ const loadingConfig = loadingIndicator ?? {
3597
+ type: "skeleton",
3598
+ ...DEFAULT_SKELETON_LIST_ITEM_PROPS,
3599
+ };
3600
+ // Use the total data count from useList, not visible rows (which are virtualized)
3601
+ const hasHeaderRow = !!header;
3602
+ const dataStartIndex = hasHeaderRow ? 1 : 0;
3603
+ const totalDataRows = dataStartIndex + count;
3604
+ const loaderIndex = row.virtualRow.index - totalDataRows;
3605
+ const shouldShowLoader = shouldShowLoaderAtIndex(loaderIndex);
3606
+ return (jsxRuntime.jsx("li", { ...listItemProps, children: shouldShowLoader ? jsxRuntime.jsx(ListLoadingIndicator, { ...loadingConfig }) : null }, key));
3607
+ }
3608
+ // Render header row
3609
+ if (row.type === "header") {
3610
+ return (jsxRuntime.jsx("li", { ...listItemProps, children: header }, key));
3611
+ }
3612
+ // Render data row
3613
+ return children({ key, listItemProps, item: row.item, index: row.dataIndex });
3614
+ }) }) }));
3615
+ };
3616
+
3617
+ const DEFAULT_LOADING_INDICATOR_CONFIG = {
3618
+ type: "skeleton",
3619
+ initialLoadingCount: 10,
3620
+ scrollLoadingCount: 3,
3621
+ };
3622
+ /**
3623
+ * A hook for managing virtualized list state and behavior.
3624
+ *
3625
+ * This hook encapsulates the logic for:
3626
+ * - Virtualizing list items using TanStack Virtual
3627
+ * - Managing infinite scroll pagination
3628
+ * - Handling header, data, and loading rows
3629
+ * - Calculating proper indices and measurements
3630
+ *
3631
+ * @example
3632
+ * ```tsx
3633
+ * const list = useList({
3634
+ * count: items.length,
3635
+ * getItem: index => items[index],
3636
+ * pagination,
3637
+ * header: <KPI title="Total" value={42} />,
3638
+ * estimateHeaderSize: () => 72, // Measure actual KPI height
3639
+ * estimateItemSize: () => 61, // Measure actual ListItem height
3640
+ * });
3641
+ *
3642
+ * return (
3643
+ * <div ref={list.containerRef}>
3644
+ * <ul style={{ height: `${list.getTotalSize()}px` }}>
3645
+ * {list.rows.map(row => {
3646
+ * const props = list.getListItemProps(row, { className: 'list-item' });
3647
+ * return <li {...props}>{row.item?.title}</li>;
3648
+ * })}
3649
+ * </ul>
3650
+ * </div>
3651
+ * );
3652
+ * ```
3653
+ */
3654
+ const useList = ({ count, pagination, header, getItem, loadingIndicator = DEFAULT_LOADING_INDICATOR_CONFIG, onRowClick, onChange, estimateItemSize, estimateHeaderSize, overscan, }) => {
3655
+ const containerRef = react.useRef(null);
3656
+ const listRef = react.useRef(null);
3657
+ const rowRefsMap = react.useRef(new Map());
3658
+ // Calculate total count including header
3659
+ const hasHeader = Boolean(header);
3660
+ const dataStartIndex = hasHeader ? 1 : 0;
3661
+ const dataCount = count;
3662
+ const totalDataRows = dataStartIndex + dataCount;
3406
3663
  // Calculate how many loading rows we need
3407
3664
  const getLoadingRowsCount = react.useCallback(() => {
3408
- const { type: loadingIndicatorType } = loadingIndicator;
3409
3665
  if (pagination?.isLoading === false)
3410
3666
  return 0;
3667
+ const { type: loadingIndicatorType } = loadingIndicator;
3411
3668
  switch (loadingIndicatorType) {
3412
3669
  case "none":
3413
3670
  return 0;
@@ -3424,64 +3681,210 @@ const List = ({ count, pagination, children, className, dataTestId, separator =
3424
3681
  throw new Error(`${loadingIndicatorType} is not known`);
3425
3682
  }
3426
3683
  }
3427
- }, [loadingIndicator, pagination]);
3684
+ }, [loadingIndicator, pagination?.isLoading, pagination?.pageInfo]);
3685
+ // Helper to determine if a specific loader index should be shown
3686
+ const shouldShowLoaderAtIndex = react.useCallback((loaderIndex) => {
3687
+ if (pagination?.isLoading === false)
3688
+ return false;
3689
+ const { type: loadingIndicatorType } = loadingIndicator;
3690
+ let result;
3691
+ switch (loadingIndicatorType) {
3692
+ case "none":
3693
+ result = false;
3694
+ break;
3695
+ case "spinner":
3696
+ result = loaderIndex === 0;
3697
+ break;
3698
+ case "custom":
3699
+ case "skeleton": {
3700
+ const isInitialLoading = !pagination?.pageInfo;
3701
+ const initialCount = loadingIndicator.initialLoadingCount ?? 10;
3702
+ const scrollCount = loadingIndicator.scrollLoadingCount ?? 3;
3703
+ const maxCount = isInitialLoading ? initialCount : scrollCount;
3704
+ result = loaderIndex < maxCount;
3705
+ break;
3706
+ }
3707
+ default: {
3708
+ throw new Error(`${loadingIndicatorType} is not known`);
3709
+ }
3710
+ }
3711
+ return result;
3712
+ }, [loadingIndicator, pagination?.isLoading, pagination?.pageInfo]);
3713
+ const totalRowCount = react.useMemo(() => {
3714
+ // Only add the exact number of loading rows we want to show
3715
+ const loadingRows = pagination?.isLoading === true ? getLoadingRowsCount() : 0;
3716
+ const total = totalDataRows + loadingRows;
3717
+ return total;
3718
+ }, [totalDataRows, pagination?.isLoading, getLoadingRowsCount]);
3719
+ // Estimate size for all rows (header, data, loading)
3428
3720
  const estimateSize = react.useCallback((index) => {
3429
- // Check if this is a loading row
3430
- if (index >= actualCount) {
3431
- const loaderIndex = index - actualCount;
3432
- const shouldShowLoader = pagination?.isLoading === true && loaderIndex < getLoadingRowsCount();
3721
+ // Loading rows
3722
+ if (index >= totalDataRows) {
3723
+ const loaderIndex = index - totalDataRows;
3724
+ const shouldShowLoader = shouldShowLoaderAtIndex(loaderIndex);
3725
+ const estimatedHeight = shouldShowLoader ? estimateItemSize(0) : 0;
3433
3726
  // Empty loader rows should be estimated at 0 height to prevent blank space
3434
- return shouldShowLoader ? DEFAULT_ROW_HEIGHT : 0;
3727
+ return estimatedHeight;
3435
3728
  }
3436
- // For data rows (including header), use custom estimator if provided
3437
- return estimateItemSize ? estimateItemSize(index) : DEFAULT_ROW_HEIGHT;
3438
- }, [estimateItemSize, actualCount, pagination?.isLoading, getLoadingRowsCount]);
3439
- const { getVirtualItems, getTotalSize, measureElement, scrollOffset } = reactTablePagination.useInfiniteScroll({
3440
- pagination: pagination || reactTablePagination.noPagination,
3441
- containerRef: parentRef,
3442
- count: actualCount + (pagination?.isLoading === true ? getLoadingRowsCount() : 0),
3729
+ // Header row (if exists, it's always at index 0)
3730
+ if (hasHeader && index === 0) {
3731
+ // Use dedicated header size estimate if provided, otherwise fall back to first item estimate
3732
+ return estimateHeaderSize ? estimateHeaderSize() : estimateItemSize(0);
3733
+ }
3734
+ // Data rows - calculate the data index
3735
+ const dataIndex = index - dataStartIndex;
3736
+ return estimateItemSize(dataIndex);
3737
+ }, [estimateItemSize, estimateHeaderSize, totalDataRows, shouldShowLoaderAtIndex, hasHeader, dataStartIndex]);
3738
+ // Set up virtualization
3739
+ const virtualizer = useInfiniteScroll({
3740
+ pagination: pagination ?? noPagination,
3741
+ scrollElementRef: containerRef,
3742
+ count: totalRowCount,
3443
3743
  estimateSize,
3444
- onChange: virtualizer => onScrollStateChange?.(virtualizer.scrollOffset ?? 0, virtualizer.isScrolling),
3744
+ overscan,
3745
+ onChange: instance => {
3746
+ if (listRef.current) {
3747
+ listRef.current.style.height = `${instance.getTotalSize()}px`;
3748
+ }
3749
+ // Apply transforms to all virtual items
3750
+ instance.getVirtualItems().forEach(virtualRow => {
3751
+ const rowRef = rowRefsMap.current.get(virtualRow.index);
3752
+ if (rowRef) {
3753
+ rowRef.style.transform = `translateY(${virtualRow.start}px)`;
3754
+ }
3755
+ });
3756
+ onChange?.(instance);
3757
+ },
3445
3758
  });
3446
- const isAtTop = scrollOffset <= 0;
3447
- return (jsxRuntime.jsx("div", { className: cvaListContainer({
3448
- withTopSeparator: topSeparatorOnScroll && !isAtTop,
3449
- className,
3450
- }), "data-testid": dataTestId, ref: parentRef, children: jsxRuntime.jsx("ul", { className: cvaList(), style: { height: `${getTotalSize()}px` }, children: getVirtualItems().map(virtualRow => {
3451
- const isLoaderRow = virtualRow.index >= actualCount;
3452
- const isHeaderRow = Boolean(header) && virtualRow.index === 0;
3453
- // Calculate data index: if header exists, subtract 1 from index for data items
3454
- const dataIndex = Boolean(header) && !isHeaderRow ? virtualRow.index - 1 : virtualRow.index;
3455
- // Calculate which loading indicator this is (for multiple loading indicators)
3456
- const loaderIndex = isLoaderRow ? virtualRow.index - actualCount : 0;
3457
- // Props required by the virtualizer for proper positioning and behavior
3458
- const listItemProps = {
3459
- className: cvaListItem({ separator }), // List styling (separators, spacing)
3460
- "data-index": isLoaderRow || isHeaderRow ? virtualRow.index : dataIndex, // For accessibility and debugging
3461
- onClick: onRowClick && !isLoaderRow && !isHeaderRow
3462
- ? () => {
3463
- const clickedItem = getItemAtIndex(dataIndex);
3464
- onRowClick(clickedItem, dataIndex);
3465
- }
3466
- : undefined, // Row-level click handling (skip header)
3467
- ref: measureElement, // Required for virtualizer to measure item dimensions
3468
- style: {
3469
- transform: `translateY(${virtualRow.start}px)`, // Critical: positions item in virtual scroll
3470
- },
3471
- tabIndex: -1, // Keyboard navigation support
3759
+ // Measure the list on mount
3760
+ react.useLayoutEffect(() => {
3761
+ // Automatically measure if header is present, or if explicitly requested
3762
+ virtualizer.measure();
3763
+ // This is straight out of the TanStack Virtual docs, so we'll trust it.
3764
+ // eslint-disable-next-line react-hooks/react-compiler
3765
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3766
+ }, []);
3767
+ // Transform virtual items into typed rows
3768
+ const rows = react.useMemo(() => {
3769
+ const virtualItems = virtualizer.getVirtualItems();
3770
+ return virtualItems.map((virtualRow) => {
3771
+ const { index } = virtualRow;
3772
+ // Determine row type
3773
+ const isLoaderRow = index >= totalDataRows;
3774
+ const isHeaderRow = hasHeader && index === 0;
3775
+ if (isLoaderRow) {
3776
+ return {
3777
+ type: "loader",
3778
+ virtualRow,
3779
+ dataIndex: -1,
3472
3780
  };
3473
- // Handle loading rows
3474
- if (isLoaderRow) {
3475
- return (jsxRuntime.jsx("li", { ...listItemProps, children: pagination?.isLoading === true && loaderIndex < getLoadingRowsCount() ? (jsxRuntime.jsx(ListLoadingIndicator, { ...loadingIndicator })) : null }, virtualRow.index));
3476
- }
3477
- // Handle header row
3478
- if (isHeaderRow && header) {
3479
- return (jsxRuntime.jsx("li", { ...listItemProps, children: header }, "header"));
3781
+ }
3782
+ if (isHeaderRow) {
3783
+ return {
3784
+ type: "header",
3785
+ virtualRow,
3786
+ dataIndex: -1,
3787
+ };
3788
+ }
3789
+ // Data row
3790
+ const dataIndex = index - dataStartIndex;
3791
+ const item = getItem(dataIndex);
3792
+ return {
3793
+ type: "data",
3794
+ virtualRow,
3795
+ item,
3796
+ dataIndex,
3797
+ };
3798
+ });
3799
+ // Call getVirtualItems() fresh each time to get updated measurements after virtualizer state changes
3800
+ }, [virtualizer, totalDataRows, hasHeader, dataStartIndex, getItem]);
3801
+ // Helper to create list item props
3802
+ const getListItemProps = react.useCallback((row, options) => {
3803
+ const { type, item, dataIndex } = row;
3804
+ // Handle click for data rows
3805
+ const handleClick = type === "data" && onRowClick && item !== undefined
3806
+ ? () => {
3807
+ onRowClick({ item, index: dataIndex });
3808
+ }
3809
+ : undefined;
3810
+ const onClick = handleClick ?? options.onClick;
3811
+ return {
3812
+ className: options.className,
3813
+ "data-index": row.virtualRow.index,
3814
+ onClick,
3815
+ ref: el => {
3816
+ if (el) {
3817
+ virtualizer.measureElement(el);
3818
+ rowRefsMap.current.set(row.virtualRow.index, el);
3819
+ // Apply transform immediately when element is added to map
3820
+ const virtualRow = virtualizer.getVirtualItems().find(vr => vr.index === row.virtualRow.index);
3821
+ if (virtualRow) {
3822
+ el.style.transform = `translateY(${virtualRow.start}px)`;
3823
+ }
3480
3824
  }
3481
- // For regular children, call the children function with virtualization props and item data
3482
- const item = getItemAtIndex(dataIndex);
3483
- return children(listItemProps, item, dataIndex);
3484
- }) }) }));
3825
+ },
3826
+ tabIndex: -1,
3827
+ };
3828
+ }, [onRowClick, virtualizer]);
3829
+ return {
3830
+ ...virtualizer,
3831
+ containerRef,
3832
+ listRef,
3833
+ rows,
3834
+ getListItemProps,
3835
+ header,
3836
+ loadingIndicator,
3837
+ shouldShowLoaderAtIndex,
3838
+ count,
3839
+ };
3840
+ };
3841
+
3842
+ // Height constants (in pixels) - based on ListItem.variants.ts styling
3843
+ const MIN_ITEM_HEIGHT = 56; // min-h-14 = 56px (minimum height for ListItem)
3844
+ const VERTICAL_PADDING = 12 + 12; // py-3 = 12px top + 12px bottom = 24px
3845
+ const TITLE_LINE_HEIGHT = 20;
3846
+ const DESCRIPTION_LINE_HEIGHT = 16;
3847
+ const META_LINE_HEIGHT = 16 + 2; // 2px padding above meta line
3848
+ /**
3849
+ * Hook that provides a memoized getListItemHeight function.
3850
+ *
3851
+ * Calculates the estimated height of a ListItem based on its configuration.
3852
+ * This function adds up the heights of each visible line plus gaps between them.
3853
+ * Height values are placeholders and need to be measured in browser DevTools.
3854
+ *
3855
+ * @returns {object} An object containing the memoized getListItemHeight function
3856
+ * @example
3857
+ * ```tsx
3858
+ * const { getListItemHeight } = useListItemHeight();
3859
+ *
3860
+ * const estimateItemSize = () => getListItemHeight({
3861
+ * hasThumbnail: true,
3862
+ * hasDescription: true,
3863
+ * });
3864
+ * ```
3865
+ */
3866
+ const useListItemHeight = () => {
3867
+ const getListItemHeight = react.useCallback(({ hasThumbnail: _hasThumbnail = false, // does not affect height
3868
+ hasDescription = false, hasMeta = false, hasDetails: _hasDetails = false, // does not affect height
3869
+ }) => {
3870
+ // Calculate content height (excluding padding)
3871
+ let contentHeight = TITLE_LINE_HEIGHT;
3872
+ // Add description line if present
3873
+ if (hasDescription) {
3874
+ contentHeight += DESCRIPTION_LINE_HEIGHT;
3875
+ }
3876
+ // Add meta line if present
3877
+ if (hasMeta) {
3878
+ contentHeight += META_LINE_HEIGHT;
3879
+ }
3880
+ // Add vertical padding
3881
+ const totalHeight = contentHeight + VERTICAL_PADDING;
3882
+ // Return the larger of calculated height or minimum height
3883
+ return Math.max(totalHeight, MIN_ITEM_HEIGHT);
3884
+ }, []);
3885
+ return {
3886
+ getListItemHeight,
3887
+ };
3485
3888
  };
3486
3889
 
3487
3890
  /**
@@ -4842,12 +5245,14 @@ exports.cvaToggleItemContent = cvaToggleItemContent;
4842
5245
  exports.cvaToggleItemText = cvaToggleItemText;
4843
5246
  exports.cvaZStackContainer = cvaZStackContainer;
4844
5247
  exports.cvaZStackItem = cvaZStackItem;
5248
+ exports.defaultPageSize = defaultPageSize;
4845
5249
  exports.docs = docs;
4846
5250
  exports.getDevicePixelRatio = getDevicePixelRatio;
4847
5251
  exports.getResponsiveRandomWidthPercentage = getResponsiveRandomWidthPercentage;
4848
5252
  exports.getValueBarColorByValue = getValueBarColorByValue;
4849
5253
  exports.iconColorNames = iconColorNames;
4850
5254
  exports.iconPalette = iconPalette;
5255
+ exports.noPagination = noPagination;
4851
5256
  exports.useClickOutside = useClickOutside;
4852
5257
  exports.useContainerBreakpoints = useContainerBreakpoints;
4853
5258
  exports.useContinuousTimeout = useContinuousTimeout;
@@ -4857,13 +5262,17 @@ exports.useElevatedReducer = useElevatedReducer;
4857
5262
  exports.useElevatedState = useElevatedState;
4858
5263
  exports.useGeometry = useGeometry;
4859
5264
  exports.useHover = useHover;
5265
+ exports.useInfiniteScroll = useInfiniteScroll;
4860
5266
  exports.useIsFirstRender = useIsFirstRender;
4861
5267
  exports.useIsFullscreen = useIsFullscreen;
4862
5268
  exports.useIsTextTruncated = useIsTextTruncated;
5269
+ exports.useList = useList;
5270
+ exports.useListItemHeight = useListItemHeight;
4863
5271
  exports.useModifierKey = useModifierKey;
4864
5272
  exports.useOverflowItems = useOverflowItems;
4865
5273
  exports.usePopoverContext = usePopoverContext;
4866
5274
  exports.usePrompt = usePrompt;
5275
+ exports.useRelayPagination = useRelayPagination;
4867
5276
  exports.useResize = useResize;
4868
5277
  exports.useScrollDetection = useScrollDetection;
4869
5278
  exports.useSelfUpdatingRef = useSelfUpdatingRef;