Files
feishin/src/renderer/components/item-list/item-table-list/item-table-list.tsx
T

2085 lines
80 KiB
TypeScript

// 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 { AnimatePresence } from 'motion/react';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import React, {
type JSXElementConstructor,
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,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns';
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 { 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' &&
'rowId' in item &&
typeof (item as any).rowId === '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[];
enableAlternateRowColors: boolean;
enableColumnReorder: boolean;
enableColumnResize: boolean;
enableDrag?: boolean;
enableExpansion: boolean;
enableHeader: boolean;
enableHorizontalBorders: boolean;
enableRowHoverHighlight: 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';
tableId: string;
totalColumnCount: number;
totalRowCount: number;
}
const VirtualizedTableGrid = React.memo(
({
activeRowId,
calculatedColumnWidths,
CellComponent,
cellPadding,
controls,
data,
enableAlternateRowColors,
enableColumnReorder,
enableColumnResize,
enableDrag,
enableExpansion,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
getRowHeight,
groups,
headerHeight,
internalState,
itemType,
mergedRowRef,
onRangeChanged,
parsedColumns,
pinnedLeftColumnCount,
pinnedLeftColumnRef,
pinnedRightColumnCount,
pinnedRightColumnRef,
pinnedRowCount,
pinnedRowRef,
playerContext,
showLeftShadow,
showRightShadow,
showTopShadow,
size,
tableId,
totalColumnCount,
totalRowCount,
}: VirtualizedTableGridProps) => {
const columnWidth = useCallback(
(index: number) => calculatedColumnWidths[index],
[calculatedColumnWidths],
);
// 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]);
// Create data array with group headers inserted as null values
// Groups are defined by itemCount, so we calculate indexes based on cumulative item counts
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;
}
// Calculate group header indexes based on itemCounts
const groupIndexes: number[] = [];
let cumulativeDataIndex = 0;
const headerOffset = enableHeader ? 1 : 0;
groups.forEach((group, groupIndex) => {
// Group header appears before its items
// Index = header offset + cumulative data index + number of previous group headers
const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex;
groupIndexes.push(groupHeaderIndex);
cumulativeDataIndex += group.itemCount;
});
let dataIndex = 0;
const startIndex = enableHeader ? 1 : 0;
let groupHeaderCount = 0;
// Iterate through the expanded row space (data + group headers)
for (
let rowIndex = startIndex;
rowIndex < startIndex + data.length + groupIndexes.length;
rowIndex++
) {
// Check if this row should have a group header
const expectedGroupIndex = groupIndexes[groupHeaderCount];
if (expectedGroupIndex !== undefined && rowIndex === expectedGroupIndex) {
result.push(null); // Group header row
groupHeaderCount++;
} else if (dataIndex < data.length) {
result.push(data[dataIndex]);
dataIndex++;
}
}
return result;
}, [data, enableHeader, groups]);
const adjustedRowIndexMap = useMemo(() => {
const map = new Map<number, number>();
if (!groups || groups.length === 0) {
const startIndex = enableHeader ? 1 : 0;
const endIndex = enableHeader ? dataWithGroups.length : dataWithGroups.length;
for (let rowIndex = startIndex; rowIndex < endIndex; rowIndex++) {
map.set(rowIndex, enableHeader ? rowIndex : rowIndex + 1);
}
return map;
}
const groupIndexes: number[] = [];
let cumulativeDataIndex = 0;
const headerOffset = enableHeader ? 1 : 0;
groups.forEach((group, groupIndex) => {
const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex;
groupIndexes.push(groupHeaderIndex);
cumulativeDataIndex += group.itemCount;
});
let adjustedIndex = 1;
const startIndex = enableHeader ? 0 : 0;
const endIndex = dataWithGroups.length;
for (let rowIndex = startIndex; rowIndex < endIndex; rowIndex++) {
if (enableHeader && rowIndex === 0) {
// Header row
map.set(rowIndex, 0);
} else if (groupIndexes.includes(rowIndex)) {
// Group header row - don't increment adjustedIndex
map.set(rowIndex, 0);
} else {
// Data row
map.set(rowIndex, adjustedIndex);
adjustedIndex++;
}
}
return map;
}, [dataWithGroups, enableHeader, groups]);
const itemProps: TableItemProps = useMemo(
() => ({
activeRowId,
adjustedRowIndexMap,
calculatedColumnWidths,
cellPadding,
columns: parsedColumns,
controls,
data: dataWithGroups,
enableAlternateRowColors,
enableColumnReorder,
enableColumnResize,
enableDrag,
enableExpansion,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
getRowHeight,
groups,
internalState,
itemType,
pinnedLeftColumnCount,
pinnedLeftColumnWidths,
pinnedRightColumnCount,
pinnedRightColumnWidths,
playerContext,
size,
tableId,
}),
[
activeRowId,
adjustedRowIndexMap,
calculatedColumnWidths,
cellPadding,
parsedColumns,
controls,
dataWithGroups,
enableAlternateRowColors,
enableColumnReorder,
enableColumnResize,
enableDrag,
enableExpansion,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
getRowHeight,
groups,
internalState,
itemType,
pinnedLeftColumnCount,
pinnedLeftColumnWidths,
pinnedRightColumnCount,
pinnedRightColumnWidths,
playerContext,
size,
tableId,
],
);
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}>
<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 && 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 && 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 && showLeftShadow && (
<div className={styles.itemTableLeftScrollShadow} />
)}
{pinnedRightColumnCount > 0 && 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 && 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';
export interface TableGroupHeader {
itemCount: number;
render: (props: {
data: unknown[];
groupIndex: number;
index: number;
internalState: ItemListStateActions;
startDataIndex: number;
}) => ReactElement;
}
export interface TableItemProps {
activeRowId?: string; // Active row's uniqueId for QUEUE_SONG performance optimization
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'];
getRowHeight: (index: number, cellProps: TableItemProps) => 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'];
tableId: string;
}
interface ItemTableListProps {
activeRowId?: string;
autoFitColumns?: boolean;
CellComponent: JSXElementConstructor<CellComponentProps<TableItemProps>>;
cellPadding?: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
columns: ItemTableListColumnConfig[];
currentPage?: number;
data: unknown[];
enableAlternateRowColors?: boolean;
enableDrag?: boolean;
enableExpansion?: boolean;
enableHeader?: boolean;
enableHorizontalBorders?: boolean;
enableRowHoverHighlight?: boolean;
enableSelection?: 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;
ref?: Ref<ItemListHandle>;
rowHeight?: ((index: number, cellProps: TableItemProps) => number) | number;
size?: 'compact' | 'default' | 'large';
}
export const ItemTableList = ({
activeRowId,
autoFitColumns = false,
CellComponent,
cellPadding = 'sm',
columns,
currentPage,
data,
enableAlternateRowColors = false,
enableDrag = true,
enableExpansion = true,
enableHeader = true,
enableHorizontalBorders = false,
enableRowHoverHighlight = true,
enableSelection = true,
enableStickyGroupRows = false,
enableStickyHeader = false,
enableVerticalBorders = false,
getRowId,
groups,
headerHeight = 40,
initialTop,
itemType,
onColumnReordered,
onColumnResized,
onRangeChanged,
onScrollEnd,
ref,
rowHeight,
size = 'default',
}: 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 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 containerFocusRef = useRef<HTMLDivElement | null>(null);
const { shouldShowStickyHeader, stickyTop } = useStickyTableHeader({
containerRef: containerFocusRef,
enabled: enableHeader && enableStickyHeader,
headerRef: pinnedRowRef,
});
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const stickyGroupRowRef = useRef<HTMLDivElement | null>(null);
// Sync scroll position and update position of sticky header
useEffect(() => {
if (
!shouldShowStickyHeader ||
!stickyHeaderRef.current ||
!rowRef.current ||
!containerFocusRef.current
) {
return;
}
const stickyHeader = stickyHeaderRef.current;
const stickyMainSection = stickyHeader.querySelector(
`.${styles.stickyHeaderSection}`,
) as HTMLDivElement;
const mainGrid = rowRef.current.childNodes[0] as HTMLDivElement;
const container = containerFocusRef.current;
if (!stickyMainSection || !mainGrid || !container) {
return;
}
const updatePosition = () => {
const containerRect = container.getBoundingClientRect();
stickyHeader.style.left = `${containerRect.left}px`;
};
const syncScroll = () => {
stickyMainSection.scrollLeft = mainGrid.scrollLeft;
};
updatePosition();
syncScroll();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
mainGrid.addEventListener('scroll', syncScroll);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
mainGrid.removeEventListener('scroll', syncScroll);
};
}, [shouldShowStickyHeader]);
useEffect(() => {
const el = rowRef.current;
if (!el) return;
const updateWidth = () => {
setCenterContainerWidth(el.clientWidth || 0);
};
updateWidth();
const resizeObserver = new ResizeObserver(() => {
updateWidth();
});
resizeObserver.observe(el);
return () => {
resizeObserver.disconnect();
};
}, []);
// Track total container width for autoSizeColumns
useEffect(() => {
const el = containerFocusRef.current;
if (!el || !autoFitColumns) return;
const updateWidth = () => {
setTotalContainerWidth(el.clientWidth || 0);
};
updateWidth();
const resizeObserver = new ResizeObserver(() => {
updateWidth();
});
resizeObserver.observe(el);
return () => {
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,
],
);
const [initialize] = 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: 'scroll', y: 'scroll' },
paddingAbsolute: true,
scrollbars: {
autoHide: 'leave',
autoHideDelay: 500,
pointers: ['mouse', 'pen', 'touch'],
theme: 'feishin-os-scrollbar',
},
},
});
useEffect(() => {
const { current: root } = scrollContainerRef;
if (root) {
initialize({
elements: { viewport: root.firstElementChild as HTMLElement },
target: root,
});
if (enableDrag) {
autoScrollForElements({
canScroll: () => true,
element: root.firstElementChild as HTMLElement,
getAllowedAxis: () => 'vertical',
getConfiguration: () => ({ maxScrollSpeed: 'fast' }),
});
}
}
return undefined;
}, [enableDrag, initialize]);
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);
scrollTimeouts.delete(element);
if (element === row && onScrollEndRef.current) {
onScrollEndRef.current(
row.scrollTop,
handleRef.current ?? (undefined as any),
);
}
}, 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;
// Prevent recursive scroll events
const isScrolling = {
header: false,
pinnedLeft: false,
pinnedRight: false,
row: false,
};
// 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,
});
}
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;
isScrolling.pinnedRight = false;
}
// Sync vertical scroll between left pinned column and main content (only if pinnedLeft exists)
if (pinnedLeft && e.currentTarget === pinnedLeft && !isScrolling.row) {
isScrolling.row = true;
row.scrollTo({
behavior: 'instant',
top: scrollTop,
});
isScrolling.row = false;
}
// Sync vertical scroll between right pinned column and main content (only if pinnedRight exists)
if (pinnedRight && e.currentTarget === pinnedRight && !isScrolling.row) {
isScrolling.row = true;
row.scrollTo({
behavior: 'instant',
top: scrollTop,
});
isScrolling.row = 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
const resizeObserver = new ResizeObserver(() => {
syncHeights();
});
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);
}
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 = () => {
const scrollLeft = row.scrollLeft;
const maxScrollLeft = row.scrollWidth - row.clientWidth;
setShowLeftShadow(pinnedLeftColumnCount > 0 && scrollLeft > 0);
setShowRightShadow(pinnedRightColumnCount > 0 && scrollLeft < maxScrollLeft);
};
checkScrollPosition();
row.addEventListener('scroll', checkScrollPosition);
return () => {
row.removeEventListener('scroll', checkScrollPosition);
};
}, [pinnedLeftColumnCount, pinnedRightColumnCount]);
// Handle top shadow visibility based on vertical scroll
useEffect(() => {
const row = rowRef.current?.childNodes[0] as HTMLDivElement;
if (!row || !enableHeader) {
const timeout = setTimeout(() => {
setShowTopShadow(false);
}, 0);
return () => clearTimeout(timeout);
}
const checkScrollPosition = () => {
const currentScrollTop = row.scrollTop;
setShowTopShadow(currentScrollTop > 0);
};
checkScrollPosition();
row.addEventListener('scroll', checkScrollPosition);
return () => {
row.removeEventListener('scroll', checkScrollPosition);
};
}, [enableHeader]);
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: containerFocusRef,
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;
const getDataFn = useCallback(() => {
const result: (null | unknown)[] = enableHeader ? [null] : [];
if (!groups || groups.length === 0) {
// No groups, just add all data
result.push(...data);
return result;
}
// Calculate group header indexes based on itemCounts
const groupIndexes: number[] = [];
let cumulativeDataIndex = 0;
const headerOffset = enableHeader ? 1 : 0;
groups.forEach((group, groupIndex) => {
// Index = header offset + cumulative data index + number of previous group headers
const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex;
groupIndexes.push(groupHeaderIndex);
cumulativeDataIndex += group.itemCount;
});
let dataIndex = 0;
const startIndex = enableHeader ? 1 : 0;
let groupHeaderCount = 0;
for (
let rowIndex = startIndex;
rowIndex < startIndex + data.length + groupIndexes.length;
rowIndex++
) {
const expectedGroupIndex = groupIndexes[groupHeaderCount];
if (expectedGroupIndex !== undefined && rowIndex === expectedGroupIndex) {
result.push(null);
groupHeaderCount++;
} else if (dataIndex < data.length) {
result.push(data[dataIndex]);
dataIndex++;
}
}
return result;
}, [data, enableHeader, groups]);
const extractRowId = useMemo(() => createExtractRowId(getRowId), [getRowId]);
const internalState = useItemListState(getDataFn, extractRowId);
const hasExpanded = internalState.hasExpanded();
// 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) => hasRequiredItemProperties(d) && d.id === lastSelected.id,
);
}
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;
// Handle Shift + Arrow for incremental range selection (matches shift+click behavior)
if (e.shiftKey) {
const selectedItems = internalState.getSelected();
const validSelectedItems = selectedItems.filter(hasRequiredStateItemProperties);
const lastSelectedItem = validSelectedItems[validSelectedItems.length - 1];
if (lastSelectedItem) {
// Find the indices of the last selected item and new item
const lastRowId = lastSelectedItem.rowId;
const lastIndex = data.findIndex((d) => {
const rowId = extractRowId(d);
return rowId === lastRowId;
});
if (lastIndex !== -1 && newIndex !== -1) {
// Create range selection from last selected to new position
const startIndex = Math.min(lastIndex, newIndex);
const stopIndex = Math.max(lastIndex, newIndex);
const rangeItems: ItemListStateItemWithRequiredProperties[] = [];
for (let i = startIndex; i <= stopIndex; i++) {
const rangeItem = data[i];
const stateItem = getStateItem(rangeItem);
if (stateItem && extractRowId(stateItem)) {
rangeItems.push(stateItem);
}
}
// Add range items to selection (matching shift+click behavior)
const currentSelected = internalState.getSelected();
const validSelected = currentSelected.filter(
hasRequiredStateItemProperties,
);
const newSelected: ItemListStateItemWithRequiredProperties[] = [
...validSelected,
];
rangeItems.forEach((rangeItem) => {
const rangeRowId = extractRowId(rangeItem);
if (
rangeRowId &&
!newSelected.some(
(selected) => extractRowId(selected) === rangeRowId,
)
) {
newSelected.push(rangeItem);
}
});
// Ensure the last item in selection is the item at newIndex for incremental extension
const newItemListItem = getStateItem(newItem);
if (newItemListItem && extractRowId(newItemListItem)) {
const newItemRowId = extractRowId(newItemListItem);
// Remove the new item from its current position if it exists
const filteredSelected = newSelected.filter(
(item) => extractRowId(item) !== newItemRowId,
);
// Add it at the end so it becomes the last selected item
filteredSelected.push(newItemListItem);
internalState.setSelected(filteredSelected);
}
}
} else {
// No previous selection, just select the new item
const newItemListItem = getStateItem(newItem);
if (newItemListItem && extractRowId(newItemListItem)) {
internalState.setSelected([newItemListItem]);
}
}
} else {
// Without Shift: select only the new item
const newItemListItem = getStateItem(newItem);
if (newItemListItem && extractRowId(newItemListItem)) {
internalState.setSelected([newItemListItem]);
}
}
const offset = calculateScrollTopForIndex(newIndex);
scrollToTableOffset(offset);
},
[
data,
enableSelection,
internalState,
calculateScrollTopForIndex,
scrollToTableOffset,
extractRowId,
getStateItem,
],
);
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 currentPage changes
useEffect(() => {
if (currentPage !== undefined) {
scrollToTableOffset(0);
}
}, [currentPage, 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,
});
// 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={styles.stickyHeaderSection}
style={{ width: `${pinnedLeftWidth}px` }}
>
{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={styles.stickyHeaderSection} style={{ width: `${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={{
height: headerHeight,
width: calculatedColumnWidths[columnIndex],
}}
{...stickyHeaderItemProps}
/>
);
})}
</div>
{pinnedRightColumnCount > 0 && (
<div
className={styles.stickyHeaderSection}
style={{ width: `${pinnedRightWidth}px` }}
>
{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={{ 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`,
}}
>
{groupContent}
</div>
</div>
)}
</div>
</div>
);
}, [
shouldRenderStickyGroupRow,
stickyGroupIndex,
groups,
data,
internalState,
calculatedColumnWidths,
pinnedLeftColumnCount,
pinnedRightColumnCount,
totalColumnCount,
groupRowHeight,
stickyGroupTop,
]);
return (
<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={containerFocusRef}
tabIndex={0}
>
{StickyHeader}
{StickyGroupRow}
<VirtualizedTableGrid
activeRowId={activeRowId}
calculatedColumnWidths={calculatedColumnWidths}
CellComponent={CellComponent}
cellPadding={cellPadding}
controls={controls}
data={data}
enableAlternateRowColors={enableAlternateRowColors}
enableColumnReorder={!!onColumnReordered}
enableColumnResize={!!onColumnResized}
enableDrag={enableDrag}
enableExpansion={enableExpansion}
enableHeader={enableHeader}
enableHorizontalBorders={enableHorizontalBorders}
enableRowHoverHighlight={enableRowHoverHighlight}
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}
tableId={tableId}
totalColumnCount={totalColumnCount}
totalRowCount={totalRowCount}
/>
<AnimatePresence initial={false}>
{hasExpanded && (
<ExpandedListContainer>
<ExpandedListItem internalState={internalState} itemType={itemType} />
</ExpandedListContainer>
)}
</AnimatePresence>
</div>
);
};