@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.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,143 @@ 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
|
+
* ```
|
|
3395
3577
|
*/
|
|
3396
|
-
const List = ({
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3578
|
+
const List = ({ children, className, dataTestId, topSeparatorOnScroll = false, separator = "line",
|
|
3579
|
+
// UseListResult properties
|
|
3580
|
+
containerRef, listRef, rows, getListItemProps, header, loadingIndicator, shouldShowLoaderAtIndex, count, 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
|
+
// Use the total data count from useList, not visible rows (which are virtualized)
|
|
3599
|
+
const hasHeaderRow = !!header;
|
|
3600
|
+
const dataStartIndex = hasHeaderRow ? 1 : 0;
|
|
3601
|
+
const totalDataRows = dataStartIndex + count;
|
|
3602
|
+
const loaderIndex = row.virtualRow.index - totalDataRows;
|
|
3603
|
+
const shouldShowLoader = shouldShowLoaderAtIndex(loaderIndex);
|
|
3604
|
+
return (jsx("li", { ...listItemProps, children: shouldShowLoader ? jsx(ListLoadingIndicator, { ...loadingConfig }) : null }, key));
|
|
3605
|
+
}
|
|
3606
|
+
// Render header row
|
|
3607
|
+
if (row.type === "header") {
|
|
3608
|
+
return (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
|
+
const DEFAULT_LOADING_INDICATOR_CONFIG = {
|
|
3616
|
+
type: "skeleton",
|
|
3617
|
+
initialLoadingCount: 10,
|
|
3618
|
+
scrollLoadingCount: 3,
|
|
3619
|
+
};
|
|
3620
|
+
/**
|
|
3621
|
+
* A hook for managing virtualized list state and behavior.
|
|
3622
|
+
*
|
|
3623
|
+
* This hook encapsulates the logic for:
|
|
3624
|
+
* - Virtualizing list items using TanStack Virtual
|
|
3625
|
+
* - Managing infinite scroll pagination
|
|
3626
|
+
* - Handling header, data, and loading rows
|
|
3627
|
+
* - Calculating proper indices and measurements
|
|
3628
|
+
*
|
|
3629
|
+
* @example
|
|
3630
|
+
* ```tsx
|
|
3631
|
+
* const list = useList({
|
|
3632
|
+
* count: items.length,
|
|
3633
|
+
* getItem: index => items[index],
|
|
3634
|
+
* pagination,
|
|
3635
|
+
* header: <KPI title="Total" value={42} />,
|
|
3636
|
+
* estimateHeaderSize: () => 72, // Measure actual KPI height
|
|
3637
|
+
* estimateItemSize: () => 61, // Measure actual ListItem height
|
|
3638
|
+
* });
|
|
3639
|
+
*
|
|
3640
|
+
* return (
|
|
3641
|
+
* <div ref={list.containerRef}>
|
|
3642
|
+
* <ul style={{ height: `${list.getTotalSize()}px` }}>
|
|
3643
|
+
* {list.rows.map(row => {
|
|
3644
|
+
* const props = list.getListItemProps(row, { className: 'list-item' });
|
|
3645
|
+
* return <li {...props}>{row.item?.title}</li>;
|
|
3646
|
+
* })}
|
|
3647
|
+
* </ul>
|
|
3648
|
+
* </div>
|
|
3649
|
+
* );
|
|
3650
|
+
* ```
|
|
3651
|
+
*/
|
|
3652
|
+
const useList = ({ count, pagination, header, getItem, loadingIndicator = DEFAULT_LOADING_INDICATOR_CONFIG, onRowClick, onChange, estimateItemSize, estimateHeaderSize, overscan, }) => {
|
|
3653
|
+
const containerRef = useRef(null);
|
|
3654
|
+
const listRef = useRef(null);
|
|
3655
|
+
const rowRefsMap = useRef(new Map());
|
|
3656
|
+
// Calculate total count including header
|
|
3657
|
+
const hasHeader = Boolean(header);
|
|
3658
|
+
const dataStartIndex = hasHeader ? 1 : 0;
|
|
3659
|
+
const dataCount = count;
|
|
3660
|
+
const totalDataRows = dataStartIndex + dataCount;
|
|
3404
3661
|
// Calculate how many loading rows we need
|
|
3405
3662
|
const getLoadingRowsCount = useCallback(() => {
|
|
3406
|
-
const { type: loadingIndicatorType } = loadingIndicator;
|
|
3407
3663
|
if (pagination?.isLoading === false)
|
|
3408
3664
|
return 0;
|
|
3665
|
+
const { type: loadingIndicatorType } = loadingIndicator;
|
|
3409
3666
|
switch (loadingIndicatorType) {
|
|
3410
3667
|
case "none":
|
|
3411
3668
|
return 0;
|
|
@@ -3422,64 +3679,210 @@ const List = ({ count, pagination, children, className, dataTestId, separator =
|
|
|
3422
3679
|
throw new Error(`${loadingIndicatorType} is not known`);
|
|
3423
3680
|
}
|
|
3424
3681
|
}
|
|
3425
|
-
}, [loadingIndicator, pagination]);
|
|
3682
|
+
}, [loadingIndicator, pagination?.isLoading, pagination?.pageInfo]);
|
|
3683
|
+
// Helper to determine if a specific loader index should be shown
|
|
3684
|
+
const shouldShowLoaderAtIndex = useCallback((loaderIndex) => {
|
|
3685
|
+
if (pagination?.isLoading === false)
|
|
3686
|
+
return false;
|
|
3687
|
+
const { type: loadingIndicatorType } = loadingIndicator;
|
|
3688
|
+
let result;
|
|
3689
|
+
switch (loadingIndicatorType) {
|
|
3690
|
+
case "none":
|
|
3691
|
+
result = false;
|
|
3692
|
+
break;
|
|
3693
|
+
case "spinner":
|
|
3694
|
+
result = loaderIndex === 0;
|
|
3695
|
+
break;
|
|
3696
|
+
case "custom":
|
|
3697
|
+
case "skeleton": {
|
|
3698
|
+
const isInitialLoading = !pagination?.pageInfo;
|
|
3699
|
+
const initialCount = loadingIndicator.initialLoadingCount ?? 10;
|
|
3700
|
+
const scrollCount = loadingIndicator.scrollLoadingCount ?? 3;
|
|
3701
|
+
const maxCount = isInitialLoading ? initialCount : scrollCount;
|
|
3702
|
+
result = loaderIndex < maxCount;
|
|
3703
|
+
break;
|
|
3704
|
+
}
|
|
3705
|
+
default: {
|
|
3706
|
+
throw new Error(`${loadingIndicatorType} is not known`);
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
return result;
|
|
3710
|
+
}, [loadingIndicator, pagination?.isLoading, pagination?.pageInfo]);
|
|
3711
|
+
const totalRowCount = useMemo(() => {
|
|
3712
|
+
// Only add the exact number of loading rows we want to show
|
|
3713
|
+
const loadingRows = pagination?.isLoading === true ? getLoadingRowsCount() : 0;
|
|
3714
|
+
const total = totalDataRows + loadingRows;
|
|
3715
|
+
return total;
|
|
3716
|
+
}, [totalDataRows, pagination?.isLoading, getLoadingRowsCount]);
|
|
3717
|
+
// Estimate size for all rows (header, data, loading)
|
|
3426
3718
|
const estimateSize = useCallback((index) => {
|
|
3427
|
-
//
|
|
3428
|
-
if (index >=
|
|
3429
|
-
const loaderIndex = index -
|
|
3430
|
-
const shouldShowLoader =
|
|
3719
|
+
// Loading rows
|
|
3720
|
+
if (index >= totalDataRows) {
|
|
3721
|
+
const loaderIndex = index - totalDataRows;
|
|
3722
|
+
const shouldShowLoader = shouldShowLoaderAtIndex(loaderIndex);
|
|
3723
|
+
const estimatedHeight = shouldShowLoader ? estimateItemSize(0) : 0;
|
|
3431
3724
|
// Empty loader rows should be estimated at 0 height to prevent blank space
|
|
3432
|
-
return
|
|
3725
|
+
return estimatedHeight;
|
|
3433
3726
|
}
|
|
3434
|
-
//
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3727
|
+
// Header row (if exists, it's always at index 0)
|
|
3728
|
+
if (hasHeader && index === 0) {
|
|
3729
|
+
// Use dedicated header size estimate if provided, otherwise fall back to first item estimate
|
|
3730
|
+
return estimateHeaderSize ? estimateHeaderSize() : estimateItemSize(0);
|
|
3731
|
+
}
|
|
3732
|
+
// Data rows - calculate the data index
|
|
3733
|
+
const dataIndex = index - dataStartIndex;
|
|
3734
|
+
return estimateItemSize(dataIndex);
|
|
3735
|
+
}, [estimateItemSize, estimateHeaderSize, totalDataRows, shouldShowLoaderAtIndex, hasHeader, dataStartIndex]);
|
|
3736
|
+
// Set up virtualization
|
|
3737
|
+
const virtualizer = useInfiniteScroll({
|
|
3738
|
+
pagination: pagination ?? noPagination,
|
|
3739
|
+
scrollElementRef: containerRef,
|
|
3740
|
+
count: totalRowCount,
|
|
3441
3741
|
estimateSize,
|
|
3442
|
-
|
|
3742
|
+
overscan,
|
|
3743
|
+
onChange: instance => {
|
|
3744
|
+
if (listRef.current) {
|
|
3745
|
+
listRef.current.style.height = `${instance.getTotalSize()}px`;
|
|
3746
|
+
}
|
|
3747
|
+
// Apply transforms to all virtual items
|
|
3748
|
+
instance.getVirtualItems().forEach(virtualRow => {
|
|
3749
|
+
const rowRef = rowRefsMap.current.get(virtualRow.index);
|
|
3750
|
+
if (rowRef) {
|
|
3751
|
+
rowRef.style.transform = `translateY(${virtualRow.start}px)`;
|
|
3752
|
+
}
|
|
3753
|
+
});
|
|
3754
|
+
onChange?.(instance);
|
|
3755
|
+
},
|
|
3443
3756
|
});
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
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
|
|
3757
|
+
// Measure the list on mount
|
|
3758
|
+
useLayoutEffect(() => {
|
|
3759
|
+
// Automatically measure if header is present, or if explicitly requested
|
|
3760
|
+
virtualizer.measure();
|
|
3761
|
+
// This is straight out of the TanStack Virtual docs, so we'll trust it.
|
|
3762
|
+
// eslint-disable-next-line react-hooks/react-compiler
|
|
3763
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3764
|
+
}, []);
|
|
3765
|
+
// Transform virtual items into typed rows
|
|
3766
|
+
const rows = useMemo(() => {
|
|
3767
|
+
const virtualItems = virtualizer.getVirtualItems();
|
|
3768
|
+
return virtualItems.map((virtualRow) => {
|
|
3769
|
+
const { index } = virtualRow;
|
|
3770
|
+
// Determine row type
|
|
3771
|
+
const isLoaderRow = index >= totalDataRows;
|
|
3772
|
+
const isHeaderRow = hasHeader && index === 0;
|
|
3773
|
+
if (isLoaderRow) {
|
|
3774
|
+
return {
|
|
3775
|
+
type: "loader",
|
|
3776
|
+
virtualRow,
|
|
3777
|
+
dataIndex: -1,
|
|
3470
3778
|
};
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3779
|
+
}
|
|
3780
|
+
if (isHeaderRow) {
|
|
3781
|
+
return {
|
|
3782
|
+
type: "header",
|
|
3783
|
+
virtualRow,
|
|
3784
|
+
dataIndex: -1,
|
|
3785
|
+
};
|
|
3786
|
+
}
|
|
3787
|
+
// Data row
|
|
3788
|
+
const dataIndex = index - dataStartIndex;
|
|
3789
|
+
const item = getItem(dataIndex);
|
|
3790
|
+
return {
|
|
3791
|
+
type: "data",
|
|
3792
|
+
virtualRow,
|
|
3793
|
+
item,
|
|
3794
|
+
dataIndex,
|
|
3795
|
+
};
|
|
3796
|
+
});
|
|
3797
|
+
// Call getVirtualItems() fresh each time to get updated measurements after virtualizer state changes
|
|
3798
|
+
}, [virtualizer, totalDataRows, hasHeader, dataStartIndex, getItem]);
|
|
3799
|
+
// Helper to create list item props
|
|
3800
|
+
const getListItemProps = useCallback((row, options) => {
|
|
3801
|
+
const { type, item, dataIndex } = row;
|
|
3802
|
+
// Handle click for data rows
|
|
3803
|
+
const handleClick = type === "data" && onRowClick && item !== undefined
|
|
3804
|
+
? () => {
|
|
3805
|
+
onRowClick({ item, index: dataIndex });
|
|
3806
|
+
}
|
|
3807
|
+
: undefined;
|
|
3808
|
+
const onClick = handleClick ?? options.onClick;
|
|
3809
|
+
return {
|
|
3810
|
+
className: options.className,
|
|
3811
|
+
"data-index": row.virtualRow.index,
|
|
3812
|
+
onClick,
|
|
3813
|
+
ref: el => {
|
|
3814
|
+
if (el) {
|
|
3815
|
+
virtualizer.measureElement(el);
|
|
3816
|
+
rowRefsMap.current.set(row.virtualRow.index, el);
|
|
3817
|
+
// Apply transform immediately when element is added to map
|
|
3818
|
+
const virtualRow = virtualizer.getVirtualItems().find(vr => vr.index === row.virtualRow.index);
|
|
3819
|
+
if (virtualRow) {
|
|
3820
|
+
el.style.transform = `translateY(${virtualRow.start}px)`;
|
|
3821
|
+
}
|
|
3478
3822
|
}
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3823
|
+
},
|
|
3824
|
+
tabIndex: -1,
|
|
3825
|
+
};
|
|
3826
|
+
}, [onRowClick, virtualizer]);
|
|
3827
|
+
return {
|
|
3828
|
+
...virtualizer,
|
|
3829
|
+
containerRef,
|
|
3830
|
+
listRef,
|
|
3831
|
+
rows,
|
|
3832
|
+
getListItemProps,
|
|
3833
|
+
header,
|
|
3834
|
+
loadingIndicator,
|
|
3835
|
+
shouldShowLoaderAtIndex,
|
|
3836
|
+
count,
|
|
3837
|
+
};
|
|
3838
|
+
};
|
|
3839
|
+
|
|
3840
|
+
// Height constants (in pixels) - based on ListItem.variants.ts styling
|
|
3841
|
+
const MIN_ITEM_HEIGHT = 56; // min-h-14 = 56px (minimum height for ListItem)
|
|
3842
|
+
const VERTICAL_PADDING = 12 + 12; // py-3 = 12px top + 12px bottom = 24px
|
|
3843
|
+
const TITLE_LINE_HEIGHT = 20;
|
|
3844
|
+
const DESCRIPTION_LINE_HEIGHT = 16;
|
|
3845
|
+
const META_LINE_HEIGHT = 16 + 2; // 2px padding above meta line
|
|
3846
|
+
/**
|
|
3847
|
+
* Hook that provides a memoized getListItemHeight function.
|
|
3848
|
+
*
|
|
3849
|
+
* Calculates the estimated height of a ListItem based on its configuration.
|
|
3850
|
+
* This function adds up the heights of each visible line plus gaps between them.
|
|
3851
|
+
* Height values are placeholders and need to be measured in browser DevTools.
|
|
3852
|
+
*
|
|
3853
|
+
* @returns {object} An object containing the memoized getListItemHeight function
|
|
3854
|
+
* @example
|
|
3855
|
+
* ```tsx
|
|
3856
|
+
* const { getListItemHeight } = useListItemHeight();
|
|
3857
|
+
*
|
|
3858
|
+
* const estimateItemSize = () => getListItemHeight({
|
|
3859
|
+
* hasThumbnail: true,
|
|
3860
|
+
* hasDescription: true,
|
|
3861
|
+
* });
|
|
3862
|
+
* ```
|
|
3863
|
+
*/
|
|
3864
|
+
const useListItemHeight = () => {
|
|
3865
|
+
const getListItemHeight = useCallback(({ hasThumbnail: _hasThumbnail = false, // does not affect height
|
|
3866
|
+
hasDescription = false, hasMeta = false, hasDetails: _hasDetails = false, // does not affect height
|
|
3867
|
+
}) => {
|
|
3868
|
+
// Calculate content height (excluding padding)
|
|
3869
|
+
let contentHeight = TITLE_LINE_HEIGHT;
|
|
3870
|
+
// Add description line if present
|
|
3871
|
+
if (hasDescription) {
|
|
3872
|
+
contentHeight += DESCRIPTION_LINE_HEIGHT;
|
|
3873
|
+
}
|
|
3874
|
+
// Add meta line if present
|
|
3875
|
+
if (hasMeta) {
|
|
3876
|
+
contentHeight += META_LINE_HEIGHT;
|
|
3877
|
+
}
|
|
3878
|
+
// Add vertical padding
|
|
3879
|
+
const totalHeight = contentHeight + VERTICAL_PADDING;
|
|
3880
|
+
// Return the larger of calculated height or minimum height
|
|
3881
|
+
return Math.max(totalHeight, MIN_ITEM_HEIGHT);
|
|
3882
|
+
}, []);
|
|
3883
|
+
return {
|
|
3884
|
+
getListItemHeight,
|
|
3885
|
+
};
|
|
3483
3886
|
};
|
|
3484
3887
|
|
|
3485
3888
|
/**
|
|
@@ -4744,4 +5147,4 @@ const cvaClickable = cvaMerge([
|
|
|
4744
5147
|
},
|
|
4745
5148
|
});
|
|
4746
5149
|
|
|
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 };
|
|
5150
|
+
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.21",
|
|
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",
|