@trackunit/react-components 1.9.19 → 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.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
|
|
3397
|
-
const
|
|
3398
|
-
|
|
3399
|
-
const
|
|
3400
|
-
//
|
|
3401
|
-
const
|
|
3402
|
-
|
|
3403
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
3428
|
-
if (index >=
|
|
3429
|
-
const loaderIndex = index -
|
|
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 ?
|
|
3686
|
+
return shouldShowLoader ? estimateItemSize(0) : 0;
|
|
3433
3687
|
}
|
|
3434
|
-
//
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
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
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
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
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
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.
|
|
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.
|
|
20
|
-
"@trackunit/css-class-variance-utilities": "1.7.
|
|
21
|
-
"@trackunit/shared-utils": "1.9.
|
|
22
|
-
"@trackunit/ui-icons": "1.7.
|
|
23
|
-
"@trackunit/react-
|
|
24
|
-
"@trackunit/react-test-setup": "1.4.11",
|
|
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",
|