Files
feishin/src/renderer/components/item-list/item-table-list/item-table-list.tsx
T
2026-01-16 23:43:50 -08:00

2626 lines
99 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<CellComponentProps<TableItemProps>>;
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<HTMLDivElement>;
onRangeChanged?: ItemTableListProps['onRangeChanged'];
parsedColumns: ReturnType<typeof parseTableColumns>;
pinnedLeftColumnCount: number;
pinnedLeftColumnRef: React.RefObject<HTMLDivElement | null>;
pinnedRightColumnCount: number;
pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;
pinnedRowCount: number;
pinnedRowRef: React.RefObject<HTMLDivElement | null>;
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<HTMLDivElement | null>(null);
const columnWidth = useCallback(
(index: number) => calculatedColumnWidths[index],
[calculatedColumnWidths],
);
const groupHeaderInfoByRowIndex = useMemo(() => {
if (!groups || groups.length === 0) return undefined;
const map = new Map<number, { groupIndex: number; startDataIndex: number }>();
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 were 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 (
<CellComponent
{...cellProps}
columnIndex={cellProps.columnIndex + pinnedLeftColumnCount}
/>
);
},
[pinnedLeftColumnCount, CellComponent],
);
const PinnedColumnCell = useCallback(
(cellProps: CellComponentProps & TableItemProps) => {
return <CellComponent {...cellProps} rowIndex={cellProps.rowIndex + pinnedRowCount} />;
},
[pinnedRowCount, CellComponent],
);
const PinnedRightColumnCell = useCallback(
(cellProps: CellComponentProps & TableItemProps) => {
return (
<CellComponent
{...cellProps}
columnIndex={cellProps.columnIndex + pinnedLeftColumnCount + totalColumnCount}
rowIndex={cellProps.rowIndex + pinnedRowCount}
/>
);
},
[pinnedLeftColumnCount, pinnedRowCount, totalColumnCount, CellComponent],
);
const PinnedRightIntersectionCell = useCallback(
(cellProps: CellComponentProps & TableItemProps) => {
return (
<CellComponent
{...cellProps}
columnIndex={cellProps.columnIndex + pinnedLeftColumnCount + totalColumnCount}
/>
);
},
[pinnedLeftColumnCount, totalColumnCount, CellComponent],
);
const RowCell = useCallback(
(cellProps: CellComponentProps<TableItemProps>) => {
return (
<CellComponent
{...cellProps}
columnIndex={cellProps.columnIndex + pinnedLeftColumnCount}
rowIndex={cellProps.rowIndex + pinnedRowCount}
/>
);
},
[pinnedLeftColumnCount, pinnedRowCount, CellComponent],
);
const handleOnCellsRendered = useCallback(
(items: {
columnStartIndex: number;
columnStopIndex: number;
rowStartIndex: number;
rowStopIndex: number;
}) => {
onRangeChanged?.({
startIndex: items.rowStartIndex,
stopIndex: items.rowStopIndex,
});
},
[onRangeChanged],
);
return (
<div className={styles.itemTableContainer} ref={hoverDelegateRef}>
<div
className={styles.itemTablePinnedColumnsGridContainer}
style={
{
'--header-height': `${headerHeight}px`,
minWidth: `${Array.from({ length: pinnedLeftColumnCount }, () => 0).reduce(
(a, _, i) => a + columnWidth(i),
0,
)}px`,
} as React.CSSProperties
}
>
{!!(pinnedLeftColumnCount || pinnedRowCount) && (
<div
className={clsx(styles.itemTablePinnedIntersectionGridContainer, {
[styles.withHeader]: enableHeader,
})}
style={{
minHeight: `${Array.from({ length: pinnedRowCount }, () => 0).reduce(
(a, _, i) => a + getRowHeight(i, itemProps),
0,
)}px`,
overflow: 'hidden',
}}
>
<Grid
cellComponent={CellComponent as any}
cellProps={itemProps}
className={styles.noScrollbar}
columnCount={pinnedLeftColumnCount}
columnWidth={columnWidth}
rowCount={pinnedRowCount}
rowHeight={getRowHeight}
/>
</div>
)}
{enableHeader && enableScrollShadow && showTopShadow && (
<div className={styles.itemTableTopScrollShadow} />
)}
{!!pinnedLeftColumnCount && (
<div
className={styles.itemTablePinnedColumnsContainer}
ref={pinnedLeftColumnRef}
>
<Grid
cellComponent={PinnedColumnCell}
cellProps={itemProps}
className={clsx(styles.noScrollbar, styles.height100)}
columnCount={pinnedLeftColumnCount}
columnWidth={columnWidth}
rowCount={totalRowCount}
rowHeight={(index, cellProps) => {
return getRowHeight(index + pinnedRowCount, cellProps);
}}
/>
</div>
)}
</div>
<div
className={styles.itemTablePinnedRowsContainer}
style={
{
'--header-height': `${headerHeight}px`,
} as React.CSSProperties
}
>
{!!pinnedRowCount && (
<div
className={clsx(styles.itemTablePinnedRowsGridContainer, {
[styles.withHeader]: enableHeader,
})}
ref={pinnedRowRef}
style={
{
'--header-height': `${headerHeight}px`,
minHeight: `${Array.from(
{ length: pinnedRowCount },
() => 0,
).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`,
overflow: 'hidden',
} as React.CSSProperties
}
>
<Grid
cellComponent={PinnedRowCell}
cellProps={itemProps}
className={styles.noScrollbar}
columnCount={totalColumnCount}
columnWidth={(index) => {
return columnWidth(index + pinnedLeftColumnCount);
}}
rowCount={Array.from({ length: pinnedRowCount }, () => 0).length}
rowHeight={getRowHeight}
/>
</div>
)}
{enableHeader && enableScrollShadow && showTopShadow && (
<div className={styles.itemTableTopScrollShadow} />
)}
<div className={styles.itemTableGridContainer} ref={mergedRowRef}>
<Grid
cellComponent={RowCell}
cellProps={itemProps}
className={styles.height100}
columnCount={totalColumnCount}
columnWidth={(index) => {
return columnWidth(index + pinnedLeftColumnCount);
}}
onCellsRendered={handleOnCellsRendered}
rowCount={totalRowCount}
rowHeight={(index, cellProps) => {
return getRowHeight(index + pinnedRowCount, cellProps);
}}
/>
{pinnedLeftColumnCount > 0 && enableScrollShadow && showLeftShadow && (
<div className={styles.itemTableLeftScrollShadow} />
)}
{pinnedRightColumnCount > 0 && enableScrollShadow && showRightShadow && (
<div className={styles.itemTableRightScrollShadow} />
)}
</div>
</div>
{!!pinnedRightColumnCount && (
<div
className={styles.itemTablePinnedColumnsGridContainer}
style={
{
'--header-height': `${headerHeight}px`,
minWidth: `${Array.from(
{ length: pinnedRightColumnCount },
() => 0,
).reduce(
(a, _, i) =>
a + columnWidth(i + pinnedLeftColumnCount + totalColumnCount),
0,
)}px`,
} as React.CSSProperties
}
>
{!!(pinnedRightColumnCount || pinnedRowCount) && (
<div
className={clsx(styles.itemTablePinnedIntersectionGridContainer, {
[styles.withHeader]: enableHeader,
})}
style={{
minHeight: `${Array.from(
{ length: pinnedRowCount },
() => 0,
).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`,
overflow: 'hidden',
}}
>
<Grid
cellComponent={PinnedRightIntersectionCell}
cellProps={itemProps}
className={styles.noScrollbar}
columnCount={pinnedRightColumnCount}
columnWidth={(index) => {
return columnWidth(
index + pinnedLeftColumnCount + totalColumnCount,
);
}}
rowCount={pinnedRowCount}
rowHeight={getRowHeight}
/>
</div>
)}
{enableHeader && enableScrollShadow && showTopShadow && (
<div className={styles.itemTableTopScrollShadow} />
)}
<div
className={styles.itemTablePinnedRightColumnsContainer}
ref={pinnedRightColumnRef}
>
<Grid
cellComponent={PinnedRightColumnCell}
cellProps={itemProps}
className={clsx(styles.noScrollbar, styles.height100)}
columnCount={pinnedRightColumnCount}
columnWidth={(index) => {
return columnWidth(
index + pinnedLeftColumnCount + totalColumnCount,
);
}}
rowCount={totalRowCount}
rowHeight={(index, cellProps) => {
return getRowHeight(index + pinnedRowCount, cellProps);
}}
/>
</div>
</div>
)}
</div>
);
};
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<number, number>;
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<number, { groupIndex: number; startDataIndex: number }>;
groups?: TableGroupHeader[];
internalState: ItemListStateActions;
itemType: ItemTableListProps['itemType'];
onRowClick?: (item: any, event: React.MouseEvent<HTMLDivElement>) => 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<CellComponentProps<TableItemProps>>;
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<ItemControls>;
ref?: Ref<ItemListHandle>;
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<HTMLDivElement>(null);
const rowRef = useRef<HTMLDivElement>(null);
const pinnedLeftColumnRef = useRef<HTMLDivElement>(null);
const pinnedRightColumnRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const mergedRowRef = useMergedRef(rowRef, scrollContainerRef);
const [showLeftShadow, setShowLeftShadow] = useState(false);
const [showRightShadow, setShowRightShadow] = useState(false);
const [showTopShadow, setShowTopShadow] = useState(false);
const handleRef = useRef<ItemListHandle | null>(null);
const { focused, ref: focusRef } = useFocusWithin();
const containerRef = useRef<HTMLDivElement | null>(null);
const mergedContainerRef = useMergedRef(containerRef, focusRef);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const stickyGroupRowRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderLeftRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderMainRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRightRef = useRef<HTMLDivElement | null>(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<ItemTableListProps['onScrollEnd']>(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<HTMLDivElement>();
const scrollTimeouts = new Map<HTMLDivElement, NodeJS.Timeout>();
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<HTMLDivElement>) => {
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<boolean>(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 (
<div
className={styles.stickyHeader}
ref={stickyHeaderRef}
style={{
top: `${stickyTop}px`,
}}
>
<div className={styles.stickyHeaderRow}>
{pinnedLeftColumnCount > 0 && (
<div
className={clsx(
styles.stickyHeaderSection,
styles.stickyHeaderPinnedLeft,
)}
ref={stickyHeaderLeftRef}
style={{
flex: '0 1 auto',
minWidth: `${pinnedLeftWidth}px`,
overflow: 'hidden',
}}
>
{parsedColumns
.filter((col) => col.pinned === 'left')
.map((col) => {
const columnIndex = parsedColumns.findIndex((c) => c === col);
return (
<CellComponent
ariaAttributes={{
'aria-colindex': columnIndex + 1,
role: 'gridcell',
}}
columnIndex={columnIndex}
key={col.id}
rowIndex={0}
style={{
height: headerHeight,
width: calculatedColumnWidths[columnIndex],
}}
{...stickyHeaderItemProps}
/>
);
})}
</div>
)}
<div
className={clsx(
styles.stickyHeaderSection,
styles.stickyHeaderMain,
styles.noScrollbar,
)}
ref={stickyHeaderMainRef}
style={{
flex: '1 1 auto',
minWidth: 0,
overflowX: 'auto',
overflowY: 'hidden',
}}
>
<div
style={{
display: 'flex',
minWidth: `${mainWidth}px`,
}}
>
{parsedColumns
.filter((col) => col.pinned === null)
.map((col) => {
const columnIndex = parsedColumns.findIndex((c) => c === col);
return (
<CellComponent
ariaAttributes={{
'aria-colindex': columnIndex + 1,
role: 'gridcell',
}}
columnIndex={columnIndex}
key={col.id}
rowIndex={0}
style={{
flexShrink: 0,
height: headerHeight,
width: calculatedColumnWidths[columnIndex],
}}
{...stickyHeaderItemProps}
/>
);
})}
</div>
</div>
{pinnedRightColumnCount > 0 && (
<div
className={clsx(
styles.stickyHeaderSection,
styles.stickyHeaderPinnedRight,
)}
ref={stickyHeaderRightRef}
style={{
flex: '0 1 auto',
minWidth: `${pinnedRightWidth}px`,
overflow: 'hidden',
}}
>
{parsedColumns
.filter((col) => col.pinned === 'right')
.map((col) => {
const columnIndex = parsedColumns.findIndex((c) => c === col);
return (
<CellComponent
ariaAttributes={{
'aria-colindex': columnIndex + 1,
role: 'gridcell',
}}
columnIndex={columnIndex}
key={col.id}
rowIndex={0}
style={{
height: headerHeight,
width: calculatedColumnWidths[columnIndex],
}}
{...stickyHeaderItemProps}
/>
);
})}
</div>
)}
</div>
</div>
);
}, [
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 (
<div
className={styles.stickyGroupRow}
ref={stickyGroupRowRef}
style={{
top: `${actualStickyTop}px`,
}}
>
<div className={styles.stickyGroupRowContent}>
{pinnedLeftColumnCount > 0 && (
<div
className={styles.stickyGroupRowSection}
style={{ width: `${pinnedLeftWidth}px` }}
>
<div
style={{
height: groupRowHeight,
width: `${pinnedLeftWidth}px`,
}}
>
{groupContent}
</div>
</div>
)}
<div
className={styles.stickyGroupRowSection}
style={{
marginLeft: pinnedLeftColumnCount > 0 ? 0 : '-2rem',
marginRight: '-2rem',
paddingLeft: pinnedLeftColumnCount > 0 ? 0 : '2rem',
paddingRight: '2rem',
width: `${mainWidth}px`,
}}
>
<div
style={{
height: groupRowHeight,
marginLeft: pinnedLeftWidth > 0 ? `-${pinnedLeftWidth}px` : 0,
width: `${totalTableWidth}px`,
}}
>
{groupContent}
</div>
</div>
{pinnedRightColumnCount > 0 && (
<div
className={styles.stickyGroupRowSection}
style={{ width: `${pinnedRightWidth}px` }}
>
<div
style={{
height: groupRowHeight,
width: `${pinnedRightWidth}px`,
}}
/>
</div>
)}
</div>
</div>
);
}, [
shouldRenderStickyGroupRow,
stickyGroupIndex,
groups,
data,
internalState,
calculatedColumnWidths,
pinnedLeftColumnCount,
pinnedRightColumnCount,
totalColumnCount,
groupRowHeight,
stickyGroupTop,
]);
useListHotkeys({
controls,
focused,
internalState,
itemType,
});
return (
<motion.div
className={styles.itemTableListContainer}
onKeyDown={handleKeyDown}
onMouseDown={(e) => {
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}
<MemoizedVirtualizedTableGrid
activeRowId={activeRowId}
calculatedColumnWidths={calculatedColumnWidths}
CellComponent={CellComponent}
cellPadding={cellPadding}
controls={controls}
data={data}
dataWithGroups={dataWithGroups}
enableAlternateRowColors={enableAlternateRowColors}
enableColumnReorder={!!onColumnReordered}
enableColumnResize={!!onColumnResized}
enableDrag={enableDrag}
enableExpansion={enableExpansion}
enableHeader={enableHeader}
enableHorizontalBorders={enableHorizontalBorders}
enableRowHoverHighlight={enableRowHoverHighlight}
enableScrollShadow={enableScrollShadow}
enableSelection={enableSelection}
enableVerticalBorders={enableVerticalBorders}
getRowHeight={getRowHeight}
groups={groups}
headerHeight={headerHeight}
internalState={internalState}
itemType={itemType}
mergedRowRef={mergedRowRef}
onRangeChanged={onRangeChanged}
parsedColumns={parsedColumns}
pinnedLeftColumnCount={pinnedLeftColumnCount}
pinnedLeftColumnRef={pinnedLeftColumnRef}
pinnedRightColumnCount={pinnedRightColumnCount}
pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowCount={pinnedRowCount}
pinnedRowRef={pinnedRowRef}
playerContext={playerContext}
showLeftShadow={showLeftShadow}
showRightShadow={showRightShadow}
showTopShadow={showTopShadow}
size={size}
startRowIndex={startRowIndex}
tableId={tableId}
totalColumnCount={totalColumnCount}
totalRowCount={totalRowCount}
/>
<ExpandedContainer internalState={internalState} itemType={itemType} />
{/* {enableSelectionDialog && <SelectionDialog internalState={internalState} />} */}
</motion.div>
);
};
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 (
<AnimatePresence initial={false}>
{hasExpanded && (
<ExpandedListContainer>
<ExpandedListItem internalState={internalState} itemType={itemType} />
</ExpandedListContainer>
)}
</AnimatePresence>
);
};
ItemTableList.displayName = 'ItemTableList';