// Component adapted from https://github.com/bvaughn/react-window/issues/826 import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; import clsx from 'clsx'; import throttle from 'lodash/throttle'; import { AnimatePresence, motion } from 'motion/react'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; import React, { type JSXElementConstructor, memo, ReactElement, Ref, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import { type CellComponentProps, Grid } from 'react-window-v2'; import styles from './item-table-list.module.css'; import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container'; import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item'; 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, useItemListStateSubscription, } 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 { 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 { 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' ); }; enum TableItemSize { COMPACT = 40, DEFAULT = 64, LARGE = 88, } interface VirtualizedTableGridProps { activeRowId?: string; calculatedColumnWidths: number[]; CellComponent: JSXElementConstructor>; cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs'; controls: ItemControls; data: unknown[]; dataWithGroups: (null | unknown)[]; enableAlternateRowColors: boolean; enableColumnReorder: boolean; enableColumnResize: boolean; enableDrag?: boolean; enableExpansion: boolean; enableHeader: boolean; enableHorizontalBorders: boolean; enableRowHoverHighlight: boolean; enableScrollShadow: boolean; enableSelection: boolean; enableVerticalBorders: boolean; getRowHeight: (index: number, cellProps: TableItemProps) => number; groups?: TableGroupHeader[]; headerHeight: number; internalState: ItemListStateActions; itemType: LibraryItem; mergedRowRef: React.Ref; onRangeChanged?: ItemTableListProps['onRangeChanged']; parsedColumns: ReturnType; pinnedLeftColumnCount: number; pinnedLeftColumnRef: React.RefObject; pinnedRightColumnCount: number; pinnedRightColumnRef: React.RefObject; pinnedRowCount: number; pinnedRowRef: React.RefObject; playerContext: PlayerContext; showLeftShadow: boolean; showRightShadow: boolean; showTopShadow: boolean; size: 'compact' | 'default' | 'large'; startRowIndex?: number; tableId: string; totalColumnCount: number; totalRowCount: number; } const VirtualizedTableGrid = ({ activeRowId, calculatedColumnWidths, CellComponent, cellPadding, controls, data, dataWithGroups, enableAlternateRowColors, enableColumnReorder, enableColumnResize, enableDrag, enableExpansion, enableHeader, enableHorizontalBorders, enableRowHoverHighlight, enableScrollShadow, enableSelection, enableVerticalBorders, getRowHeight, groups, headerHeight, internalState, itemType, mergedRowRef, onRangeChanged, parsedColumns, pinnedLeftColumnCount, pinnedLeftColumnRef, pinnedRightColumnCount, pinnedRightColumnRef, pinnedRowCount, pinnedRowRef, playerContext, showLeftShadow, showRightShadow, showTopShadow, size, startRowIndex, tableId, totalColumnCount, totalRowCount, }: VirtualizedTableGridProps) => { const hoverDelegateRef = useRef(null); const columnWidth = useCallback( (index: number) => calculatedColumnWidths[index], [calculatedColumnWidths], ); 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 getGroupRenderData = useCallback(() => data, [data]); // Row hover highlight: do one delegated listener per table rather than per cell // This is intentionally imperative to avoid React re-rendering the entire visible grid on hover useEffect(() => { if (!enableRowHoverHighlight) return; const root = hoverDelegateRef.current; if (!root) return; let hoveredKey: null | string = null; let rafId: null | number = null; const getRowKey = (target: EventTarget | null): null | string => { const el = target instanceof Element ? target : null; const rowEl = el?.closest?.('[data-row-index]') as HTMLElement | null; return rowEl?.getAttribute('data-row-index') ?? null; }; const apply = (prev: null | string, next: null | string) => { if (rafId !== null) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(() => { if (prev) { root.querySelectorAll(`[data-row-index="${prev}"]`).forEach((node) => { (node as HTMLElement).removeAttribute('data-row-hovered'); }); } if (next) { root.querySelectorAll(`[data-row-index="${next}"]`).forEach((node) => { (node as HTMLElement).setAttribute('data-row-hovered', 'true'); }); } }); }; const setHovered = (next: null | string) => { if (next === hoveredKey) return; const prev = hoveredKey; hoveredKey = next; apply(prev, next); }; const onPointerOver = (e: PointerEvent) => { setHovered(getRowKey(e.target)); }; const onPointerOut = (e: PointerEvent) => { // If moving within the same row, keep it hovered const relatedKey = getRowKey((e as any).relatedTarget); if (relatedKey === hoveredKey) return; setHovered(relatedKey); }; root.addEventListener('pointerover', onPointerOver); root.addEventListener('pointerout', onPointerOut); return () => { root.removeEventListener('pointerover', onPointerOver); root.removeEventListener('pointerout', onPointerOut); if (rafId !== null) cancelAnimationFrame(rafId); // Ensure we don't leave stale attributes behind if (hoveredKey) apply(hoveredKey, null); }; }, [enableRowHoverHighlight]); useEffect(() => { const root = hoverDelegateRef.current; if (!root) return; let current: null | { edge: 'bottom' | 'top'; rowKey: string } = null; let pending: null | { edge: 'bottom' | 'top' | null; rowKey: string } = null; let rafId: null | number = null; const clearRow = (rowKey: string) => { root.querySelectorAll(`[data-row-index="${rowKey}"]`).forEach((node) => { const el = node as HTMLElement; el.removeAttribute('data-row-dragged-over'); el.removeAttribute('data-row-dragged-over-first'); }); }; const applyRow = (rowKey: string, edge: 'bottom' | 'top') => { const nodes = root.querySelectorAll(`[data-row-index="${rowKey}"]`); nodes.forEach((node, idx) => { const el = node as HTMLElement; el.setAttribute('data-row-dragged-over', edge); if (idx === 0) { el.setAttribute('data-row-dragged-over-first', 'true'); } else { el.removeAttribute('data-row-dragged-over-first'); } }); }; const flush = () => { rafId = null; const next = pending; pending = null; if (!next) return; // Clear previous row if we’re moving rows or clearing. if (current && current.rowKey !== next.rowKey) { clearRow(current.rowKey); current = null; } if (!next.edge) { if (current) { clearRow(current.rowKey); current = null; } return; } // If same row + edge, no-op. if (current && current.rowKey === next.rowKey && current.edge === next.edge) return; if (current) clearRow(current.rowKey); applyRow(next.rowKey, next.edge); current = { edge: next.edge, rowKey: next.rowKey }; }; const scheduleFlush = () => { if (rafId !== null) return; rafId = requestAnimationFrame(flush); }; const onRowDragOver = (e: Event) => { const ev = e as CustomEvent<{ edge?: 'bottom' | 'top' | null; rowKey?: string }>; const rowKey = ev.detail?.rowKey; const edge = ev.detail?.edge ?? null; if (!rowKey) return; pending = { edge, rowKey }; scheduleFlush(); }; root.addEventListener('itl:row-drag-over', onRowDragOver as any); return () => { root.removeEventListener('itl:row-drag-over', onRowDragOver as any); if (rafId !== null) cancelAnimationFrame(rafId); if (current) clearRow(current.rowKey); }; }, []); // 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 stableConfigProps = useMemo( () => ({ cellPadding, columns: parsedColumns, controls, enableHeader, getRowHeight, internalState, itemType, playerContext, size, tableId, }), [ cellPadding, parsedColumns, controls, enableHeader, getRowHeight, internalState, itemType, playerContext, size, tableId, ], ); const dynamicDataProps = useMemo( () => ({ activeRowId, calculatedColumnWidths, data: dataWithGroups, getAdjustedRowIndex, getGroupRenderData, groupHeaderInfoByRowIndex, pinnedLeftColumnCount, pinnedLeftColumnWidths, pinnedRightColumnCount, pinnedRightColumnWidths, startRowIndex, }), [ activeRowId, calculatedColumnWidths, dataWithGroups, getAdjustedRowIndex, getGroupRenderData, groupHeaderInfoByRowIndex, pinnedLeftColumnCount, pinnedLeftColumnWidths, pinnedRightColumnCount, pinnedRightColumnWidths, startRowIndex, ], ); const featureFlags = useMemo( () => ({ enableAlternateRowColors, enableColumnReorder, enableColumnResize, enableDrag, enableExpansion, enableHorizontalBorders, enableRowHoverHighlight, enableSelection, enableVerticalBorders, groups, }), [ enableAlternateRowColors, enableColumnReorder, enableColumnResize, enableDrag, enableExpansion, enableHorizontalBorders, enableRowHoverHighlight, enableSelection, enableVerticalBorders, groups, ], ); const itemProps: TableItemProps = useMemo( () => ({ ...stableConfigProps, ...dynamicDataProps, ...featureFlags, }), [stableConfigProps, dynamicDataProps, featureFlags], ); 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 (
0).reduce( (a, _, i) => a + columnWidth(i), 0, )}px`, } as React.CSSProperties } > {!!(pinnedLeftColumnCount || pinnedRowCount) && (
0).reduce( (a, _, i) => a + getRowHeight(i, itemProps), 0, )}px`, overflow: 'hidden', }} >
)} {enableHeader && enableScrollShadow && showTopShadow && (
)} {!!pinnedLeftColumnCount && (
{ return getRowHeight(index + pinnedRowCount, cellProps); }} />
)}
{!!pinnedRowCount && (
0, ).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`, overflow: 'hidden', } as React.CSSProperties } > { return columnWidth(index + pinnedLeftColumnCount); }} rowCount={Array.from({ length: pinnedRowCount }, () => 0).length} rowHeight={getRowHeight} />
)} {enableHeader && enableScrollShadow && showTopShadow && (
)}
{ return columnWidth(index + pinnedLeftColumnCount); }} onCellsRendered={handleOnCellsRendered} rowCount={totalRowCount} rowHeight={(index, cellProps) => { return getRowHeight(index + pinnedRowCount, cellProps); }} /> {pinnedLeftColumnCount > 0 && enableScrollShadow && showLeftShadow && (
)} {pinnedRightColumnCount > 0 && enableScrollShadow && showRightShadow && (
)}
{!!pinnedRightColumnCount && (
0, ).reduce( (a, _, i) => a + columnWidth(i + pinnedLeftColumnCount + totalColumnCount), 0, )}px`, } as React.CSSProperties } > {!!(pinnedRightColumnCount || pinnedRowCount) && (
0, ).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`, overflow: 'hidden', }} > { return columnWidth( index + pinnedLeftColumnCount + totalColumnCount, ); }} rowCount={pinnedRowCount} rowHeight={getRowHeight} />
)} {enableHeader && enableScrollShadow && showTopShadow && (
)}
{ return columnWidth( index + pinnedLeftColumnCount + totalColumnCount, ); }} rowCount={totalRowCount} rowHeight={(index, cellProps) => { return getRowHeight(index + pinnedRowCount, cellProps); }} />
)}
); }; VirtualizedTableGrid.displayName = 'VirtualizedTableGrid'; const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, nextProps) => { return ( prevProps.activeRowId === nextProps.activeRowId && prevProps.calculatedColumnWidths === nextProps.calculatedColumnWidths && prevProps.cellPadding === nextProps.cellPadding && prevProps.controls === nextProps.controls && prevProps.data === nextProps.data && prevProps.dataWithGroups === nextProps.dataWithGroups && prevProps.enableAlternateRowColors === nextProps.enableAlternateRowColors && prevProps.enableColumnReorder === nextProps.enableColumnReorder && prevProps.enableColumnResize === nextProps.enableColumnResize && prevProps.enableDrag === nextProps.enableDrag && prevProps.enableExpansion === nextProps.enableExpansion && prevProps.enableHeader === nextProps.enableHeader && prevProps.enableHorizontalBorders === nextProps.enableHorizontalBorders && prevProps.enableRowHoverHighlight === nextProps.enableRowHoverHighlight && prevProps.enableScrollShadow === nextProps.enableScrollShadow && prevProps.enableSelection === nextProps.enableSelection && prevProps.enableVerticalBorders === nextProps.enableVerticalBorders && prevProps.getRowHeight === nextProps.getRowHeight && prevProps.groups === nextProps.groups && prevProps.headerHeight === nextProps.headerHeight && prevProps.internalState === nextProps.internalState && prevProps.itemType === nextProps.itemType && 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.playerContext === nextProps.playerContext && prevProps.showLeftShadow === nextProps.showLeftShadow && prevProps.showRightShadow === nextProps.showRightShadow && prevProps.showTopShadow === nextProps.showTopShadow && prevProps.size === nextProps.size && prevProps.startRowIndex === nextProps.startRowIndex && prevProps.tableId === nextProps.tableId && 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 { activeRowId?: string; adjustedRowIndexMap?: Map; calculatedColumnWidths?: number[]; cellPadding?: ItemTableListProps['cellPadding']; columns: ItemTableListColumnConfig[]; controls: ItemControls; data: ItemTableListProps['data']; enableAlternateRowColors?: ItemTableListProps['enableAlternateRowColors']; enableColumnReorder?: boolean; enableColumnResize?: boolean; enableDrag?: ItemTableListProps['enableDrag']; 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; groupHeaderInfoByRowIndex?: Map; groups?: TableGroupHeader[]; internalState: ItemListStateActions; itemType: ItemTableListProps['itemType']; onRowClick?: (item: any, event: React.MouseEvent) => void; pinnedLeftColumnCount?: number; pinnedLeftColumnWidths?: number[]; pinnedRightColumnCount?: number; pinnedRightColumnWidths?: number[]; playerContext: PlayerContext; 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; enableEntranceAnimation?: boolean; enableExpansion?: boolean; enableHeader?: boolean; enableHorizontalBorders?: boolean; enableRowHoverHighlight?: boolean; enableScrollShadow?: boolean; enableSelection?: boolean; enableSelectionDialog?: boolean; enableStickyGroupRows?: boolean; enableStickyHeader?: boolean; enableVerticalBorders?: boolean; getRowId?: ((item: unknown) => string) | string; groups?: TableGroupHeader[]; headerHeight?: number; initialTop?: { behavior?: 'auto' | 'smooth'; to: number; type: 'index' | 'offset'; }; 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 BaseItemTableList = ({ activeRowId, autoFitColumns = false, CellComponent, cellPadding = 'sm', columns, data, enableAlternateRowColors = false, enableDrag = true, enableEntranceAnimation = true, enableExpansion = true, enableHeader = true, enableHorizontalBorders = false, enableRowHoverHighlight = true, enableScrollShadow = true, enableSelection = true, enableStickyGroupRows = false, enableStickyHeader = false, enableVerticalBorders = false, getRowId, groups, headerHeight = 40, initialTop, itemType, onColumnReordered, onColumnResized, onRangeChanged, onScrollEnd, overrideControls, ref, rowHeight, size = 'default', startRowIndex, }: ItemTableListProps) => { const tableId = useId(); const totalItemCount = enableHeader ? data.length + 1 : data.length; const parsedColumns = useMemo(() => parseTableColumns(columns), [columns]); const columnCount = parsedColumns.length; const playerContext = usePlayer(); const [centerContainerWidth, setCenterContainerWidth] = useState(0); const [totalContainerWidth, setTotalContainerWidth] = useState(0); // Compute dataWithGroups once to avoid duplicate computation // This is used by both VirtualizedTableGrid and getDataFn const dataWithGroups = useMemo(() => { const result: (null | unknown)[] = enableHeader ? [null] : []; if (!groups || groups.length === 0) { // No groups, just add all data result.push(...data); return result; } // Build the expanded row model: [header?] + (groupHeader + groupItems)* + any remaining items. let dataIndex = 0; for (const group of groups) { // Group header row result.push(null); // Group items const end = Math.min(data.length, dataIndex + group.itemCount); for (; dataIndex < end; dataIndex++) { result.push(data[dataIndex]); } } // If groups don't account for all items, append the remainder. for (; dataIndex < data.length; dataIndex++) { result.push(data[dataIndex]); } return result; }, [data, enableHeader, groups]); // Compute distributed widths: unpinned columns with autoWidth will share any remaining space // When autoSizeColumns is true, all column widths are treated as proportions and scaled to fit the container const calculatedColumnWidths = useMemo(() => { const baseWidths = parsedColumns.map((c) => c.width); // When autoSizeColumns is enabled, treat all widths as proportions and scale to fit container if (autoFitColumns) { // Calculate total reference width (sum of all base widths) const totalReferenceWidth = baseWidths.reduce((sum, width) => sum + width, 0); if (totalReferenceWidth === 0 || totalContainerWidth === 0) { return baseWidths.map((width) => Math.round(width)); } // Scale factor to fit all columns proportionally within the total container width const scaleFactor = totalContainerWidth / totalReferenceWidth; // Apply scale factor to all columns proportionally and round to integers const scaledWidths = baseWidths.map((width) => Math.round(width * scaleFactor)); // Adjust for rounding errors: ensure total equals totalContainerWidth const totalScaled = scaledWidths.reduce((sum, width) => sum + width, 0); const difference = totalContainerWidth - totalScaled; if (difference !== 0 && scaledWidths.length > 0) { // Distribute the difference to the largest columns const sortedIndices = scaledWidths .map((width, idx) => ({ idx, width })) .sort((a, b) => b.width - a.width); const adjustmentPerColumn = Math.sign(difference); const adjustmentCount = Math.abs(difference); for (let i = 0; i < adjustmentCount && i < sortedIndices.length; i++) { scaledWidths[sortedIndices[i].idx] += adjustmentPerColumn; } } return scaledWidths; } // Original behavior: distribute extra space to auto-size columns const distributed = baseWidths.slice(); const unpinnedIndices: number[] = []; const autoUnpinnedIndices: number[] = []; parsedColumns.forEach((col, idx) => { if (col.pinned === null) { unpinnedIndices.push(idx); if (col.autoSize) { autoUnpinnedIndices.push(idx); } } }); if (unpinnedIndices.length === 0 || autoUnpinnedIndices.length === 0) { return distributed.map((width) => Math.round(width)); } const unpinnedBaseTotal = unpinnedIndices.reduce((sum, idx) => sum + baseWidths[idx], 0); // Distribute only when there is extra space within the center container const extra = Math.max(0, centerContainerWidth - unpinnedBaseTotal); if (extra <= 0) { return distributed.map((width) => Math.round(width)); } const extraPer = extra / autoUnpinnedIndices.length; autoUnpinnedIndices.forEach((idx) => { distributed[idx] = Math.round(baseWidths[idx] + extraPer); }); // Round all widths to integers return distributed.map((width) => Math.round(width)); }, [parsedColumns, centerContainerWidth, autoFitColumns, totalContainerWidth]); const pinnedLeftColumnCount = parsedColumns.filter((col) => col.pinned === 'left').length; const pinnedRightColumnCount = parsedColumns.filter((col) => col.pinned === 'right').length; const pinnedRowCount = enableHeader ? 1 : 0; // Calculate group header row count - each group has one header row const groupHeaderRowCount = useMemo(() => { if (!groups || groups.length === 0) return 0; return groups.length; }, [groups]); // Group headers are inserted at specific indexes, so they add to the total row count const totalRowCount = totalItemCount - pinnedRowCount + groupHeaderRowCount; const totalColumnCount = columnCount - pinnedLeftColumnCount - pinnedRightColumnCount; 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 [showLeftShadow, setShowLeftShadow] = useState(false); const [showRightShadow, setShowRightShadow] = useState(false); const [showTopShadow, setShowTopShadow] = useState(false); const handleRef = useRef(null); const { focused, ref: focusRef } = useFocusWithin(); const containerRef = useRef(null); const mergedContainerRef = useMergedRef(containerRef, focusRef); 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: containerRef, enabled: enableHeader && enableStickyHeader, headerRef: pinnedRowRef, mainGridRef: rowRef, pinnedLeftColumnRef, pinnedRightColumnRef, stickyHeaderMainRef, }); // Update position and width of sticky header (scroll sync is handled in the hook) useEffect(() => { if (!shouldShowStickyHeader || !stickyHeaderRef.current || !containerRef.current) { return; } const stickyHeader = stickyHeaderRef.current; const container = containerRef.current; let isMounted = true; const updatePosition = () => { // Guard against updates after unmount if (!isMounted || !stickyHeader || !container) { return; } try { const containerRect = container.getBoundingClientRect(); stickyHeader.style.left = `${containerRect.left}px`; stickyHeader.style.width = `${containerRect.width}px`; } catch { // Silently handle errors if elements are no longer in DOM } }; updatePosition(); window.addEventListener('resize', updatePosition); window.addEventListener('scroll', updatePosition, true); return () => { isMounted = false; window.removeEventListener('resize', updatePosition); window.removeEventListener('scroll', updatePosition, true); }; }, [shouldShowStickyHeader]); useEffect(() => { const el = rowRef.current; if (!el) return; const updateWidth = () => { setCenterContainerWidth(el.clientWidth || 0); }; updateWidth(); let debounceTimeout: NodeJS.Timeout | null = null; const resizeObserver = new ResizeObserver(() => { if (debounceTimeout) { clearTimeout(debounceTimeout); } debounceTimeout = setTimeout(() => { updateWidth(); }, 100); }); resizeObserver.observe(el); return () => { if (debounceTimeout) { clearTimeout(debounceTimeout); } resizeObserver.disconnect(); }; }, []); // Track total container width for autoSizeColumns useEffect(() => { const el = containerRef.current; if (!el || !autoFitColumns) return; const updateWidth = () => { setTotalContainerWidth(el.clientWidth || 0); }; updateWidth(); let debounceTimeout: NodeJS.Timeout | null = null; const resizeObserver = new ResizeObserver(() => { if (debounceTimeout) { clearTimeout(debounceTimeout); } debounceTimeout = setTimeout(() => { updateWidth(); }, 100); }); resizeObserver.observe(el); return () => { if (debounceTimeout) { clearTimeout(debounceTimeout); } resizeObserver.disconnect(); }; }, [autoFitColumns]); const onScrollEndRef = useRef(onScrollEnd); useEffect(() => { onScrollEndRef.current = onScrollEnd; }, [onScrollEnd]); const scrollToTableOffset = useCallback((offset: number) => { const mainContainer = rowRef.current?.childNodes[0] as HTMLDivElement | undefined; const pinnedLeftContainer = pinnedLeftColumnRef.current?.childNodes[0] as | HTMLDivElement | undefined; const pinnedRightContainer = pinnedRightColumnRef.current?.childNodes[0] as | HTMLDivElement | undefined; const behavior = 'instant'; if (mainContainer) { mainContainer.scrollTo({ behavior, top: offset }); } if (pinnedLeftContainer) { pinnedLeftContainer.scrollTo({ behavior, top: offset }); } if (pinnedRightContainer) { pinnedRightContainer.scrollTo({ behavior, top: offset }); } }, []); const DEFAULT_ROW_HEIGHT = size === 'compact' ? TableItemSize.COMPACT : size === 'large' ? TableItemSize.LARGE : TableItemSize.DEFAULT; const calculateScrollTopForIndex = useCallback( (index: number) => { const adjustedIndex = enableHeader ? Math.max(0, index - 1) : index; let scrollTop = 0; // Create a minimal mock cellProps for rowHeight function calls if needed const mockCellProps: TableItemProps = { cellPadding, columns: parsedColumns, controls: {} as ItemControls, data: enableHeader ? [null, ...data] : data, enableAlternateRowColors, enableExpansion, enableHeader, enableHorizontalBorders, enableRowHoverHighlight, enableSelection, enableVerticalBorders, getRowHeight: () => DEFAULT_ROW_HEIGHT, internalState: {} as ItemListStateActions, itemType, playerContext, size, tableId, }; for (let i = 0; i < adjustedIndex; i++) { let height: number; if (typeof rowHeight === 'number') { height = rowHeight; } else if (typeof rowHeight === 'function') { height = rowHeight(i, mockCellProps); } else { height = DEFAULT_ROW_HEIGHT; } scrollTop += height; } return scrollTop; }, [ enableHeader, cellPadding, parsedColumns, data, enableAlternateRowColors, enableExpansion, enableHorizontalBorders, enableRowHoverHighlight, enableSelection, enableVerticalBorders, itemType, playerContext, size, tableId, DEFAULT_ROW_HEIGHT, rowHeight, ], ); const scrollToTableIndex = useCallback( (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => { const mainContainer = rowRef.current?.childNodes[0] as HTMLDivElement | undefined; if (!mainContainer) return; const viewportHeight = mainContainer.clientHeight; const align = options?.align || 'top'; // Calculate the base scroll offset (top of the row) let offset = calculateScrollTopForIndex(index); // Calculate row height for the target index const adjustedIndex = enableHeader ? Math.max(0, index - 1) : index; const mockCellProps: TableItemProps = { cellPadding, columns: parsedColumns, controls: {} as ItemControls, data: enableHeader ? [null, ...data] : data, enableAlternateRowColors, enableExpansion, enableHeader, enableHorizontalBorders, enableRowHoverHighlight, enableSelection, enableVerticalBorders, getRowHeight: () => DEFAULT_ROW_HEIGHT, internalState: {} as ItemListStateActions, itemType, playerContext, size, tableId, }; let targetRowHeight: number; if (typeof rowHeight === 'number') { targetRowHeight = rowHeight; } else if (typeof rowHeight === 'function') { targetRowHeight = rowHeight(adjustedIndex, mockCellProps); } else { targetRowHeight = DEFAULT_ROW_HEIGHT; } // Adjust offset based on alignment if (align === 'center') { offset = offset - viewportHeight / 2 + targetRowHeight / 2; } else if (align === 'bottom') { offset = offset - viewportHeight + targetRowHeight; } // 'top' uses the base offset // Ensure offset is not negative offset = Math.max(0, offset); scrollToTableOffset(offset); }, [ calculateScrollTopForIndex, scrollToTableOffset, enableHeader, cellPadding, parsedColumns, data, enableAlternateRowColors, enableExpansion, enableHorizontalBorders, enableRowHoverHighlight, enableSelection, enableVerticalBorders, itemType, playerContext, size, tableId, DEFAULT_ROW_HEIGHT, rowHeight, ], ); // Main grid overlayscrollbars - only handle X-axis if right-pinned columns exist const [initialize, osInstance] = useOverlayScrollbars({ defer: false, events: { initialized(osInstance) { const { viewport } = osInstance.elements(); viewport.style.overflowX = `var(--os-viewport-overflow-x)`; if (pinnedRightColumnCount > 0) { viewport.style.overflowY = 'auto'; } else { viewport.style.overflowY = `var(--os-viewport-overflow-y)`; } }, }, options: { overflow: { x: 'scroll', y: pinnedRightColumnCount > 0 ? 'hidden' : 'scroll', }, paddingAbsolute: true, scrollbars: { autoHide: 'leave', autoHideDelay: 500, pointers: ['mouse', 'pen', 'touch'], theme: 'feishin-os-scrollbar', }, }, }); // Right pinned columns overlayscrollbars - enable Y-axis scroll when right-pinned columns exist const [initializeRightPinned, osInstanceRightPinned] = useOverlayScrollbars({ defer: false, events: { initialized(osInstance) { const { viewport } = osInstance.elements(); viewport.style.overflowX = `var(--os-viewport-overflow-x)`; viewport.style.overflowY = `var(--os-viewport-overflow-y)`; }, }, options: { overflow: { x: 'hidden', y: 'scroll' }, paddingAbsolute: true, scrollbars: { autoHide: 'leave', autoHideDelay: 500, pointers: ['mouse', 'pen', 'touch'], theme: 'feishin-os-scrollbar', }, }, }); useEffect(() => { const { current: root } = scrollContainerRef; if (!root || !root.firstElementChild) { return; } const viewport = root.firstElementChild as HTMLElement; initialize({ elements: { viewport }, target: root, }); if (enableDrag) { autoScrollForElements({ canScroll: () => true, element: viewport, getAllowedAxis: () => 'vertical', getConfiguration: () => ({ maxScrollSpeed: 'fast' }), }); } return () => { try { const instance = osInstance(); const { current: root } = scrollContainerRef; // Check if instance exists and elements are still connected to the DOM if (instance && root) { const viewport = root.firstElementChild as HTMLElement; // Check if elements are still in the document const rootInDocument = document.contains(root); const viewportInDocument = viewport && document.contains(viewport); // Only destroy if elements are still in the document if (rootInDocument && viewportInDocument) { instance.destroy(); } } } catch { // Ignore error } }; }, [enableDrag, initialize, osInstance, pinnedRightColumnCount]); useEffect(() => { if (pinnedLeftColumnCount === 0) { return; } const { current: root } = pinnedLeftColumnRef; if (!root || !root.firstElementChild) { return; } const viewport = root.firstElementChild as HTMLElement; if (enableDrag) { autoScrollForElements({ canScroll: () => true, element: viewport, getAllowedAxis: () => 'vertical', getConfiguration: () => ({ maxScrollSpeed: 'fast' }), }); } }, [enableDrag, pinnedLeftColumnCount]); // Initialize overlayscrollbars for right pinned columns useEffect(() => { if (pinnedRightColumnCount === 0) { return; } const { current: root } = pinnedRightColumnRef; if (!root || !root.firstElementChild) { return; } const viewport = root.firstElementChild as HTMLElement; initializeRightPinned({ elements: { viewport }, target: root, }); if (enableDrag) { autoScrollForElements({ canScroll: () => true, element: viewport, getAllowedAxis: () => 'vertical', getConfiguration: () => ({ maxScrollSpeed: 'fast' }), }); } return () => { try { const instance = osInstanceRightPinned(); const { current: root } = pinnedRightColumnRef; if (instance && root) { const viewport = root.firstElementChild as HTMLElement; const rootInDocument = document.contains(root); const viewportInDocument = viewport && document.contains(viewport); if (rootInDocument && viewportInDocument) { instance.destroy(); } } } catch { // Ignore error } }; }, [enableDrag, initializeRightPinned, osInstanceRightPinned, pinnedRightColumnCount]); useEffect(() => { const header = pinnedRowRef.current?.childNodes[0] as HTMLDivElement; const row = rowRef.current?.childNodes[0] as HTMLDivElement; const pinnedLeft = pinnedLeftColumnRef.current?.childNodes[0] as HTMLDivElement; const pinnedRight = pinnedRightColumnRef.current?.childNodes[0] as HTMLDivElement; if (row) { // Ensure all containers have the same height const syncHeights = () => { const rowHeight = row.scrollHeight; let targetHeight = rowHeight; if (pinnedLeft) { const pinnedLeftHeight = pinnedLeft.scrollHeight; targetHeight = Math.max(targetHeight, pinnedLeftHeight); } if (pinnedRight) { const pinnedRightHeight = pinnedRight.scrollHeight; targetHeight = Math.max(targetHeight, pinnedRightHeight); } // Set consistent heights for all elements if (pinnedLeft && pinnedLeft.style.height !== `${targetHeight}px`) { pinnedLeft.style.height = `${targetHeight}px`; } if (pinnedRight && pinnedRight.style.height !== `${targetHeight}px`) { pinnedRight.style.height = `${targetHeight}px`; } if (row.style.height !== `${targetHeight}px`) { row.style.height = `${targetHeight}px`; } }; const timeoutId = setTimeout(syncHeights, 0); const activeElement = { element: null } as { element: HTMLDivElement | null }; const scrollingElements = new Set(); const scrollTimeouts = new Map(); const setActiveElement = (e: HTMLElementEventMap['pointermove']) => { activeElement.element = e.currentTarget as HTMLDivElement; }; const setActiveElementFromWheel = (e: HTMLElementEventMap['wheel']) => { activeElement.element = e.currentTarget as HTMLDivElement; }; // Track which elements are actively scrolling const markElementAsScrolling = (element: HTMLDivElement) => { scrollingElements.add(element); // Clear existing timeout for this element const existingTimeout = scrollTimeouts.get(element); if (existingTimeout) { clearTimeout(existingTimeout); } // Set a timeout to remove the element from scrolling set const timeout = setTimeout(() => { scrollingElements.delete(element); // Use right pinned column scroll position if right-pinned columns exist const hasRightPinnedColumns = pinnedRightColumnCount > 0; const scrollElement = hasRightPinnedColumns && pinnedRight ? pinnedRight : row; if (scrollElement && onScrollEndRef.current) { onScrollEndRef.current( scrollElement.scrollTop, handleRef.current ?? (undefined as any), ); } scrollTimeouts.delete(element); }, 150); scrollTimeouts.set(element, timeout); }; const syncScroll = (e: HTMLElementEventMap['scroll']) => { const currentElement = e.currentTarget as HTMLDivElement; markElementAsScrolling(currentElement); // Allow sync if: // 1. Current element is the active element (normal case) // 2. Current element is actively scrolling (handles autoscroll and other continuous scrolling) const shouldSync = currentElement === activeElement.element || scrollingElements.has(currentElement); if (!shouldSync) { return; } const scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; const scrollLeft = (e.currentTarget as HTMLDivElement).scrollLeft; const isScrolling = { header: false, pinnedLeft: false, pinnedRight: false, row: false, }; const hasRightPinnedColumns = pinnedRightColumnCount > 0; // Sync horizontal scroll between header and main content (only if header exists) if (header && e.currentTarget === header && !isScrolling.row) { isScrolling.row = true; row.scrollTo({ behavior: 'instant', left: scrollLeft, }); isScrolling.row = false; } // Sync from main content to header and sticky columns if ( e.currentTarget === row && !isScrolling.header && !isScrolling.pinnedLeft && !isScrolling.pinnedRight ) { if (header) { isScrolling.header = true; header.scrollTo({ behavior: 'instant', left: scrollLeft, }); } // When right-pinned columns exist, sync Y-scroll to right pinned column instead of from main grid if (hasRightPinnedColumns && pinnedRight) { isScrolling.pinnedRight = true; pinnedRight.scrollTo({ behavior: 'instant', top: scrollTop, }); isScrolling.pinnedRight = false; } else { // When no right-pinned columns, sync Y-scroll normally if (pinnedLeft) { isScrolling.pinnedLeft = true; pinnedLeft.scrollTo({ behavior: 'instant', top: scrollTop, }); } if (pinnedRight) { isScrolling.pinnedRight = true; pinnedRight.scrollTo({ behavior: 'instant', top: scrollTop, }); } } isScrolling.header = false; isScrolling.pinnedLeft = false; } // Sync vertical scroll between left pinned column and main content (only if pinnedLeft exists) if (pinnedLeft && e.currentTarget === pinnedLeft && !isScrolling.row) { // When right-pinned columns exist, sync Y-scroll to right pinned column instead of main grid if (hasRightPinnedColumns && pinnedRight) { isScrolling.pinnedRight = true; pinnedRight.scrollTo({ behavior: 'instant', top: scrollTop, }); isScrolling.pinnedRight = false; } else { isScrolling.row = true; row.scrollTo({ behavior: 'instant', top: scrollTop, }); isScrolling.row = false; } } // Sync vertical scroll from right pinned column to main content and left pinned column // When right-pinned columns exist, this is the source of truth for Y-scroll if (pinnedRight && e.currentTarget === pinnedRight && !isScrolling.row) { isScrolling.row = true; row.scrollTo({ behavior: 'instant', top: scrollTop, }); isScrolling.row = false; if (pinnedLeft) { isScrolling.pinnedLeft = true; pinnedLeft.scrollTo({ behavior: 'instant', top: scrollTop, }); isScrolling.pinnedLeft = false; } } }; // Add event listeners for elements that exist if (header) { header.addEventListener('pointermove', setActiveElement); header.addEventListener('wheel', setActiveElementFromWheel); header.addEventListener('scroll', syncScroll); } row.addEventListener('pointermove', setActiveElement); row.addEventListener('wheel', setActiveElementFromWheel); row.addEventListener('scroll', syncScroll); if (pinnedLeft) { pinnedLeft.addEventListener('pointermove', setActiveElement); pinnedLeft.addEventListener('wheel', setActiveElementFromWheel); pinnedLeft.addEventListener('scroll', syncScroll); } if (pinnedRight) { pinnedRight.addEventListener('pointermove', setActiveElement); pinnedRight.addEventListener('wheel', setActiveElementFromWheel); pinnedRight.addEventListener('scroll', syncScroll); } // Add resize observer to maintain height sync let heightSyncDebounceTimeout: NodeJS.Timeout | null = null; const resizeObserver = new ResizeObserver(() => { if (heightSyncDebounceTimeout) { clearTimeout(heightSyncDebounceTimeout); } heightSyncDebounceTimeout = setTimeout(() => { syncHeights(); }, 100); }); resizeObserver.observe(row); if (pinnedLeft) { resizeObserver.observe(pinnedLeft); } if (pinnedRight) { resizeObserver.observe(pinnedRight); } return () => { clearTimeout(timeoutId); scrollTimeouts.forEach((timeout) => clearTimeout(timeout)); scrollTimeouts.clear(); scrollingElements.clear(); if (header) { header.removeEventListener('pointermove', setActiveElement); header.removeEventListener('wheel', setActiveElementFromWheel); header.removeEventListener('scroll', syncScroll); } row.removeEventListener('pointermove', setActiveElement); row.removeEventListener('wheel', setActiveElementFromWheel); row.removeEventListener('scroll', syncScroll); if (pinnedLeft) { pinnedLeft.removeEventListener('pointermove', setActiveElement); pinnedLeft.removeEventListener('wheel', setActiveElementFromWheel); pinnedLeft.removeEventListener('scroll', syncScroll); } if (pinnedRight) { pinnedRight.removeEventListener('pointermove', setActiveElement); pinnedRight.removeEventListener('wheel', setActiveElementFromWheel); pinnedRight.removeEventListener('scroll', syncScroll); } if (heightSyncDebounceTimeout) { clearTimeout(heightSyncDebounceTimeout); } resizeObserver.disconnect(); }; } return undefined; }, [pinnedLeftColumnCount, pinnedRightColumnCount]); // Handle left and right shadow visibility based on horizontal scroll useEffect(() => { const row = rowRef.current?.childNodes[0] as HTMLDivElement; if (!row) { const timeout = setTimeout(() => { setShowLeftShadow(false); setShowRightShadow(false); }, 0); return () => clearTimeout(timeout); } const checkScrollPosition = throttle(() => { const scrollLeft = row.scrollLeft; const maxScrollLeft = row.scrollWidth - row.clientWidth; setShowLeftShadow(pinnedLeftColumnCount > 0 && scrollLeft > 0); setShowRightShadow(pinnedRightColumnCount > 0 && scrollLeft < maxScrollLeft); }, 50); checkScrollPosition(); row.addEventListener('scroll', checkScrollPosition, { passive: true }); return () => { checkScrollPosition.cancel(); row.removeEventListener('scroll', checkScrollPosition); }; }, [pinnedLeftColumnCount, pinnedRightColumnCount]); // Handle top shadow visibility based on vertical scroll useEffect(() => { const row = rowRef.current?.childNodes[0] as HTMLDivElement; const pinnedRight = pinnedRightColumnRef.current?.childNodes[0] as HTMLDivElement; if (!row || !enableHeader) { const timeout = setTimeout(() => { setShowTopShadow(false); }, 0); return () => clearTimeout(timeout); } // When right-pinned columns exist, use right pinned column's scroll position const scrollElement = pinnedRightColumnCount > 0 && pinnedRight ? pinnedRight : row; const checkScrollPosition = throttle(() => { const currentScrollTop = scrollElement.scrollTop; setShowTopShadow(currentScrollTop > 0); }, 50); checkScrollPosition(); scrollElement.addEventListener('scroll', checkScrollPosition, { passive: true }); return () => { checkScrollPosition.cancel(); scrollElement.removeEventListener('scroll', checkScrollPosition); }; }, [enableHeader, pinnedRightColumnCount]); const getRowHeight = useCallback( (index: number, cellProps: TableItemProps) => { const height = size === 'compact' ? 40 : size === 'large' ? 88 : 64; 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' ? 40 : size === 'large' ? 88 : 64; 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 { shouldShowStickyGroupRow, stickyGroupIndex, stickyTop: stickyGroupTop, } = useStickyTableGroupRows({ containerRef: containerRef, enabled: enableStickyGroupRows && !!groups && groups.length > 0, getRowHeight: getRowHeightWrapper, groups, headerHeight, mainGridRef: rowRef, shouldShowStickyHeader, stickyHeaderTop: stickyTop, }); // Show sticky group row whenever it should be shown const shouldRenderStickyGroupRow = shouldShowStickyGroupRow; // Update position and width of sticky group row useEffect(() => { if (!shouldRenderStickyGroupRow || !stickyGroupRowRef.current || !containerRef.current) { return; } const stickyGroupRow = stickyGroupRowRef.current; const container = containerRef.current; let isMounted = true; const updatePosition = () => { // Guard against updates after unmount if (!isMounted || !stickyGroupRow || !container) { return; } try { const containerRect = container.getBoundingClientRect(); stickyGroupRow.style.left = `${containerRect.left}px`; stickyGroupRow.style.width = `${containerRect.width}px`; } catch { // Silently handle errors if elements are no longer in DOM } }; updatePosition(); window.addEventListener('resize', updatePosition); window.addEventListener('scroll', updatePosition, true); return () => { isMounted = false; window.removeEventListener('resize', updatePosition); window.removeEventListener('scroll', updatePosition, true); }; }, [shouldRenderStickyGroupRow]); const getDataFn = useCallback(() => { return dataWithGroups; }, [dataWithGroups]); const extractRowId = useMemo(() => createExtractRowId(getRowId), [getRowId]); const internalState = useItemListState(getDataFn, extractRowId); // Helper function to get ItemListStateItemWithRequiredProperties (rowId is separate, not part of item) 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 = useCallback( (e: React.KeyboardEvent) => { if (!enableSelection) return; if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; e.preventDefault(); e.stopPropagation(); const selected = internalState.getSelected(); const validSelected = selected.filter(hasRequiredStateItemProperties); let currentIndex = -1; if (validSelected.length > 0) { const lastSelected = validSelected[validSelected.length - 1]; currentIndex = data.findIndex( (d) => extractRowId(d) === extractRowId(lastSelected), ); } let newIndex = 0; if (currentIndex !== -1) { newIndex = e.key === 'ArrowDown' ? Math.min(currentIndex + 1, data.length - 1) : Math.max(currentIndex - 1, 0); } const newItem: any = data[newIndex]; if (!newItem) return; const newItemListItem = getStateItem(newItem); if (newItemListItem && extractRowId(newItemListItem)) { internalState.setSelected([newItemListItem]); } // Check if we need to scroll by determining if the item is at the edge of the viewport const gridIndex = enableHeader ? newIndex + 1 : newIndex; const mainContainer = rowRef.current?.childNodes[0] as HTMLDivElement | undefined; const pinnedRightContainer = pinnedRightColumnRef.current?.childNodes[0] as | HTMLDivElement | undefined; // Use right pinned column scroll position if right-pinned columns exist const scrollContainer = pinnedRightColumnCount > 0 && pinnedRightContainer ? pinnedRightContainer : mainContainer; if (scrollContainer) { const viewportTop = scrollContainer.scrollTop; const viewportHeight = scrollContainer.clientHeight; const viewportBottom = viewportTop + viewportHeight; const rowTop = calculateScrollTopForIndex(gridIndex); const adjustedIndex = enableHeader ? Math.max(0, newIndex - 1) : newIndex; const mockCellProps: TableItemProps = { cellPadding, columns: parsedColumns, controls: {} as ItemControls, data: enableHeader ? [null, ...data] : data, enableAlternateRowColors, enableExpansion, enableHeader, enableHorizontalBorders, enableRowHoverHighlight, enableSelection, enableVerticalBorders, getRowHeight: () => DEFAULT_ROW_HEIGHT, internalState: {} as ItemListStateActions, itemType, playerContext, size, tableId, }; let calculatedRowHeight: number; if (typeof rowHeight === 'number') { calculatedRowHeight = rowHeight; } else if (typeof rowHeight === 'function') { calculatedRowHeight = rowHeight(adjustedIndex, mockCellProps); } else { calculatedRowHeight = DEFAULT_ROW_HEIGHT; } const rowBottom = rowTop + calculatedRowHeight; // Check if row is fully visible within viewport const isFullyVisible = rowTop >= viewportTop && rowBottom <= viewportBottom; // Check if row is at the edge (top or bottom of viewport) const isAtTopEdge = rowTop < viewportTop; const isAtBottomEdge = rowBottom >= viewportBottom; // Only scroll if the item is not fully visible or at the edge if (!isFullyVisible || isAtTopEdge || isAtBottomEdge) { // Determine alignment based on direction const align: 'bottom' | 'top' = e.key === 'ArrowDown' && isAtBottomEdge ? 'bottom' : e.key === 'ArrowUp' && isAtTopEdge ? 'top' : isAtBottomEdge ? 'bottom' : isAtTopEdge ? 'top' : 'top'; scrollToTableIndex(gridIndex, { align }); } } }, [ data, enableSelection, internalState, calculateScrollTopForIndex, scrollToTableIndex, extractRowId, getStateItem, pinnedRightColumnCount, enableHeader, cellPadding, parsedColumns, enableAlternateRowColors, enableExpansion, enableHorizontalBorders, enableRowHoverHighlight, enableVerticalBorders, itemType, playerContext, size, tableId, DEFAULT_ROW_HEIGHT, rowHeight, ], ); const isInitialScrollPositionSet = useRef(false); useEffect(() => { if (!initialTop || isInitialScrollPositionSet.current) return; isInitialScrollPositionSet.current = true; if (initialTop.type === 'offset') { scrollToTableOffset(initialTop.to); } else { scrollToTableIndex(initialTop.to); } }, [initialTop, scrollToTableIndex, scrollToTableOffset]); // Scroll to top when startRowIndex changes useEffect(() => { if (startRowIndex !== undefined) { scrollToTableOffset(0); } }, [startRowIndex, scrollToTableOffset]); const imperativeHandle: ItemListHandle = useMemo(() => { return { internalState, scrollToIndex: (index: number, options?: { align?: 'bottom' | 'center' | 'top' }) => { scrollToTableIndex(enableHeader ? index + 1 : index, options); }, scrollToOffset: (offset: number) => { scrollToTableOffset(offset); }, }; }, [enableHeader, internalState, scrollToTableIndex, scrollToTableOffset]); useImperativeHandle(ref, () => imperativeHandle); useEffect(() => { handleRef.current = imperativeHandle; }, [imperativeHandle]); const controls = useDefaultItemListControls({ onColumnReordered, onColumnResized, overrides: overrideControls, }); // Create itemProps for sticky header const stickyHeaderItemProps: TableItemProps = useMemo( () => ({ calculatedColumnWidths, 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: calculatedColumnWidths.slice(0, pinnedLeftColumnCount), pinnedRightColumnCount, pinnedRightColumnWidths: calculatedColumnWidths.slice( pinnedLeftColumnCount + totalColumnCount, ), playerContext, size, tableId, }), [ calculatedColumnWidths, cellPadding, controls, parsedColumns, enableAlternateRowColors, enableDrag, enableExpansion, enableHeader, enableHorizontalBorders, enableRowHoverHighlight, enableSelection, enableVerticalBorders, getRowHeight, groups, internalState, itemType, onColumnReordered, onColumnResized, pinnedLeftColumnCount, pinnedRightColumnCount, playerContext, size, tableId, totalColumnCount, ], ); 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, ]); // Calculate group row height (use same as regular table row height) const groupRowHeight = useMemo(() => { if (stickyGroupIndex === null || !groups) { const height = size === 'compact' ? 40 : size === 'large' ? 88 : 64; return typeof rowHeight === 'number' ? rowHeight : height; } // Calculate the row index for this group header let cumulativeDataIndex = 0; const headerOffset = enableHeader ? 1 : 0; for (let i = 0; i < stickyGroupIndex; i++) { cumulativeDataIndex += groups[i].itemCount; } const groupHeaderIndex = headerOffset + cumulativeDataIndex + stickyGroupIndex; // Use the regular row height for group rows 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); // Calculate the actual sticky position accounting for sticky header 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, ]); useListHotkeys({ controls, focused, internalState, itemType, }); return ( { 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 ? 1 : 0, ease: 'anticipate' }} > {StickyHeader} {StickyGroupRow} {/* {enableSelectionDialog && } */} ); }; export const ItemTableList = memo(BaseItemTableList); const ExpandedContainer = ({ internalState, itemType, }: { internalState: ItemListStateActions; itemType: LibraryItem; }) => { const hasExpanded = useItemListStateSubscription(internalState, (state) => state ? state.expanded.size > 0 : false, ); return ( {hasExpanded && ( )} ); }; ItemTableList.displayName = 'ItemTableList';