@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.cjs.js +437 -62
- package/index.esm.js +432 -63
- package/package.json +8 -8
- package/src/components/List/List.d.ts +64 -85
- package/src/components/List/useList.d.ts +167 -0
- package/src/components/ListItem/useListItemHeight.d.ts +35 -0
- package/src/components/index.d.ts +2 -0
- package/src/hooks/index.d.ts +3 -0
- package/src/hooks/noPagination.d.ts +2 -0
- package/src/hooks/useInfiniteScroll.d.ts +36 -0
- package/src/hooks/useRelayPagination.d.ts +43 -0
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,138 @@ 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
|
+
* ```
|
|
3579
|
+
*/
|
|
3580
|
+
const List = ({ children, className, dataTestId, topSeparatorOnScroll = false, separator = "line",
|
|
3581
|
+
// UseListResult properties
|
|
3582
|
+
containerRef, listRef, rows, getListItemProps, header, loadingIndicator, 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
|
+
const hasHeader = rows.some(r => r.type === "header");
|
|
3601
|
+
const dataRowCount = rows.filter(r => r.type === "data").length;
|
|
3602
|
+
const loaderIndex = row.virtualRow.index - dataRowCount - (hasHeader ? 1 : 0);
|
|
3603
|
+
const shouldShowLoader = loadingConfig.type !== "none" && loaderIndex < (loadingConfig.initialLoadingCount ?? 10);
|
|
3604
|
+
return (jsxRuntime.jsx("li", { ...listItemProps, children: shouldShowLoader ? jsxRuntime.jsx(ListLoadingIndicator, { ...loadingConfig }) : null }, key));
|
|
3605
|
+
}
|
|
3606
|
+
// Render header row
|
|
3607
|
+
if (row.type === "header") {
|
|
3608
|
+
return (jsxRuntime.jsx("li", { ...listItemProps, children: header }, key));
|
|
3609
|
+
}
|
|
3610
|
+
// Render data row
|
|
3611
|
+
return children({ key, listItemProps, item: row.item, index: row.dataIndex });
|
|
3612
|
+
}) }) }));
|
|
3613
|
+
};
|
|
3614
|
+
|
|
3615
|
+
/**
|
|
3616
|
+
* A hook for managing virtualized list state and behavior.
|
|
3617
|
+
*
|
|
3618
|
+
* This hook encapsulates the logic for:
|
|
3619
|
+
* - Virtualizing list items using TanStack Virtual
|
|
3620
|
+
* - Managing infinite scroll pagination
|
|
3621
|
+
* - Handling header, data, and loading rows
|
|
3622
|
+
* - Calculating proper indices and measurements
|
|
3623
|
+
*
|
|
3624
|
+
* @example
|
|
3625
|
+
* ```tsx
|
|
3626
|
+
* const list = useList({
|
|
3627
|
+
* count: items.length,
|
|
3628
|
+
* getItem: index => items[index],
|
|
3629
|
+
* pagination,
|
|
3630
|
+
* header: <KPI title="Total" value={42} />,
|
|
3631
|
+
* estimateHeaderSize: () => 72, // Measure actual KPI height
|
|
3632
|
+
* estimateItemSize: () => 61, // Measure actual ListItem height
|
|
3633
|
+
* });
|
|
3634
|
+
*
|
|
3635
|
+
* return (
|
|
3636
|
+
* <div ref={list.containerRef}>
|
|
3637
|
+
* <ul style={{ height: `${list.getTotalSize()}px` }}>
|
|
3638
|
+
* {list.rows.map(row => {
|
|
3639
|
+
* const props = list.getListItemProps(row, { className: 'list-item' });
|
|
3640
|
+
* return <li {...props}>{row.item?.title}</li>;
|
|
3641
|
+
* })}
|
|
3642
|
+
* </ul>
|
|
3643
|
+
* </div>
|
|
3644
|
+
* );
|
|
3645
|
+
* ```
|
|
3397
3646
|
*/
|
|
3398
|
-
const
|
|
3399
|
-
const
|
|
3400
|
-
|
|
3401
|
-
const
|
|
3402
|
-
//
|
|
3403
|
-
const
|
|
3404
|
-
|
|
3405
|
-
|
|
3647
|
+
const useList = ({ count, pagination, header, getItem, loadingIndicator, onRowClick, onChange, estimateItemSize, estimateHeaderSize, overscan, }) => {
|
|
3648
|
+
const containerRef = react.useRef(null);
|
|
3649
|
+
const listRef = react.useRef(null);
|
|
3650
|
+
const rowRefsMap = react.useRef(new Map());
|
|
3651
|
+
// Calculate total count including header
|
|
3652
|
+
const hasHeader = Boolean(header);
|
|
3653
|
+
const dataStartIndex = hasHeader ? 1 : 0;
|
|
3654
|
+
const dataCount = count;
|
|
3655
|
+
const totalDataRows = dataStartIndex + dataCount;
|
|
3406
3656
|
// Calculate how many loading rows we need
|
|
3407
3657
|
const getLoadingRowsCount = react.useCallback(() => {
|
|
3408
|
-
|
|
3658
|
+
if (!loadingIndicator)
|
|
3659
|
+
return 0;
|
|
3409
3660
|
if (pagination?.isLoading === false)
|
|
3410
3661
|
return 0;
|
|
3662
|
+
const { type: loadingIndicatorType } = loadingIndicator;
|
|
3411
3663
|
switch (loadingIndicatorType) {
|
|
3412
3664
|
case "none":
|
|
3413
3665
|
return 0;
|
|
@@ -3424,64 +3676,181 @@ const List = ({ count, pagination, children, className, dataTestId, separator =
|
|
|
3424
3676
|
throw new Error(`${loadingIndicatorType} is not known`);
|
|
3425
3677
|
}
|
|
3426
3678
|
}
|
|
3427
|
-
}, [loadingIndicator, pagination]);
|
|
3679
|
+
}, [loadingIndicator, pagination?.isLoading, pagination?.pageInfo]);
|
|
3680
|
+
const totalRowCount = react.useMemo(() => totalDataRows + (pagination?.isLoading === true ? getLoadingRowsCount() : 0), [totalDataRows, pagination?.isLoading, getLoadingRowsCount]);
|
|
3681
|
+
// Estimate size for all rows (header, data, loading)
|
|
3428
3682
|
const estimateSize = react.useCallback((index) => {
|
|
3429
|
-
//
|
|
3430
|
-
if (index >=
|
|
3431
|
-
const loaderIndex = index -
|
|
3683
|
+
// Loading rows
|
|
3684
|
+
if (index >= totalDataRows) {
|
|
3685
|
+
const loaderIndex = index - totalDataRows;
|
|
3432
3686
|
const shouldShowLoader = pagination?.isLoading === true && loaderIndex < getLoadingRowsCount();
|
|
3433
3687
|
// Empty loader rows should be estimated at 0 height to prevent blank space
|
|
3434
|
-
return shouldShowLoader ?
|
|
3688
|
+
return shouldShowLoader ? estimateItemSize(0) : 0;
|
|
3435
3689
|
}
|
|
3436
|
-
//
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3690
|
+
// Header row (if exists, it's always at index 0)
|
|
3691
|
+
if (hasHeader && index === 0) {
|
|
3692
|
+
// Use dedicated header size estimate if provided, otherwise fall back to first item estimate
|
|
3693
|
+
return estimateHeaderSize ? estimateHeaderSize() : estimateItemSize(0);
|
|
3694
|
+
}
|
|
3695
|
+
// Data rows - calculate the data index
|
|
3696
|
+
const dataIndex = index - dataStartIndex;
|
|
3697
|
+
return estimateItemSize(dataIndex);
|
|
3698
|
+
}, [
|
|
3699
|
+
estimateItemSize,
|
|
3700
|
+
estimateHeaderSize,
|
|
3701
|
+
totalDataRows,
|
|
3702
|
+
pagination?.isLoading,
|
|
3703
|
+
getLoadingRowsCount,
|
|
3704
|
+
hasHeader,
|
|
3705
|
+
dataStartIndex,
|
|
3706
|
+
]);
|
|
3707
|
+
// Set up virtualization
|
|
3708
|
+
const virtualizer = useInfiniteScroll({
|
|
3709
|
+
pagination: pagination ?? noPagination,
|
|
3710
|
+
scrollElementRef: containerRef,
|
|
3711
|
+
count: totalRowCount,
|
|
3443
3712
|
estimateSize,
|
|
3444
|
-
|
|
3713
|
+
overscan,
|
|
3714
|
+
onChange: instance => {
|
|
3715
|
+
if (listRef.current) {
|
|
3716
|
+
listRef.current.style.height = `${instance.getTotalSize()}px`;
|
|
3717
|
+
}
|
|
3718
|
+
// Apply transforms to all virtual items
|
|
3719
|
+
instance.getVirtualItems().forEach(virtualRow => {
|
|
3720
|
+
const rowRef = rowRefsMap.current.get(virtualRow.index);
|
|
3721
|
+
if (rowRef) {
|
|
3722
|
+
rowRef.style.transform = `translateY(${virtualRow.start}px)`;
|
|
3723
|
+
}
|
|
3724
|
+
});
|
|
3725
|
+
onChange?.(instance);
|
|
3726
|
+
},
|
|
3445
3727
|
});
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
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
|
|
3728
|
+
// Measure the list on mount
|
|
3729
|
+
react.useLayoutEffect(() => {
|
|
3730
|
+
// Automatically measure if header is present, or if explicitly requested
|
|
3731
|
+
virtualizer.measure();
|
|
3732
|
+
// This is straight out of the TanStack Virtual docs, so we'll trust it.
|
|
3733
|
+
// eslint-disable-next-line react-hooks/react-compiler
|
|
3734
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3735
|
+
}, []);
|
|
3736
|
+
// Transform virtual items into typed rows
|
|
3737
|
+
const rows = react.useMemo(() => {
|
|
3738
|
+
return virtualizer.getVirtualItems().map((virtualRow) => {
|
|
3739
|
+
const { index } = virtualRow;
|
|
3740
|
+
// Determine row type
|
|
3741
|
+
const isLoaderRow = index >= totalDataRows;
|
|
3742
|
+
const isHeaderRow = hasHeader && index === 0;
|
|
3743
|
+
if (isLoaderRow) {
|
|
3744
|
+
return {
|
|
3745
|
+
type: "loader",
|
|
3746
|
+
virtualRow,
|
|
3747
|
+
dataIndex: -1,
|
|
3472
3748
|
};
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3749
|
+
}
|
|
3750
|
+
if (isHeaderRow) {
|
|
3751
|
+
return {
|
|
3752
|
+
type: "header",
|
|
3753
|
+
virtualRow,
|
|
3754
|
+
dataIndex: -1,
|
|
3755
|
+
};
|
|
3756
|
+
}
|
|
3757
|
+
// Data row
|
|
3758
|
+
const dataIndex = index - dataStartIndex;
|
|
3759
|
+
const item = getItem(dataIndex);
|
|
3760
|
+
return {
|
|
3761
|
+
type: "data",
|
|
3762
|
+
virtualRow,
|
|
3763
|
+
item,
|
|
3764
|
+
dataIndex,
|
|
3765
|
+
};
|
|
3766
|
+
});
|
|
3767
|
+
// Call getVirtualItems() fresh each time to get updated measurements after virtualizer state changes
|
|
3768
|
+
}, [virtualizer, totalDataRows, hasHeader, dataStartIndex, getItem]);
|
|
3769
|
+
// Helper to create list item props
|
|
3770
|
+
const getListItemProps = react.useCallback((row, options) => {
|
|
3771
|
+
const { type, item, dataIndex } = row;
|
|
3772
|
+
// Handle click for data rows
|
|
3773
|
+
const handleClick = type === "data" && onRowClick && item !== undefined
|
|
3774
|
+
? () => {
|
|
3775
|
+
onRowClick({ item, index: dataIndex });
|
|
3776
|
+
}
|
|
3777
|
+
: undefined;
|
|
3778
|
+
const onClick = handleClick ?? options.onClick;
|
|
3779
|
+
return {
|
|
3780
|
+
className: options.className,
|
|
3781
|
+
"data-index": row.virtualRow.index,
|
|
3782
|
+
onClick,
|
|
3783
|
+
ref: el => {
|
|
3784
|
+
if (el) {
|
|
3785
|
+
virtualizer.measureElement(el);
|
|
3786
|
+
rowRefsMap.current.set(row.virtualRow.index, el);
|
|
3787
|
+
// Apply transform immediately when element is added to map
|
|
3788
|
+
const virtualRow = virtualizer.getVirtualItems().find(vr => vr.index === row.virtualRow.index);
|
|
3789
|
+
if (virtualRow) {
|
|
3790
|
+
el.style.transform = `translateY(${virtualRow.start}px)`;
|
|
3791
|
+
}
|
|
3480
3792
|
}
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3793
|
+
},
|
|
3794
|
+
tabIndex: -1,
|
|
3795
|
+
};
|
|
3796
|
+
}, [onRowClick, virtualizer]);
|
|
3797
|
+
return {
|
|
3798
|
+
...virtualizer,
|
|
3799
|
+
containerRef,
|
|
3800
|
+
listRef,
|
|
3801
|
+
rows,
|
|
3802
|
+
getListItemProps,
|
|
3803
|
+
header,
|
|
3804
|
+
loadingIndicator,
|
|
3805
|
+
};
|
|
3806
|
+
};
|
|
3807
|
+
|
|
3808
|
+
// Height constants (in pixels) - based on ListItem.variants.ts styling
|
|
3809
|
+
const MIN_ITEM_HEIGHT = 56; // min-h-14 = 56px (minimum height for ListItem)
|
|
3810
|
+
const VERTICAL_PADDING = 12 + 12; // py-3 = 12px top + 12px bottom = 24px
|
|
3811
|
+
const TITLE_LINE_HEIGHT = 20;
|
|
3812
|
+
const DESCRIPTION_LINE_HEIGHT = 16;
|
|
3813
|
+
const META_LINE_HEIGHT = 16 + 2; // 2px padding above meta line
|
|
3814
|
+
/**
|
|
3815
|
+
* Hook that provides a memoized getListItemHeight function.
|
|
3816
|
+
*
|
|
3817
|
+
* Calculates the estimated height of a ListItem based on its configuration.
|
|
3818
|
+
* This function adds up the heights of each visible line plus gaps between them.
|
|
3819
|
+
* Height values are placeholders and need to be measured in browser DevTools.
|
|
3820
|
+
*
|
|
3821
|
+
* @returns {object} An object containing the memoized getListItemHeight function
|
|
3822
|
+
* @example
|
|
3823
|
+
* ```tsx
|
|
3824
|
+
* const { getListItemHeight } = useListItemHeight();
|
|
3825
|
+
*
|
|
3826
|
+
* const estimateItemSize = () => getListItemHeight({
|
|
3827
|
+
* hasThumbnail: true,
|
|
3828
|
+
* hasDescription: true,
|
|
3829
|
+
* });
|
|
3830
|
+
* ```
|
|
3831
|
+
*/
|
|
3832
|
+
const useListItemHeight = () => {
|
|
3833
|
+
const getListItemHeight = react.useCallback(({ hasThumbnail: _hasThumbnail = false, // does not affect height
|
|
3834
|
+
hasDescription = false, hasMeta = false, hasDetails: _hasDetails = false, // does not affect height
|
|
3835
|
+
}) => {
|
|
3836
|
+
// Calculate content height (excluding padding)
|
|
3837
|
+
let contentHeight = TITLE_LINE_HEIGHT;
|
|
3838
|
+
// Add description line if present
|
|
3839
|
+
if (hasDescription) {
|
|
3840
|
+
contentHeight += DESCRIPTION_LINE_HEIGHT;
|
|
3841
|
+
}
|
|
3842
|
+
// Add meta line if present
|
|
3843
|
+
if (hasMeta) {
|
|
3844
|
+
contentHeight += META_LINE_HEIGHT;
|
|
3845
|
+
}
|
|
3846
|
+
// Add vertical padding
|
|
3847
|
+
const totalHeight = contentHeight + VERTICAL_PADDING;
|
|
3848
|
+
// Return the larger of calculated height or minimum height
|
|
3849
|
+
return Math.max(totalHeight, MIN_ITEM_HEIGHT);
|
|
3850
|
+
}, []);
|
|
3851
|
+
return {
|
|
3852
|
+
getListItemHeight,
|
|
3853
|
+
};
|
|
3485
3854
|
};
|
|
3486
3855
|
|
|
3487
3856
|
/**
|
|
@@ -4842,12 +5211,14 @@ exports.cvaToggleItemContent = cvaToggleItemContent;
|
|
|
4842
5211
|
exports.cvaToggleItemText = cvaToggleItemText;
|
|
4843
5212
|
exports.cvaZStackContainer = cvaZStackContainer;
|
|
4844
5213
|
exports.cvaZStackItem = cvaZStackItem;
|
|
5214
|
+
exports.defaultPageSize = defaultPageSize;
|
|
4845
5215
|
exports.docs = docs;
|
|
4846
5216
|
exports.getDevicePixelRatio = getDevicePixelRatio;
|
|
4847
5217
|
exports.getResponsiveRandomWidthPercentage = getResponsiveRandomWidthPercentage;
|
|
4848
5218
|
exports.getValueBarColorByValue = getValueBarColorByValue;
|
|
4849
5219
|
exports.iconColorNames = iconColorNames;
|
|
4850
5220
|
exports.iconPalette = iconPalette;
|
|
5221
|
+
exports.noPagination = noPagination;
|
|
4851
5222
|
exports.useClickOutside = useClickOutside;
|
|
4852
5223
|
exports.useContainerBreakpoints = useContainerBreakpoints;
|
|
4853
5224
|
exports.useContinuousTimeout = useContinuousTimeout;
|
|
@@ -4857,13 +5228,17 @@ exports.useElevatedReducer = useElevatedReducer;
|
|
|
4857
5228
|
exports.useElevatedState = useElevatedState;
|
|
4858
5229
|
exports.useGeometry = useGeometry;
|
|
4859
5230
|
exports.useHover = useHover;
|
|
5231
|
+
exports.useInfiniteScroll = useInfiniteScroll;
|
|
4860
5232
|
exports.useIsFirstRender = useIsFirstRender;
|
|
4861
5233
|
exports.useIsFullscreen = useIsFullscreen;
|
|
4862
5234
|
exports.useIsTextTruncated = useIsTextTruncated;
|
|
5235
|
+
exports.useList = useList;
|
|
5236
|
+
exports.useListItemHeight = useListItemHeight;
|
|
4863
5237
|
exports.useModifierKey = useModifierKey;
|
|
4864
5238
|
exports.useOverflowItems = useOverflowItems;
|
|
4865
5239
|
exports.usePopoverContext = usePopoverContext;
|
|
4866
5240
|
exports.usePrompt = usePrompt;
|
|
5241
|
+
exports.useRelayPagination = useRelayPagination;
|
|
4867
5242
|
exports.useResize = useResize;
|
|
4868
5243
|
exports.useScrollDetection = useScrollDetection;
|
|
4869
5244
|
exports.useSelfUpdatingRef = useSelfUpdatingRef;
|