@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 +472 -63
- package/index.esm.js +467 -64
- package/package.json +8 -8
- package/src/components/List/List.d.ts +64 -85
- package/src/components/List/useList.d.ts +171 -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,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 = ({
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
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
|
-
//
|
|
3430
|
-
if (index >=
|
|
3431
|
-
const loaderIndex = index -
|
|
3432
|
-
const shouldShowLoader =
|
|
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
|
|
3727
|
+
return estimatedHeight;
|
|
3435
3728
|
}
|
|
3436
|
-
//
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
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
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
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
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
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;
|