// Component adapted from https://github.com/bvaughn/react-window/issues/826 import clsx from 'clsx'; import { motion } from 'motion/react'; import React, { type JSXElementConstructor, memo, ReactElement, Ref, RefObject, useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore, } from 'react'; import { useParams } from 'react-router'; import { type CellComponentProps, Grid } from 'react-window-v2'; import styles from './item-table-list.module.css'; import { appendLayoutFillColumn } from '/@/renderer/components/item-list/helpers/append-layout-fill-column'; import { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id'; import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls'; import { ItemListStateActions, ItemListStateItemWithRequiredProperties, useItemListState, } from '/@/renderer/components/item-list/helpers/item-list-state'; import { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns'; import { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys'; import { useContainerWidthTracking } from '/@/renderer/components/item-list/item-table-list/hooks/use-container-width-tracking'; import { useRowInteractionDelegate } from '/@/renderer/components/item-list/item-table-list/hooks/use-row-interaction-delegate'; import { useStickyGroupRowPositioning } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-group-row-positioning'; import { useStickyHeaderPositioning } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-header-positioning'; import { useStickyTableGroupRows } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-table-group-rows'; import { useStickyTableHeader } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-table-header'; import { useTableColumnModel } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-column-model'; import { useTableImperativeHandle } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-imperative-handle'; import { useTableInitialScroll } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-initial-scroll'; import { useTableKeyboardNavigation } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-keyboard-navigation'; import { useTablePaneSync } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-pane-sync'; import { useTableRowModel } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-row-model'; import { useTableScrollToIndex } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index'; import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; import { ItemTableListColumnResizeLiveProvider, type ItemTableListConfig, ItemTableListConfigProvider, ItemTableListStoreProvider, useItemTableListColumnResizeLiveState, } from '/@/renderer/components/item-list/item-table-list/item-table-list-context'; import { MemoizedCellRouter, useColumnCellComponents, } from '/@/renderer/components/item-list/item-table-list/memoized-cell-router'; import { createTableScrollShadowStore, type TableScrollShadowStore, } from '/@/renderer/components/item-list/item-table-list/table-scroll-shadow-store'; import { ItemControls, ItemListHandle, ItemTableListColumnConfig, } from '/@/renderer/components/item-list/types'; import { PlayerContext, usePlayer } from '/@/renderer/features/player/context/player-context'; import { animationProps } from '/@/shared/components/animations/animation-props'; import { useFocusWithin } from '/@/shared/hooks/use-focus-within'; import { useMergedRef } from '/@/shared/hooks/use-merged-ref'; import { LibraryItem } from '/@/shared/types/domain-types'; import { TableColumn } from '/@/shared/types/types'; /** * Type guard to check if an item has the required properties (id and serverId) * Similar to the type guard used in ItemCard */ const hasRequiredItemProperties = (item: unknown): item is { id: string; serverId: string } => { return ( typeof item === 'object' && item !== null && 'id' in item && typeof (item as any).id === 'string' && '_serverId' in item && typeof (item as any)._serverId === 'string' ); }; /** * Type guard to check if an item has the required properties for ItemListStateItemWithRequiredProperties */ const hasRequiredStateItemProperties = ( item: unknown, ): item is ItemListStateItemWithRequiredProperties => { return ( typeof item === 'object' && item !== null && 'id' in item && typeof (item as any).id === 'string' && '_serverId' in item && typeof (item as any)._serverId === 'string' && '_itemType' in item && typeof (item as any)._itemType === 'string' ); }; export enum TableItemSize { COMPACT = 40, DEFAULT = 64, LARGE = 88, } const ItemTableScrollShadowTop = memo(function ItemTableScrollShadowTop({ enableHeader, enableScrollShadow, scrollShadowStore, }: { enableHeader: boolean; enableScrollShadow: boolean; scrollShadowStore: TableScrollShadowStore; }) { const { showTopShadow } = useSyncExternalStore( scrollShadowStore.subscribe, scrollShadowStore.getSnapshot, ); if (!enableHeader || !enableScrollShadow || !showTopShadow) return null; return
; }); ItemTableScrollShadowTop.displayName = 'ItemTableScrollShadowTop'; const ItemTableScrollShadowLeft = memo(function ItemTableScrollShadowLeft({ enableScrollShadow, pinnedLeftColumnCount, scrollShadowStore, }: { enableScrollShadow: boolean; pinnedLeftColumnCount: number; scrollShadowStore: TableScrollShadowStore; }) { const { showLeftShadow } = useSyncExternalStore( scrollShadowStore.subscribe, scrollShadowStore.getSnapshot, ); if (pinnedLeftColumnCount <= 0 || !enableScrollShadow || !showLeftShadow) return null; return
; }); ItemTableScrollShadowLeft.displayName = 'ItemTableScrollShadowLeft'; const ItemTableScrollShadowRight = memo(function ItemTableScrollShadowRight({ enableScrollShadow, pinnedRightColumnCount, scrollShadowStore, }: { enableScrollShadow: boolean; pinnedRightColumnCount: number; scrollShadowStore: TableScrollShadowStore; }) { const { showRightShadow } = useSyncExternalStore( scrollShadowStore.subscribe, scrollShadowStore.getSnapshot, ); if (pinnedRightColumnCount <= 0 || !enableScrollShadow || !showRightShadow) return null; return
; }); ItemTableScrollShadowRight.displayName = 'ItemTableScrollShadowRight'; interface VirtualizedTableGridProps { calculatedColumnWidths: number[]; CellComponent: JSXElementConstructor>; data: unknown[]; dataWithGroups: (null | unknown)[]; enableScrollShadow: boolean; getItem?: (index: number) => undefined | unknown; headerHeight: number; mergedRowRef: React.Ref; onRangeChanged?: ItemTableListProps['onRangeChanged']; parsedColumns: ReturnType; pinnedLeftColumnCount: number; pinnedLeftColumnRef: React.RefObject; pinnedRightColumnCount: number; pinnedRightColumnRef: React.RefObject; pinnedRowCount: number; pinnedRowRef: React.RefObject; scrollShadowStore: TableScrollShadowStore; tableConfig: ItemTableListConfig; totalColumnCount: number; totalRowCount: number; } const VirtualizedTableGrid = ({ calculatedColumnWidths, CellComponent, data, dataWithGroups, enableScrollShadow, getItem, headerHeight, mergedRowRef, onRangeChanged, parsedColumns, pinnedLeftColumnCount, pinnedLeftColumnRef, pinnedRightColumnCount, pinnedRightColumnRef, pinnedRowCount, pinnedRowRef, scrollShadowStore, tableConfig, totalColumnCount, totalRowCount, }: VirtualizedTableGridProps) => { const { enableHeader, enableRowHoverHighlight, getRowHeight, groups } = tableConfig; const hoverDelegateRef = useRef(null); useRowInteractionDelegate({ containerRef: hoverDelegateRef, enableRowHoverHighlight, }); const columnWidth = useCallback( (index: number) => calculatedColumnWidths[index], [calculatedColumnWidths], ); const columnWidthMemoized = useCallback( (index: number) => columnWidth(index + pinnedLeftColumnCount), [columnWidth, pinnedLeftColumnCount], ); const groupHeaderInfoByRowIndex = useMemo(() => { if (!groups || groups.length === 0) return undefined; const map = new Map(); const headerOffset = enableHeader ? 1 : 0; let cumulativeDataIndex = 0; for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) { const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex; map.set(groupHeaderIndex, { groupIndex, startDataIndex: cumulativeDataIndex }); cumulativeDataIndex += groups[groupIndex].itemCount; } return map; }, [groups, enableHeader]); const rowHeightMemoized = useCallback( (index: number, cellProps: TableItemProps) => { const adjustedIndex = index + pinnedRowCount; return getRowHeight(adjustedIndex, cellProps); }, [getRowHeight, pinnedRowCount], ); const pinnedRightColumnWidthMemoized = useCallback( (index: number) => columnWidth(index + pinnedLeftColumnCount + totalColumnCount), [columnWidth, pinnedLeftColumnCount, totalColumnCount], ); const getGroupRenderData = useCallback(() => data, [data]); // Calculate pinned column widths for group header positioning const pinnedLeftColumnWidths = useMemo(() => { return Array.from({ length: pinnedLeftColumnCount }, (_, i) => columnWidth(i)); }, [pinnedLeftColumnCount, columnWidth]); const pinnedRightColumnWidths = useMemo(() => { return Array.from({ length: pinnedRightColumnCount }, (_, i) => columnWidth(i + pinnedLeftColumnCount + totalColumnCount), ); }, [pinnedRightColumnCount, pinnedLeftColumnCount, totalColumnCount, columnWidth]); const groupHeaderRowIndexes = useMemo(() => { if (!groupHeaderInfoByRowIndex || groupHeaderInfoByRowIndex.size === 0) return []; return Array.from(groupHeaderInfoByRowIndex.keys()).sort((a, b) => a - b); }, [groupHeaderInfoByRowIndex]); const adjustedRowIndexCacheRef = useRef<{ lastRowIndex: number; pos: number }>({ lastRowIndex: -1, pos: 0, }); useEffect(() => { adjustedRowIndexCacheRef.current = { lastRowIndex: -1, pos: 0 }; }, [enableHeader, groupHeaderRowIndexes, groups]); const getAdjustedRowIndex = useCallback( (rowIndex: number) => { if (!groups || groups.length === 0) { if (enableHeader && rowIndex === 0) return 0; return enableHeader ? rowIndex : rowIndex + 1; } if (enableHeader && rowIndex === 0) return 0; if (groupHeaderInfoByRowIndex?.has(rowIndex)) return 0; const headerOffset = enableHeader ? 1 : 0; const cache = adjustedRowIndexCacheRef.current; // Count group header rows strictly before this rowIndex. let pos: number; if (cache.lastRowIndex !== -1 && rowIndex >= cache.lastRowIndex) { pos = cache.pos; while ( pos < groupHeaderRowIndexes.length && groupHeaderRowIndexes[pos] < rowIndex ) { pos++; } } else { // upperBound(groupHeaderRowIndexes, rowIndex - 1) let lo = 0; let hi = groupHeaderRowIndexes.length; const target = rowIndex - 1; while (lo < hi) { const mid = (lo + hi) >>> 1; if (groupHeaderRowIndexes[mid] <= target) lo = mid + 1; else hi = mid; } pos = lo; } cache.lastRowIndex = rowIndex; cache.pos = pos; const groupHeadersBefore = pos; const dataIndexZeroBased = rowIndex - headerOffset - groupHeadersBefore; return dataIndexZeroBased + 1; }, [enableHeader, groupHeaderInfoByRowIndex, groupHeaderRowIndexes, groups], ); const getRowItem = useCallback( (rowIndex: number): null | undefined | unknown => { // Header row if (enableHeader && rowIndex === 0) return null; // Group header rows are represented as null in the row model if (groupHeaderInfoByRowIndex?.has(rowIndex)) return null; if (!groups || groups.length === 0) { const dataIndex = enableHeader ? rowIndex - 1 : rowIndex; return getItem ? getItem(dataIndex) : dataWithGroups[rowIndex]; } const headerOffset = enableHeader ? 1 : 0; // Count group header rows strictly before this rowIndex (upperBound on groupHeaderRowIndexes) let lo = 0; let hi = groupHeaderRowIndexes.length; const target = rowIndex - 1; while (lo < hi) { const mid = (lo + hi) >>> 1; if (groupHeaderRowIndexes[mid] <= target) lo = mid + 1; else hi = mid; } const groupHeadersBefore = lo; const dataIndex = rowIndex - headerOffset - groupHeadersBefore; return getItem ? getItem(dataIndex) : undefined; }, [ dataWithGroups, enableHeader, getItem, groupHeaderInfoByRowIndex, groupHeaderRowIndexes, groups, ], ); const gridOnlyProps = useMemo( () => ({ calculatedColumnWidths, data: dataWithGroups, getAdjustedRowIndex, getGroupRenderData, getRowItem, groupHeaderInfoByRowIndex, hasAlbumGroupColumn: parsedColumns.some((col) => col.id === TableColumn.ALBUM_GROUP), pinnedLeftColumnCount, pinnedLeftColumnWidths, pinnedRightColumnCount, pinnedRightColumnWidths, }), [ calculatedColumnWidths, dataWithGroups, getRowItem, getAdjustedRowIndex, getGroupRenderData, groupHeaderInfoByRowIndex, parsedColumns, pinnedLeftColumnCount, pinnedLeftColumnWidths, pinnedRightColumnCount, pinnedRightColumnWidths, ], ); const itemProps: TableItemProps = useMemo( () => ({ cellPadding: tableConfig.cellPadding, columns: tableConfig.columns, controls: tableConfig.controls, enableAlternateRowColors: tableConfig.enableAlternateRowColors, enableColumnReorder: tableConfig.enableColumnReorder, enableColumnResize: tableConfig.enableColumnResize, enableDrag: tableConfig.enableDrag, enableExpansion: tableConfig.enableExpansion, enableHeader: tableConfig.enableHeader, enableHorizontalBorders: tableConfig.enableHorizontalBorders, enableRowHoverHighlight: tableConfig.enableRowHoverHighlight, enableSelection: tableConfig.enableSelection, enableVerticalBorders: tableConfig.enableVerticalBorders, getRowHeight: tableConfig.getRowHeight, groups: tableConfig.groups, internalState: tableConfig.internalState, itemType: tableConfig.itemType, playerContext: tableConfig.playerContext, playlistId: tableConfig.playlistId, size: tableConfig.size, startRowIndex: tableConfig.startRowIndex, tableId: tableConfig.tableId, ...gridOnlyProps, }), [gridOnlyProps, tableConfig], ); const pinnedLeftGridMinWidthPx = useMemo(() => { let sum = 0; for (let i = 0; i < pinnedLeftColumnCount; i++) { sum += calculatedColumnWidths[i] ?? 0; } return sum; }, [calculatedColumnWidths, pinnedLeftColumnCount]); const pinnedRightGridMinWidthPx = useMemo(() => { let sum = 0; const start = pinnedLeftColumnCount + totalColumnCount; for (let i = 0; i < pinnedRightColumnCount; i++) { sum += calculatedColumnWidths[start + i] ?? 0; } return sum; }, [calculatedColumnWidths, pinnedLeftColumnCount, pinnedRightColumnCount, totalColumnCount]); const pinnedRowsMinHeightPx = useMemo(() => { let sum = 0; for (let i = 0; i < pinnedRowCount; i++) { sum += getRowHeight(i, itemProps); } return sum; }, [getRowHeight, itemProps, pinnedRowCount]); const PinnedRowCell = useCallback( (cellProps: CellComponentProps & TableItemProps) => { return ( ); }, [pinnedLeftColumnCount, CellComponent], ); const PinnedColumnCell = useCallback( (cellProps: CellComponentProps & TableItemProps) => { return ; }, [pinnedRowCount, CellComponent], ); const PinnedRightColumnCell = useCallback( (cellProps: CellComponentProps & TableItemProps) => { return ( ); }, [pinnedLeftColumnCount, pinnedRowCount, totalColumnCount, CellComponent], ); const PinnedRightIntersectionCell = useCallback( (cellProps: CellComponentProps & TableItemProps) => { return ( ); }, [pinnedLeftColumnCount, totalColumnCount, CellComponent], ); const RowCell = useCallback( (cellProps: CellComponentProps) => { return ( ); }, [pinnedLeftColumnCount, pinnedRowCount, CellComponent], ); const handleOnCellsRendered = useCallback( (items: { columnStartIndex: number; columnStopIndex: number; rowStartIndex: number; rowStopIndex: number; }) => { onRangeChanged?.({ startIndex: items.rowStartIndex, stopIndex: items.rowStopIndex, }); }, [onRangeChanged], ); return (
{!!(pinnedLeftColumnCount || pinnedRowCount) && (
)} {!!pinnedLeftColumnCount && (
{ return getRowHeight(index + pinnedRowCount, cellProps); }} />
)}
{!!pinnedRowCount && (
{ return columnWidth(index + pinnedLeftColumnCount); }} rowCount={pinnedRowCount} rowHeight={getRowHeight} />
)}
{!!pinnedRightColumnCount && (
{!!(pinnedRightColumnCount || pinnedRowCount) && (
{ return columnWidth( index + pinnedLeftColumnCount + totalColumnCount, ); }} rowCount={pinnedRowCount} rowHeight={getRowHeight} />
)}
)}
); }; VirtualizedTableGrid.displayName = 'VirtualizedTableGrid'; function shallowEqualNumberArrays(a: number[], b: number[]): boolean { if (a === b) return true; if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false; } return true; } const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, nextProps) => { return ( shallowEqualNumberArrays( prevProps.calculatedColumnWidths, nextProps.calculatedColumnWidths, ) && prevProps.tableConfig === nextProps.tableConfig && prevProps.data === nextProps.data && prevProps.dataWithGroups === nextProps.dataWithGroups && prevProps.enableScrollShadow === nextProps.enableScrollShadow && prevProps.getItem === nextProps.getItem && prevProps.headerHeight === nextProps.headerHeight && prevProps.mergedRowRef === nextProps.mergedRowRef && prevProps.onRangeChanged === nextProps.onRangeChanged && prevProps.parsedColumns === nextProps.parsedColumns && prevProps.pinnedLeftColumnCount === nextProps.pinnedLeftColumnCount && prevProps.pinnedLeftColumnRef === nextProps.pinnedLeftColumnRef && prevProps.pinnedRightColumnCount === nextProps.pinnedRightColumnCount && prevProps.pinnedRightColumnRef === nextProps.pinnedRightColumnRef && prevProps.pinnedRowCount === nextProps.pinnedRowCount && prevProps.pinnedRowRef === nextProps.pinnedRowRef && prevProps.scrollShadowStore === nextProps.scrollShadowStore && prevProps.totalColumnCount === nextProps.totalColumnCount && prevProps.totalRowCount === nextProps.totalRowCount && prevProps.CellComponent === nextProps.CellComponent ); }); MemoizedVirtualizedTableGrid.displayName = 'MemoizedVirtualizedTableGrid'; export interface TableGroupHeader { itemCount: number; render: (props: { data: unknown[]; groupIndex: number; index: number; internalState: ItemListStateActions; startDataIndex: number; }) => ReactElement; } export interface TableItemProps { adjustedRowIndexMap?: Map; calculatedColumnWidths?: number[]; cellPadding?: ItemTableListProps['cellPadding']; columns: ItemTableListColumnConfig[]; controls: ItemControls; data: ItemTableListProps['data']; enableAlternateRowColors?: ItemTableListProps['enableAlternateRowColors']; enableColumnReorder?: boolean; enableColumnResize?: boolean; enableDrag?: ItemTableListProps['enableDrag']; enableDragScroll?: boolean; enableExpansion?: ItemTableListProps['enableExpansion']; enableHeader?: ItemTableListProps['enableHeader']; enableHorizontalBorders?: ItemTableListProps['enableHorizontalBorders']; enableRowHoverHighlight?: ItemTableListProps['enableRowHoverHighlight']; enableSelection?: ItemTableListProps['enableSelection']; enableVerticalBorders?: ItemTableListProps['enableVerticalBorders']; getAdjustedRowIndex?: (rowIndex: number) => number; getGroupRenderData?: () => unknown[]; getRowHeight: (index: number, cellProps: TableItemProps) => number; getRowItem?: (rowIndex: number) => null | undefined | unknown; groupHeaderInfoByRowIndex?: Map; groups?: TableGroupHeader[]; hasAlbumGroupColumn?: boolean; internalState: ItemListStateActions; itemType: ItemTableListProps['itemType']; onRowClick?: (item: any, event: React.MouseEvent) => void; pinnedLeftColumnCount?: number; pinnedLeftColumnWidths?: number[]; pinnedRightColumnCount?: number; pinnedRightColumnWidths?: number[]; playerContext: PlayerContext; playlistId?: string; size?: ItemTableListProps['size']; startRowIndex?: number; tableId: string; } interface ItemTableListProps { activeRowId?: string; autoFitColumns?: boolean; CellComponent?: JSXElementConstructor>; cellPadding?: 'lg' | 'md' | 'sm' | 'xl' | 'xs'; columns: ItemTableListColumnConfig[]; data: unknown[]; enableAlternateRowColors?: boolean; enableDrag?: boolean; enableDragScroll?: boolean; enableEntranceAnimation?: boolean; enableExpansion?: boolean; enableHeader?: boolean; enableHorizontalBorders?: boolean; enableRowHoverHighlight?: boolean; enableScrollShadow?: boolean; enableSelection?: boolean; enableSelectionDialog?: boolean; enableStickyGroupRows?: boolean; enableStickyHeader?: boolean; enableVerticalBorders?: boolean; getItem?: (index: number) => undefined | unknown; getItemIndex?: (rowId: string) => number | undefined; getRowId?: ((item: unknown) => string) | string; groups?: TableGroupHeader[]; headerHeight?: number; initialTop?: { behavior?: 'auto' | 'smooth'; to: number; type: 'index' | 'offset'; }; itemCount?: number; itemType: LibraryItem; onColumnReordered?: ( columnIdFrom: TableColumn, columnIdTo: TableColumn, edge: 'bottom' | 'left' | 'right' | 'top' | null, ) => void; onColumnResized?: (columnId: TableColumn, width: number) => void; onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => void; onScrollEnd?: (offset: number, internalState: ItemListStateActions) => void; overrideControls?: Partial; ref?: Ref; rowHeight?: ((index: number, cellProps: TableItemProps) => number) | number; size?: 'compact' | 'default' | 'large'; startRowIndex?: number; } const ItemTableListStickyUI = memo( ({ calculatedColumnWidths, CellComponent, containerRef, data, enableHeader, enableStickyGroupRows, enableStickyHeader, getRowHeightWrapper, groups, headerHeight, internalState, parsedColumns, pinnedLeftColumnCount, pinnedLeftColumnRef, pinnedRightColumnCount, pinnedRightColumnRef, pinnedRowRef, rowHeight, rowRef, size, stickyHeaderItemProps, totalColumnCount, }: { calculatedColumnWidths: number[]; CellComponent: JSXElementConstructor>; containerRef: RefObject; data: unknown[]; enableHeader: boolean; enableStickyGroupRows: boolean; enableStickyHeader: boolean; getRowHeightWrapper: (index: number) => number; groups?: TableGroupHeader[]; headerHeight: number; internalState: ItemListStateActions; parsedColumns: ReturnType; pinnedLeftColumnCount: number; pinnedLeftColumnRef: RefObject; pinnedRightColumnCount: number; pinnedRightColumnRef: RefObject; pinnedRowRef: RefObject; rowHeight?: ((index: number, cellProps: TableItemProps) => number) | number; rowRef: RefObject; size: 'compact' | 'default' | 'large'; stickyHeaderItemProps: TableItemProps; totalColumnCount: number; }) => { const stickyHeaderRef = useRef(null); const stickyGroupRowRef = useRef(null); const stickyHeaderLeftRef = useRef(null); const stickyHeaderMainRef = useRef(null); const stickyHeaderRightRef = useRef(null); const { shouldShowStickyHeader, stickyTop } = useStickyTableHeader({ containerRef, enabled: enableHeader && enableStickyHeader, headerRef: pinnedRowRef, mainGridRef: rowRef, pinnedLeftColumnRef, pinnedRightColumnRef, stickyHeaderMainRef, }); useStickyHeaderPositioning({ containerRef, shouldShowStickyHeader, stickyHeaderRef, }); const { shouldShowStickyGroupRow, stickyGroupIndex, stickyTop: stickyGroupTop, } = useStickyTableGroupRows({ containerRef, enabled: enableStickyGroupRows && !!groups && groups.length > 0, getRowHeight: getRowHeightWrapper, groups, headerHeight, mainGridRef: rowRef, shouldShowStickyHeader, stickyHeaderTop: stickyTop, }); const shouldRenderStickyGroupRow = shouldShowStickyGroupRow; useStickyGroupRowPositioning({ containerRef, shouldRenderStickyGroupRow, stickyGroupRowRef, }); const StickyHeader = useMemo(() => { if (!shouldShowStickyHeader || !enableHeader) { return null; } const pinnedLeftWidth = calculatedColumnWidths .slice(0, pinnedLeftColumnCount) .reduce((sum, width) => sum + width, 0); const mainWidth = calculatedColumnWidths .slice(pinnedLeftColumnCount, pinnedLeftColumnCount + totalColumnCount) .reduce((sum, width) => sum + width, 0); const pinnedRightWidth = calculatedColumnWidths .slice(pinnedLeftColumnCount + totalColumnCount) .reduce((sum, width) => sum + width, 0); return (
{pinnedLeftColumnCount > 0 && (
{parsedColumns .filter((col) => col.pinned === 'left') .map((col) => { const columnIndex = parsedColumns.findIndex( (c) => c === col, ); return ( ); })}
)}
{parsedColumns .filter((col) => col.pinned === null) .map((col) => { const columnIndex = parsedColumns.findIndex( (c) => c === col, ); return ( ); })}
{pinnedRightColumnCount > 0 && (
{parsedColumns .filter((col) => col.pinned === 'right') .map((col) => { const columnIndex = parsedColumns.findIndex( (c) => c === col, ); return ( ); })}
)}
); }, [ shouldShowStickyHeader, enableHeader, stickyTop, calculatedColumnWidths, pinnedLeftColumnCount, pinnedRightColumnCount, totalColumnCount, parsedColumns, headerHeight, CellComponent, stickyHeaderItemProps, ]); const groupRowHeight = useMemo(() => { if (stickyGroupIndex === null || !groups) { const height = size === 'compact' ? 40 : size === 'large' ? 88 : 64; return typeof rowHeight === 'number' ? rowHeight : height; } let cumulativeDataIndex = 0; const headerOffset = enableHeader ? 1 : 0; for (let i = 0; i < stickyGroupIndex; i++) { cumulativeDataIndex += groups[i].itemCount; } const groupHeaderIndex = headerOffset + cumulativeDataIndex + stickyGroupIndex; return getRowHeightWrapper(groupHeaderIndex); }, [stickyGroupIndex, groups, getRowHeightWrapper, enableHeader, rowHeight, size]); const StickyGroupRow = useMemo(() => { if (!shouldRenderStickyGroupRow || stickyGroupIndex === null || !groups) { return null; } const group = groups[stickyGroupIndex]; const originalData = data.filter((item) => item !== null); let cumulativeDataIndex = 0; for (let i = 0; i < stickyGroupIndex; i++) { cumulativeDataIndex += groups[i].itemCount; } const groupContent = group.render({ data: originalData, groupIndex: stickyGroupIndex, index: 0, internalState, startDataIndex: cumulativeDataIndex, }); const pinnedLeftWidth = calculatedColumnWidths .slice(0, pinnedLeftColumnCount) .reduce((sum, width) => sum + width, 0); const mainWidth = calculatedColumnWidths .slice(pinnedLeftColumnCount, pinnedLeftColumnCount + totalColumnCount) .reduce((sum, width) => sum + width, 0); const pinnedRightWidth = calculatedColumnWidths .slice(pinnedLeftColumnCount + totalColumnCount) .reduce((sum, width) => sum + width, 0); const totalTableWidth = calculatedColumnWidths.reduce((sum, width) => sum + width, 0); const actualStickyTop = stickyGroupTop; return (
{pinnedLeftColumnCount > 0 && (
{groupContent}
)}
0 ? 0 : '-2rem', marginRight: '-2rem', paddingLeft: pinnedLeftColumnCount > 0 ? 0 : '2rem', paddingRight: '2rem', width: `${mainWidth}px`, }} >
0 ? `-${pinnedLeftWidth}px` : 0, width: `${totalTableWidth}px`, }} > {groupContent}
{pinnedRightColumnCount > 0 && (
)}
); }, [ shouldRenderStickyGroupRow, stickyGroupIndex, groups, data, internalState, calculatedColumnWidths, pinnedLeftColumnCount, pinnedRightColumnCount, totalColumnCount, groupRowHeight, stickyGroupTop, ]); return ( <> {StickyHeader} {StickyGroupRow} ); }, ); ItemTableListStickyUI.displayName = 'ItemTableListStickyUI'; const BaseItemTableList = ({ activeRowId, autoFitColumns = false, CellComponent = ItemTableListColumn, cellPadding = 'sm', columns, data, enableAlternateRowColors = false, enableDrag = true, enableDragScroll = true, enableEntranceAnimation = true, enableExpansion = true, enableHeader = true, enableHorizontalBorders = false, enableRowHoverHighlight = true, enableScrollShadow = true, enableSelection = true, enableStickyGroupRows = false, enableStickyHeader = false, enableVerticalBorders = false, getItem, getItemIndex, getRowId, groups, headerHeight = 40, initialTop, itemCount, itemType, onColumnReordered, onColumnResized, onRangeChanged, onScrollEnd, overrideControls, ref, rowHeight, size = 'default', startRowIndex, }: ItemTableListProps) => { const { playlistId: routePlaylistId } = useParams() as { playlistId?: string }; const tableId = useId(); const baseItemCount = itemCount ?? data.length; const totalItemCount = enableHeader ? baseItemCount + 1 : baseItemCount; const [centerContainerWidth, setCenterContainerWidth] = useState(0); const [totalContainerWidth, setTotalContainerWidth] = useState(0); const columnsForLayout = useMemo( () => appendLayoutFillColumn(columns, autoFitColumns), [autoFitColumns, columns], ); const { calculatedColumnWidths, parsedColumns, pinnedLeftColumnCount, pinnedRightColumnCount, totalColumnCount, } = useTableColumnModel({ autoFitColumns, centerContainerWidth, columns: columnsForLayout, totalContainerWidth, }); const { clearColumnResizePreview, columnResizePreview, scheduleColumnResizePreview } = useItemTableListColumnResizeLiveState(); const columnResizeLiveValue = useMemo( () => ({ clearColumnResizePreview, scheduleColumnResizePreview, }), [clearColumnResizePreview, scheduleColumnResizePreview], ); const displayColumnWidths = useMemo(() => { if (!columnResizePreview) { return calculatedColumnWidths; } const next = calculatedColumnWidths.slice(); const { columnIndex, width } = columnResizePreview; if (columnIndex >= 0 && columnIndex < next.length) { next[columnIndex] = width; } return next; }, [calculatedColumnWidths, columnResizePreview]); const playerContext = usePlayer(); const { dataWithGroups: dataWithGroupsFromModel, groupHeaderRowCount: groupHeaderRowCountFromModel, } = useTableRowModel({ data, enableHeader, groups, }); const shouldUseAccessor = typeof getItem === 'function' && typeof itemCount === 'number'; // Avoid constructing a massive row-model array for infinite lists. // Cell renderers use `getRowItem` accessor when provided. const dataWithGroups = useMemo<(null | unknown)[]>(() => { if (!shouldUseAccessor) return dataWithGroupsFromModel; return enableHeader ? [null] : []; }, [dataWithGroupsFromModel, enableHeader, shouldUseAccessor]); const groupHeaderRowCount = useMemo(() => { if (!shouldUseAccessor) return groupHeaderRowCountFromModel; return groups?.length ? groups.length : 0; }, [groupHeaderRowCountFromModel, groups, shouldUseAccessor]); const pinnedRowCount = enableHeader ? 1 : 0; // Group headers are inserted at specific indexes, so they add to the total row count const totalRowCount = totalItemCount - pinnedRowCount + groupHeaderRowCount; const pinnedRowRef = useRef(null); const rowRef = useRef(null); const pinnedLeftColumnRef = useRef(null); const pinnedRightColumnRef = useRef(null); const scrollContainerRef = useRef(null); const mergedRowRef = useMergedRef(rowRef, scrollContainerRef); const scrollShadowStore = useMemo(() => createTableScrollShadowStore(), []); const handleRef = useRef(null); const { focused, ref: focusRef } = useFocusWithin(); const containerRef = useRef(null); const mergedContainerRef = useMergedRef(containerRef, focusRef); useContainerWidthTracking({ autoFitColumns, containerRef, rowRef, setCenterContainerWidth, setTotalContainerWidth, }); const onScrollEndRef = useRef(onScrollEnd); useEffect(() => { onScrollEndRef.current = onScrollEnd; }, [onScrollEnd]); const { calculateScrollTopForIndex, DEFAULT_ROW_HEIGHT, scrollToTableIndex, scrollToTableOffset, } = useTableScrollToIndex({ cellPadding, columns: parsedColumns, data, enableAlternateRowColors, enableExpansion, enableHeader, enableHorizontalBorders, enableRowHoverHighlight, enableSelection, enableVerticalBorders, itemType, pinnedLeftColumnRef, pinnedRightColumnRef, playerContext, rowHeight, rowRef, size, tableId, }); useTablePaneSync({ enableDrag, enableDragScroll, enableHeader, handleRef, onScrollEndRef, pinnedLeftColumnCount, pinnedLeftColumnRef, pinnedRightColumnCount, pinnedRightColumnRef, pinnedRowRef, rowRef, scrollContainerRef, scrollShadowStore, }); const getRowHeight = useCallback( (index: number, cellProps: TableItemProps) => { const height = size === 'compact' ? TableItemSize.COMPACT : size === 'large' ? TableItemSize.LARGE : TableItemSize.DEFAULT; const baseHeight = typeof rowHeight === 'number' ? rowHeight : rowHeight?.(index, cellProps) || height; // If enableHeader is true and this is the first sticky row, use fixed header height if (enableHeader && index === 0 && pinnedRowCount > 0) { return headerHeight; } return baseHeight; }, [enableHeader, headerHeight, rowHeight, pinnedRowCount, size], ); // Create a wrapper for getRowHeight that doesn't require cellProps (for sticky group rows hook) const getRowHeightWrapper = useCallback( (index: number) => { const height = size === 'compact' ? TableItemSize.COMPACT : size === 'large' ? TableItemSize.LARGE : TableItemSize.DEFAULT; const baseHeight = typeof rowHeight === 'number' ? rowHeight : height; // If enableHeader is true and this is the first sticky row, use fixed header height if (enableHeader && index === 0 && pinnedRowCount > 0) { return headerHeight; } return baseHeight; }, [enableHeader, headerHeight, rowHeight, pinnedRowCount, size], ); const getDataFn = useCallback(() => { return data; }, [data]); const extractRowId = useMemo(() => createExtractRowId(getRowId), [getRowId]); const internalState = useItemListState(getDataFn, extractRowId); const getStateItem = useCallback( (item: any): ItemListStateItemWithRequiredProperties | null => { if (!hasRequiredItemProperties(item)) { return null; } if ( typeof item === 'object' && item !== null && '_serverId' in item && '_itemType' in item ) { return item as ItemListStateItemWithRequiredProperties; } return null; }, [], ); const { handleKeyDown } = useTableKeyboardNavigation({ calculateScrollTopForIndex, cellPadding, data, DEFAULT_ROW_HEIGHT, enableHeader, enableSelection, extractRowId, getItem, getItemIndex, getStateItem, hasRequiredStateItemProperties, internalState, itemCount: baseItemCount, itemType, parsedColumns, pinnedRightColumnCount, pinnedRightColumnRef, playerContext, rowHeight, rowRef, scrollToTableIndex, size, tableId, }); useTableInitialScroll({ initialTop, scrollToTableIndex, scrollToTableOffset, startRowIndex, }); useTableImperativeHandle({ enableHeader, handleRef, internalState, ref, scrollToTableIndex, scrollToTableOffset, }); const controls = useDefaultItemListControls({ onColumnReordered, onColumnResized, overrides: overrideControls, }); // Create itemProps for sticky header const stickyHeaderItemProps: TableItemProps = useMemo( () => ({ calculatedColumnWidths: displayColumnWidths, cellPadding, columns: parsedColumns, controls, data: [null], // Header row enableAlternateRowColors, enableColumnReorder: !!onColumnReordered, enableColumnResize: !!onColumnResized, enableDrag, enableExpansion, enableHeader, enableHorizontalBorders, enableRowHoverHighlight, enableSelection, enableVerticalBorders, getRowHeight, groups, internalState, itemType, pinnedLeftColumnCount, pinnedLeftColumnWidths: displayColumnWidths.slice(0, pinnedLeftColumnCount), pinnedRightColumnCount, pinnedRightColumnWidths: displayColumnWidths.slice( pinnedLeftColumnCount + totalColumnCount, ), playerContext, playlistId: routePlaylistId, size, tableId, }), [ displayColumnWidths, cellPadding, controls, parsedColumns, enableAlternateRowColors, enableDrag, enableExpansion, enableHeader, enableHorizontalBorders, enableRowHoverHighlight, enableSelection, enableVerticalBorders, getRowHeight, groups, internalState, itemType, onColumnReordered, onColumnResized, pinnedLeftColumnCount, pinnedRightColumnCount, playerContext, routePlaylistId, size, tableId, totalColumnCount, ], ); useListHotkeys({ controls, focused, internalState, itemType, }); const tableConfigValue = useMemo( () => ({ cellPadding, columns: parsedColumns, controls, enableAlternateRowColors, enableColumnReorder: !!onColumnReordered, enableColumnResize: !!onColumnResized, enableDrag, enableExpansion, enableHeader, enableHorizontalBorders, enableRowHoverHighlight, enableSelection, enableVerticalBorders, getRowHeight, groups, internalState, itemType, playerContext, playlistId: routePlaylistId, size, startRowIndex, tableId, }), [ cellPadding, parsedColumns, controls, enableAlternateRowColors, onColumnReordered, onColumnResized, enableDrag, enableExpansion, enableHeader, enableHorizontalBorders, enableRowHoverHighlight, enableSelection, enableVerticalBorders, getRowHeight, groups, internalState, itemType, playerContext, routePlaylistId, size, startRowIndex, tableId, ], ); const columnCellComponents = useColumnCellComponents( parsedColumns.map((c) => c.id as TableColumn), itemType, ); const optimizedCellComponent = useMemo< JSXElementConstructor> >(() => { if (CellComponent && CellComponent !== ItemTableListColumn) { return CellComponent; } return (cellProps: CellComponentProps) => { return ( ); }; }, [CellComponent, columnCellComponents]); const tableMotion = ( { const element = e.currentTarget as HTMLDivElement; // Focus without scrolling into view if (element.focus) { element.focus({ preventScroll: true }); } }} ref={mergedContainerRef} tabIndex={0} {...animationProps.fadeIn} transition={{ duration: enableEntranceAnimation ? 0.3 : 0, ease: 'anticipate' }} > ); return ( {onColumnResized ? ( {tableMotion} ) : ( tableMotion )} ); }; export const ItemTableList = memo(BaseItemTableList); ItemTableList.displayName = 'ItemTableList';