@trackunit/react-components 1.9.18 → 1.9.20

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,12 +10,12 @@ 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 { useVirtualizer } from '@tanstack/react-virtual';
13
14
  import { useDebounceCallback, useCopyToClipboard } from 'usehooks-ts';
14
15
  import { Link, useBlocker } from '@tanstack/react-router';
15
16
  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';
16
17
  import { omit } from 'es-toolkit';
17
18
  import { twMerge } from 'tailwind-merge';
18
- import { useInfiniteScroll, noPagination } from '@trackunit/react-table-pagination';
19
19
  import { HelmetProvider, Helmet } from 'react-helmet-async';
20
20
  import { Trigger, Content, List as List$1, Root } from '@radix-ui/react-tabs';
21
21
 
@@ -991,6 +991,21 @@ const Badge = ({ color = "primary", size = "default", compact = false, className
991
991
  return (jsx("span", { className: cvaBadge({ color, size, className, compact, isSingleChar }), "data-testid": dataTestId, children: compact ? null : displayedCount }));
992
992
  };
993
993
 
994
+ const noPagination = {
995
+ isLoading: false,
996
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
997
+ nextPage: () => { },
998
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
999
+ previousPage: () => { },
1000
+ pageInfo: {
1001
+ count: null,
1002
+ hasNextPage: false,
1003
+ hasPreviousPage: false,
1004
+ endCursor: null,
1005
+ startCursor: null,
1006
+ },
1007
+ };
1008
+
994
1009
  /**
995
1010
  * Custom hook to handle click outside events.
996
1011
  *
@@ -1379,6 +1394,73 @@ const useHover = ({ debounced = false, delay = 100, direction = "out" } = { debo
1379
1394
  return { onMouseEnter, onMouseLeave, hovering: debounced ? debouncedHovering : hovering };
1380
1395
  };
1381
1396
 
1397
+ const OVERSCAN = 10;
1398
+ const DEFAULT_ROW_HEIGHT = 50;
1399
+ /**
1400
+ * Custom hook for implementing infinite scrolling in a table using TanStack Virtual.
1401
+ *
1402
+ * @param props - The configuration object for the infinite scroll hook.
1403
+ * @param props.pagination - The relay pagination object for managing data loading.
1404
+ * @param props.scrollElementRef - Reference to the scrollable container element.
1405
+ * @param props.count - Total number of items to virtualize.
1406
+ * @param props.estimateSize - Optional function to estimate item height.
1407
+ * @param props.overscan - Optional number of items to render outside the visible area.
1408
+ * @param props.onChange - Optional callback when virtualizer changes.
1409
+ * @returns {Virtualizer} The virtualizer instance with all its properties and methods.
1410
+ * @description
1411
+ * This hook is used to implement infinite scrolling in a table. It uses TanStack Virtual's
1412
+ * built-in capabilities for virtualization and automatically loads more data when scrolling
1413
+ * approaches the end of the available content.
1414
+ * @example
1415
+ * const virtualizer = useInfiniteScroll<HTMLDivElement, HTMLDivElement>({
1416
+ * pagination: relayPaginationObject,
1417
+ * scrollElementRef: tableScrollElementRef,
1418
+ * count: 50,
1419
+ * estimateSize: () => 35,
1420
+ * });
1421
+ */
1422
+ const useInfiniteScroll = ({ pagination, scrollElementRef, count, estimateSize, overscan, onChange, }) => {
1423
+ const handleChange = useCallback((virtualizer) => {
1424
+ onChange?.(virtualizer);
1425
+ }, [onChange]);
1426
+ const handleEstimateSize = useCallback((index) => estimateSize?.(index) ?? DEFAULT_ROW_HEIGHT, [estimateSize]);
1427
+ const rowVirtualizer = useVirtualizer({
1428
+ count,
1429
+ getScrollElement: () => scrollElementRef.current,
1430
+ estimateSize: handleEstimateSize,
1431
+ overscan: overscan ?? OVERSCAN,
1432
+ onChange: handleChange,
1433
+ });
1434
+ const virtualItems = rowVirtualizer.getVirtualItems();
1435
+ // Auto-load more data based on scroll position and content height
1436
+ useEffect(() => {
1437
+ if (pagination.pageInfo?.hasNextPage !== true || pagination.isLoading) {
1438
+ return;
1439
+ }
1440
+ const container = scrollElementRef.current;
1441
+ if (!container) {
1442
+ return;
1443
+ }
1444
+ // Check if we should load more data based on virtual items (scroll-based loading)
1445
+ const [lastItem] = [...virtualItems].reverse();
1446
+ const shouldLoadFromScroll = lastItem !== undefined && lastItem.index >= count - 1;
1447
+ // Check if content is insufficient to fill container (initial loading)
1448
+ const shouldLoadFromInsufficientContent = container.scrollHeight <= container.clientHeight;
1449
+ if (shouldLoadFromScroll || shouldLoadFromInsufficientContent) {
1450
+ pagination.nextPage();
1451
+ }
1452
+ }, [
1453
+ pagination.pageInfo?.hasNextPage,
1454
+ pagination.nextPage,
1455
+ pagination.isLoading,
1456
+ pagination,
1457
+ scrollElementRef,
1458
+ virtualItems,
1459
+ count,
1460
+ ]);
1461
+ return rowVirtualizer;
1462
+ };
1463
+
1382
1464
  /**
1383
1465
  * Differentiate between the first and subsequent renders.
1384
1466
  *
@@ -1481,6 +1563,60 @@ const useModifierKey = ({ exclude = [] } = {}) => {
1481
1563
  return isModifierPressed;
1482
1564
  };
1483
1565
 
1566
+ const defaultPageSize = 50;
1567
+ /**
1568
+ * Custom hook for handling Relay pagination in tables.
1569
+ *
1570
+ * @param {RelayPaginationProps} props - The props object containing pagination configuration.
1571
+ * @returns {RelayPaginationSupport} An object containing functions and state for managing Relay pagination.
1572
+ */
1573
+ const useRelayPagination = ({ onReset, pageSize } = { pageSize: defaultPageSize }) => {
1574
+ const [variables, setVariables] = useState({ first: pageSize });
1575
+ const [pageInfo, setPageInfo] = useState();
1576
+ const [isLoading, setIsLoading] = useState(true);
1577
+ const nextPage = useCallback(() => {
1578
+ if (pageInfo?.hasNextPage === true) {
1579
+ setVariables({
1580
+ after: pageInfo.endCursor === null ? undefined : pageInfo.endCursor,
1581
+ first: pageSize,
1582
+ });
1583
+ }
1584
+ }, [pageInfo?.endCursor, pageInfo?.hasNextPage, pageSize]);
1585
+ const previousPage = useCallback(() => {
1586
+ if (pageInfo?.hasPreviousPage === true) {
1587
+ setVariables({
1588
+ before: pageInfo.startCursor === null ? undefined : pageInfo.startCursor,
1589
+ last: pageSize,
1590
+ });
1591
+ }
1592
+ }, [pageInfo?.hasPreviousPage, pageInfo?.startCursor, pageSize]);
1593
+ const reset = useCallback(() => {
1594
+ setVariables({
1595
+ last: undefined,
1596
+ before: undefined,
1597
+ after: undefined,
1598
+ first: pageSize,
1599
+ });
1600
+ if (onReset) {
1601
+ onReset();
1602
+ }
1603
+ }, [onReset, pageSize, setVariables]);
1604
+ return useMemo(() => {
1605
+ return {
1606
+ variables,
1607
+ table: {
1608
+ nextPage,
1609
+ previousPage,
1610
+ isLoading,
1611
+ setIsLoading,
1612
+ reset,
1613
+ pageInfo: pageInfo === null ? undefined : pageInfo,
1614
+ setPageInfo,
1615
+ },
1616
+ };
1617
+ }, [variables, nextPage, previousPage, isLoading, reset, pageInfo]);
1618
+ };
1619
+
1484
1620
  /**
1485
1621
  * Custom hook to handle window resize events and provide the current window size.
1486
1622
  *
@@ -3331,7 +3467,7 @@ const ListItemSkeleton = ({ hasThumbnail = DEFAULT_SKELETON_LIST_ITEM_PROPS.hasT
3331
3467
  return (jsxs("div", { className: cvaListItem$1({ className: "w-full" }), children: [jsxs("div", { className: cvaMainInformationClass({ hasThumbnail, className: "w-full" }), children: [hasThumbnail ? (jsx("div", { className: cvaThumbnailContainer({ className: "bg-gray-200" }), children: jsx("div", { className: twMerge("bg-gray-300", thumbnailShape === "circle" ? "rounded-full" : "rounded"), style: { width: 20, height: 20 } }) })) : null, jsxs("div", { className: "grid-rows-min-fr grid w-full items-center gap-1 text-sm", children: [jsx(SkeletonLines, { height: "0.875em", lines: 1, width: lineWidths.title }), hasDescription ? jsx(SkeletonLines, { height: "0.75em", lines: 1, width: lineWidths.description }) : null, hasMeta ? jsx(SkeletonLines, { height: "0.75em", lines: 1, width: lineWidths.meta }) : null] })] }), hasDetails ? (jsx("div", { className: "pl-2 text-sm", children: jsx(SkeletonLines, { height: "0.875em", lines: 1, width: lineWidths.details }) })) : null] }));
3332
3468
  };
3333
3469
 
3334
- const cvaListContainer = cvaMerge(["overflow-auto", "h-full"], {
3470
+ const cvaListContainer = cvaMerge(["overflow-y-auto", "overflow-x-hidden", "h-full"], {
3335
3471
  variants: {
3336
3472
  withTopSeparator: {
3337
3473
  true: ["border-t", "border-neutral-200", "transition-colors duration-200 ease-in"],
@@ -3374,12 +3510,14 @@ const ListLoadingIndicator = ({ type, hasThumbnail, thumbnailShape, hasDescripti
3374
3510
  }
3375
3511
  };
3376
3512
 
3377
- const DEFAULT_ROW_HEIGHT = 61; // 61px is the height of the ListItem component as of 2025-09-26
3378
3513
  /**
3379
3514
  * A performant virtualized list component with infinite scrolling support.
3380
3515
  *
3381
3516
  * ⚠️ **Important**: Requires a container with defined height to work properly.
3382
3517
  *
3518
+ * **Usage Pattern**: Always use the `useList` hook in your component and spread the result into this component.
3519
+ * This gives you access to the virtualizer state (scroll position, isScrolling, etc.) in the parent.
3520
+ *
3383
3521
  * Features:
3384
3522
  * - Virtualized rendering using TanStack Virtual for performance with large datasets
3385
3523
  * - Automatic infinite scroll loading when approaching the end of the list
@@ -3388,24 +3526,138 @@ const DEFAULT_ROW_HEIGHT = 61; // 61px is the height of the ListItem component a
3388
3526
  * - Configurable loading indicators (skeleton, spinner, or custom)
3389
3527
  * - Scroll state detection and callbacks
3390
3528
  * - Variable-height item support via `estimateItemSize`
3529
+ * - Dynamic measurement for accurate positioning of variable-height items
3391
3530
  *
3392
3531
  * The component automatically loads more data when:
3393
3532
  * - User scrolls to the last visible item
3394
3533
  * - Content height is insufficient to fill the container
3534
+ *
3535
+ * **Headers with Different Heights**: When using a header that differs in height from list items,
3536
+ * provide `estimateHeaderSize` for optimal initial rendering. The list automatically measures
3537
+ * actual heights on mount to ensure correct positioning.
3538
+ *
3539
+ * @example Basic usage
3540
+ * ```tsx
3541
+ * const list = useList({
3542
+ * count: items.length,
3543
+ * getItem: index => items[index],
3544
+ * estimateItemSize: () => 61,
3545
+ * });
3546
+ *
3547
+ * return (
3548
+ * <List {...list}>
3549
+ * {({ key, listItemProps, item }) => (
3550
+ * <ListItem key={key} {...listItemProps} title={item?.name} />
3551
+ * )}
3552
+ * </List>
3553
+ * );
3554
+ * ```
3555
+ * @example With header
3556
+ * ```tsx
3557
+ * const list = useList({
3558
+ * count: items.length,
3559
+ * getItem: index => items[index],
3560
+ * header: <KPI title="Total" value={42} />,
3561
+ * estimateHeaderSize: () => 72, // Actual KPI height
3562
+ * estimateItemSize: () => 61, // Actual ListItem height
3563
+ * });
3564
+ *
3565
+ * // Access virtualizer state in parent
3566
+ * console.log('Scroll position:', list.scrollOffset);
3567
+ * console.log('Is scrolling:', list.isScrolling);
3568
+ *
3569
+ * return (
3570
+ * <List {...list}>
3571
+ * {({ key, listItemProps, item }) => (
3572
+ * <ListItem key={key} {...listItemProps} title={item?.name} />
3573
+ * )}
3574
+ * </List>
3575
+ * );
3576
+ * ```
3577
+ */
3578
+ const List = ({ children, className, dataTestId, topSeparatorOnScroll = false, separator = "line",
3579
+ // UseListResult properties
3580
+ containerRef, listRef, rows, getListItemProps, header, loadingIndicator, isScrolling, scrollOffset,
3581
+ // Unused but part of UseListResult interface
3582
+ getTotalSize: _getTotalSize, getVirtualItems: _getVirtualItems, scrollToOffset: _scrollToOffset, scrollToIndex: _scrollToIndex, measure: _measure, }) => {
3583
+ return (jsx("div", { className: cvaListContainer({
3584
+ withTopSeparator: topSeparatorOnScroll && (scrollOffset ?? 0) > 0,
3585
+ className,
3586
+ }), "data-is-scrolling": isScrolling, "data-testid": dataTestId, ref: containerRef, children: jsx("ul", { className: cvaList(), ref: listRef, children: rows.map(row => {
3587
+ // Generate list item props with separator styling
3588
+ const listItemProps = getListItemProps(row, {
3589
+ className: cvaListItem({ separator }),
3590
+ });
3591
+ const key = row.virtualRow.key;
3592
+ // Render loading row
3593
+ if (row.type === "loader") {
3594
+ const loadingConfig = loadingIndicator ?? {
3595
+ type: "skeleton",
3596
+ ...DEFAULT_SKELETON_LIST_ITEM_PROPS,
3597
+ };
3598
+ const hasHeader = rows.some(r => r.type === "header");
3599
+ const dataRowCount = rows.filter(r => r.type === "data").length;
3600
+ const loaderIndex = row.virtualRow.index - dataRowCount - (hasHeader ? 1 : 0);
3601
+ const shouldShowLoader = loadingConfig.type !== "none" && loaderIndex < (loadingConfig.initialLoadingCount ?? 10);
3602
+ return (jsx("li", { ...listItemProps, children: shouldShowLoader ? jsx(ListLoadingIndicator, { ...loadingConfig }) : null }, key));
3603
+ }
3604
+ // Render header row
3605
+ if (row.type === "header") {
3606
+ return (jsx("li", { ...listItemProps, children: header }, key));
3607
+ }
3608
+ // Render data row
3609
+ return children({ key, listItemProps, item: row.item, index: row.dataIndex });
3610
+ }) }) }));
3611
+ };
3612
+
3613
+ /**
3614
+ * A hook for managing virtualized list state and behavior.
3615
+ *
3616
+ * This hook encapsulates the logic for:
3617
+ * - Virtualizing list items using TanStack Virtual
3618
+ * - Managing infinite scroll pagination
3619
+ * - Handling header, data, and loading rows
3620
+ * - Calculating proper indices and measurements
3621
+ *
3622
+ * @example
3623
+ * ```tsx
3624
+ * const list = useList({
3625
+ * count: items.length,
3626
+ * getItem: index => items[index],
3627
+ * pagination,
3628
+ * header: <KPI title="Total" value={42} />,
3629
+ * estimateHeaderSize: () => 72, // Measure actual KPI height
3630
+ * estimateItemSize: () => 61, // Measure actual ListItem height
3631
+ * });
3632
+ *
3633
+ * return (
3634
+ * <div ref={list.containerRef}>
3635
+ * <ul style={{ height: `${list.getTotalSize()}px` }}>
3636
+ * {list.rows.map(row => {
3637
+ * const props = list.getListItemProps(row, { className: 'list-item' });
3638
+ * return <li {...props}>{row.item?.title}</li>;
3639
+ * })}
3640
+ * </ul>
3641
+ * </div>
3642
+ * );
3643
+ * ```
3395
3644
  */
3396
- const List = ({ count, pagination, children, className, dataTestId, separator = "line", loadingIndicator = { type: "skeleton", ...DEFAULT_SKELETON_LIST_ITEM_PROPS }, onRowClick, onScrollStateChange, topSeparatorOnScroll = false, estimateItemSize, header, getItem, }) => {
3397
- const parentRef = useRef(null);
3398
- // Calculate the actual count including header
3399
- const actualCount = count + (header ? 1 : 0);
3400
- // Helper function to get item for a given data index
3401
- const getItemAtIndex = useCallback((dataIndex) => {
3402
- return getItem(dataIndex);
3403
- }, [getItem]);
3645
+ const useList = ({ count, pagination, header, getItem, loadingIndicator, onRowClick, onChange, estimateItemSize, estimateHeaderSize, overscan, }) => {
3646
+ const containerRef = useRef(null);
3647
+ const listRef = useRef(null);
3648
+ const rowRefsMap = useRef(new Map());
3649
+ // Calculate total count including header
3650
+ const hasHeader = Boolean(header);
3651
+ const dataStartIndex = hasHeader ? 1 : 0;
3652
+ const dataCount = count;
3653
+ const totalDataRows = dataStartIndex + dataCount;
3404
3654
  // Calculate how many loading rows we need
3405
3655
  const getLoadingRowsCount = useCallback(() => {
3406
- const { type: loadingIndicatorType } = loadingIndicator;
3656
+ if (!loadingIndicator)
3657
+ return 0;
3407
3658
  if (pagination?.isLoading === false)
3408
3659
  return 0;
3660
+ const { type: loadingIndicatorType } = loadingIndicator;
3409
3661
  switch (loadingIndicatorType) {
3410
3662
  case "none":
3411
3663
  return 0;
@@ -3422,64 +3674,181 @@ const List = ({ count, pagination, children, className, dataTestId, separator =
3422
3674
  throw new Error(`${loadingIndicatorType} is not known`);
3423
3675
  }
3424
3676
  }
3425
- }, [loadingIndicator, pagination]);
3677
+ }, [loadingIndicator, pagination?.isLoading, pagination?.pageInfo]);
3678
+ const totalRowCount = useMemo(() => totalDataRows + (pagination?.isLoading === true ? getLoadingRowsCount() : 0), [totalDataRows, pagination?.isLoading, getLoadingRowsCount]);
3679
+ // Estimate size for all rows (header, data, loading)
3426
3680
  const estimateSize = useCallback((index) => {
3427
- // Check if this is a loading row
3428
- if (index >= actualCount) {
3429
- const loaderIndex = index - actualCount;
3681
+ // Loading rows
3682
+ if (index >= totalDataRows) {
3683
+ const loaderIndex = index - totalDataRows;
3430
3684
  const shouldShowLoader = pagination?.isLoading === true && loaderIndex < getLoadingRowsCount();
3431
3685
  // Empty loader rows should be estimated at 0 height to prevent blank space
3432
- return shouldShowLoader ? DEFAULT_ROW_HEIGHT : 0;
3686
+ return shouldShowLoader ? estimateItemSize(0) : 0;
3433
3687
  }
3434
- // For data rows (including header), use custom estimator if provided
3435
- return estimateItemSize ? estimateItemSize(index) : DEFAULT_ROW_HEIGHT;
3436
- }, [estimateItemSize, actualCount, pagination?.isLoading, getLoadingRowsCount]);
3437
- const { getVirtualItems, getTotalSize, measureElement, scrollOffset } = useInfiniteScroll({
3438
- pagination: pagination || noPagination,
3439
- containerRef: parentRef,
3440
- count: actualCount + (pagination?.isLoading === true ? getLoadingRowsCount() : 0),
3688
+ // Header row (if exists, it's always at index 0)
3689
+ if (hasHeader && index === 0) {
3690
+ // Use dedicated header size estimate if provided, otherwise fall back to first item estimate
3691
+ return estimateHeaderSize ? estimateHeaderSize() : estimateItemSize(0);
3692
+ }
3693
+ // Data rows - calculate the data index
3694
+ const dataIndex = index - dataStartIndex;
3695
+ return estimateItemSize(dataIndex);
3696
+ }, [
3697
+ estimateItemSize,
3698
+ estimateHeaderSize,
3699
+ totalDataRows,
3700
+ pagination?.isLoading,
3701
+ getLoadingRowsCount,
3702
+ hasHeader,
3703
+ dataStartIndex,
3704
+ ]);
3705
+ // Set up virtualization
3706
+ const virtualizer = useInfiniteScroll({
3707
+ pagination: pagination ?? noPagination,
3708
+ scrollElementRef: containerRef,
3709
+ count: totalRowCount,
3441
3710
  estimateSize,
3442
- onChange: virtualizer => onScrollStateChange?.(virtualizer.scrollOffset ?? 0, virtualizer.isScrolling),
3711
+ overscan,
3712
+ onChange: instance => {
3713
+ if (listRef.current) {
3714
+ listRef.current.style.height = `${instance.getTotalSize()}px`;
3715
+ }
3716
+ // Apply transforms to all virtual items
3717
+ instance.getVirtualItems().forEach(virtualRow => {
3718
+ const rowRef = rowRefsMap.current.get(virtualRow.index);
3719
+ if (rowRef) {
3720
+ rowRef.style.transform = `translateY(${virtualRow.start}px)`;
3721
+ }
3722
+ });
3723
+ onChange?.(instance);
3724
+ },
3443
3725
  });
3444
- const isAtTop = scrollOffset <= 0;
3445
- return (jsx("div", { className: cvaListContainer({
3446
- withTopSeparator: topSeparatorOnScroll && !isAtTop,
3447
- className,
3448
- }), "data-testid": dataTestId, ref: parentRef, children: jsx("ul", { className: cvaList(), style: { height: `${getTotalSize()}px` }, children: getVirtualItems().map(virtualRow => {
3449
- const isLoaderRow = virtualRow.index >= actualCount;
3450
- const isHeaderRow = Boolean(header) && virtualRow.index === 0;
3451
- // Calculate data index: if header exists, subtract 1 from index for data items
3452
- const dataIndex = Boolean(header) && !isHeaderRow ? virtualRow.index - 1 : virtualRow.index;
3453
- // Calculate which loading indicator this is (for multiple loading indicators)
3454
- const loaderIndex = isLoaderRow ? virtualRow.index - actualCount : 0;
3455
- // Props required by the virtualizer for proper positioning and behavior
3456
- const listItemProps = {
3457
- className: cvaListItem({ separator }), // List styling (separators, spacing)
3458
- "data-index": isLoaderRow || isHeaderRow ? virtualRow.index : dataIndex, // For accessibility and debugging
3459
- onClick: onRowClick && !isLoaderRow && !isHeaderRow
3460
- ? () => {
3461
- const clickedItem = getItemAtIndex(dataIndex);
3462
- onRowClick(clickedItem, dataIndex);
3463
- }
3464
- : undefined, // Row-level click handling (skip header)
3465
- ref: measureElement, // Required for virtualizer to measure item dimensions
3466
- style: {
3467
- transform: `translateY(${virtualRow.start}px)`, // Critical: positions item in virtual scroll
3468
- },
3469
- tabIndex: -1, // Keyboard navigation support
3726
+ // Measure the list on mount
3727
+ useLayoutEffect(() => {
3728
+ // Automatically measure if header is present, or if explicitly requested
3729
+ virtualizer.measure();
3730
+ // This is straight out of the TanStack Virtual docs, so we'll trust it.
3731
+ // eslint-disable-next-line react-hooks/react-compiler
3732
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3733
+ }, []);
3734
+ // Transform virtual items into typed rows
3735
+ const rows = useMemo(() => {
3736
+ return virtualizer.getVirtualItems().map((virtualRow) => {
3737
+ const { index } = virtualRow;
3738
+ // Determine row type
3739
+ const isLoaderRow = index >= totalDataRows;
3740
+ const isHeaderRow = hasHeader && index === 0;
3741
+ if (isLoaderRow) {
3742
+ return {
3743
+ type: "loader",
3744
+ virtualRow,
3745
+ dataIndex: -1,
3470
3746
  };
3471
- // Handle loading rows
3472
- if (isLoaderRow) {
3473
- return (jsx("li", { ...listItemProps, children: pagination?.isLoading === true && loaderIndex < getLoadingRowsCount() ? (jsx(ListLoadingIndicator, { ...loadingIndicator })) : null }, virtualRow.index));
3474
- }
3475
- // Handle header row
3476
- if (isHeaderRow && header) {
3477
- return (jsx("li", { ...listItemProps, children: header }, "header"));
3747
+ }
3748
+ if (isHeaderRow) {
3749
+ return {
3750
+ type: "header",
3751
+ virtualRow,
3752
+ dataIndex: -1,
3753
+ };
3754
+ }
3755
+ // Data row
3756
+ const dataIndex = index - dataStartIndex;
3757
+ const item = getItem(dataIndex);
3758
+ return {
3759
+ type: "data",
3760
+ virtualRow,
3761
+ item,
3762
+ dataIndex,
3763
+ };
3764
+ });
3765
+ // Call getVirtualItems() fresh each time to get updated measurements after virtualizer state changes
3766
+ }, [virtualizer, totalDataRows, hasHeader, dataStartIndex, getItem]);
3767
+ // Helper to create list item props
3768
+ const getListItemProps = useCallback((row, options) => {
3769
+ const { type, item, dataIndex } = row;
3770
+ // Handle click for data rows
3771
+ const handleClick = type === "data" && onRowClick && item !== undefined
3772
+ ? () => {
3773
+ onRowClick({ item, index: dataIndex });
3774
+ }
3775
+ : undefined;
3776
+ const onClick = handleClick ?? options.onClick;
3777
+ return {
3778
+ className: options.className,
3779
+ "data-index": row.virtualRow.index,
3780
+ onClick,
3781
+ ref: el => {
3782
+ if (el) {
3783
+ virtualizer.measureElement(el);
3784
+ rowRefsMap.current.set(row.virtualRow.index, el);
3785
+ // Apply transform immediately when element is added to map
3786
+ const virtualRow = virtualizer.getVirtualItems().find(vr => vr.index === row.virtualRow.index);
3787
+ if (virtualRow) {
3788
+ el.style.transform = `translateY(${virtualRow.start}px)`;
3789
+ }
3478
3790
  }
3479
- // For regular children, call the children function with virtualization props and item data
3480
- const item = getItemAtIndex(dataIndex);
3481
- return children(listItemProps, item, dataIndex);
3482
- }) }) }));
3791
+ },
3792
+ tabIndex: -1,
3793
+ };
3794
+ }, [onRowClick, virtualizer]);
3795
+ return {
3796
+ ...virtualizer,
3797
+ containerRef,
3798
+ listRef,
3799
+ rows,
3800
+ getListItemProps,
3801
+ header,
3802
+ loadingIndicator,
3803
+ };
3804
+ };
3805
+
3806
+ // Height constants (in pixels) - based on ListItem.variants.ts styling
3807
+ const MIN_ITEM_HEIGHT = 56; // min-h-14 = 56px (minimum height for ListItem)
3808
+ const VERTICAL_PADDING = 12 + 12; // py-3 = 12px top + 12px bottom = 24px
3809
+ const TITLE_LINE_HEIGHT = 20;
3810
+ const DESCRIPTION_LINE_HEIGHT = 16;
3811
+ const META_LINE_HEIGHT = 16 + 2; // 2px padding above meta line
3812
+ /**
3813
+ * Hook that provides a memoized getListItemHeight function.
3814
+ *
3815
+ * Calculates the estimated height of a ListItem based on its configuration.
3816
+ * This function adds up the heights of each visible line plus gaps between them.
3817
+ * Height values are placeholders and need to be measured in browser DevTools.
3818
+ *
3819
+ * @returns {object} An object containing the memoized getListItemHeight function
3820
+ * @example
3821
+ * ```tsx
3822
+ * const { getListItemHeight } = useListItemHeight();
3823
+ *
3824
+ * const estimateItemSize = () => getListItemHeight({
3825
+ * hasThumbnail: true,
3826
+ * hasDescription: true,
3827
+ * });
3828
+ * ```
3829
+ */
3830
+ const useListItemHeight = () => {
3831
+ const getListItemHeight = useCallback(({ hasThumbnail: _hasThumbnail = false, // does not affect height
3832
+ hasDescription = false, hasMeta = false, hasDetails: _hasDetails = false, // does not affect height
3833
+ }) => {
3834
+ // Calculate content height (excluding padding)
3835
+ let contentHeight = TITLE_LINE_HEIGHT;
3836
+ // Add description line if present
3837
+ if (hasDescription) {
3838
+ contentHeight += DESCRIPTION_LINE_HEIGHT;
3839
+ }
3840
+ // Add meta line if present
3841
+ if (hasMeta) {
3842
+ contentHeight += META_LINE_HEIGHT;
3843
+ }
3844
+ // Add vertical padding
3845
+ const totalHeight = contentHeight + VERTICAL_PADDING;
3846
+ // Return the larger of calculated height or minimum height
3847
+ return Math.max(totalHeight, MIN_ITEM_HEIGHT);
3848
+ }, []);
3849
+ return {
3850
+ getListItemHeight,
3851
+ };
3483
3852
  };
3484
3853
 
3485
3854
  /**
@@ -4744,4 +5113,4 @@ const cvaClickable = cvaMerge([
4744
5113
  },
4745
5114
  });
4746
5115
 
4747
- export { ActionRenderer, Alert, Badge, Breadcrumb, BreadcrumbContainer, Button, Card, CardBody, CardFooter, CardHeader, Collapse, CompletionStatusIndicator, CopyableText, DetailsList, EmptyState, EmptyValue, ExternalLink, Heading, Highlight, HorizontalOverflowScroller, Icon, IconButton, Indicator, KPI, KPICard, List, ListItem, MenuDivider, MenuItem, MenuList, MoreMenu, Notice, PackageNameStoryComponent, Page, PageContent, PageHeader, PageHeaderKpiMetrics, PageHeaderSecondaryActions, PageHeaderTitle, Pagination, Polygon, Popover, PopoverContent, PopoverTitle, PopoverTrigger, Portal, Prompt, ROLE_CARD, SectionHeader, Sidebar, SkeletonLines, Spacer, Spinner, StarButton, Tab, TabContent, TabList, Tabs, Tag, Text, ToggleGroup, Tooltip, ValueBar, ZStack, cvaButton, cvaButtonPrefixSuffix, cvaButtonSpinner, cvaButtonSpinnerContainer, cvaClickable, cvaContainerStyles, cvaIconButton, cvaImgStyles, cvaIndicator, cvaIndicatorIcon, cvaIndicatorIconBackground, cvaIndicatorLabel, cvaIndicatorPing, cvaInteractableItem, cvaList, cvaListContainer, cvaListItem, cvaMenuItem, cvaMenuItemLabel, cvaMenuItemPrefix, cvaMenuItemStyle, cvaMenuItemSuffix, cvaPageHeader, cvaPageHeaderContainer, cvaPageHeaderHeading, cvaToggleGroup, cvaToggleGroupWithSlidingBackground, cvaToggleItem, cvaToggleItemContent, cvaToggleItemText, cvaZStackContainer, cvaZStackItem, docs, getDevicePixelRatio, getResponsiveRandomWidthPercentage, getValueBarColorByValue, iconColorNames, iconPalette, useClickOutside, useContainerBreakpoints, useContinuousTimeout, useDebounce, useDevicePixelRatio, useElevatedReducer, useElevatedState, useGeometry, useHover, useIsFirstRender, useIsFullscreen, useIsTextTruncated, useModifierKey, useOverflowItems, usePopoverContext, usePrompt, useResize, useScrollDetection, useSelfUpdatingRef, useStable, useTimeout, useViewportBreakpoints, useWatch, useWindowActivity };
5116
+ export { ActionRenderer, Alert, Badge, Breadcrumb, BreadcrumbContainer, Button, Card, CardBody, CardFooter, CardHeader, Collapse, CompletionStatusIndicator, CopyableText, DetailsList, EmptyState, EmptyValue, ExternalLink, Heading, Highlight, HorizontalOverflowScroller, Icon, IconButton, Indicator, KPI, KPICard, List, ListItem, MenuDivider, MenuItem, MenuList, MoreMenu, Notice, PackageNameStoryComponent, Page, PageContent, PageHeader, PageHeaderKpiMetrics, PageHeaderSecondaryActions, PageHeaderTitle, Pagination, Polygon, Popover, PopoverContent, PopoverTitle, PopoverTrigger, Portal, Prompt, ROLE_CARD, SectionHeader, Sidebar, SkeletonLines, Spacer, Spinner, StarButton, Tab, TabContent, TabList, Tabs, Tag, Text, ToggleGroup, Tooltip, ValueBar, ZStack, cvaButton, cvaButtonPrefixSuffix, cvaButtonSpinner, cvaButtonSpinnerContainer, cvaClickable, cvaContainerStyles, cvaIconButton, cvaImgStyles, cvaIndicator, cvaIndicatorIcon, cvaIndicatorIconBackground, cvaIndicatorLabel, cvaIndicatorPing, cvaInteractableItem, cvaList, cvaListContainer, cvaListItem, cvaMenuItem, cvaMenuItemLabel, cvaMenuItemPrefix, cvaMenuItemStyle, cvaMenuItemSuffix, cvaPageHeader, cvaPageHeaderContainer, cvaPageHeaderHeading, cvaToggleGroup, cvaToggleGroupWithSlidingBackground, cvaToggleItem, cvaToggleItemContent, cvaToggleItemText, cvaZStackContainer, cvaZStackItem, defaultPageSize, docs, getDevicePixelRatio, getResponsiveRandomWidthPercentage, getValueBarColorByValue, iconColorNames, iconPalette, noPagination, useClickOutside, useContainerBreakpoints, useContinuousTimeout, useDebounce, useDevicePixelRatio, useElevatedReducer, useElevatedState, useGeometry, useHover, useInfiniteScroll, useIsFirstRender, useIsFullscreen, useIsTextTruncated, useList, useListItemHeight, useModifierKey, useOverflowItems, usePopoverContext, usePrompt, useRelayPagination, useResize, useScrollDetection, useSelfUpdatingRef, useStable, useTimeout, useViewportBreakpoints, useWatch, useWindowActivity };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-components",
3
- "version": "1.9.18",
3
+ "version": "1.9.20",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -16,14 +16,14 @@
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.10",
20
- "@trackunit/css-class-variance-utilities": "1.7.10",
21
- "@trackunit/shared-utils": "1.9.10",
22
- "@trackunit/ui-icons": "1.7.12",
23
- "@trackunit/react-table-pagination": "1.7.10",
24
- "@trackunit/react-test-setup": "1.4.10",
19
+ "@trackunit/ui-design-tokens": "1.7.12",
20
+ "@trackunit/css-class-variance-utilities": "1.7.12",
21
+ "@trackunit/shared-utils": "1.9.12",
22
+ "@trackunit/ui-icons": "1.7.14",
23
+ "@trackunit/react-test-setup": "1.4.12",
25
24
  "@tanstack/react-router": "1.114.29",
26
- "es-toolkit": "^1.39.10"
25
+ "es-toolkit": "^1.39.10",
26
+ "@tanstack/react-virtual": "3.13.12"
27
27
  },
28
28
  "module": "./index.esm.js",
29
29
  "main": "./index.cjs.js",