refactor item list table drag/hover

This commit is contained in:
jeffvli
2026-01-16 11:38:45 -08:00
parent 431ff76e19
commit 79e7d7a010
4 changed files with 298 additions and 337 deletions
@@ -33,7 +33,6 @@ export const RowIndexColumn = (props: ItemTableListInnerColumn) => {
const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => {
const {
adjustedRowIndexMap,
controls,
data,
enableExpansion,
@@ -45,7 +44,9 @@ const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => {
} = props;
let adjustedRowIndex =
adjustedRowIndexMap?.get(rowIndex) ?? (enableHeader ? rowIndex : rowIndex + 1);
props.getAdjustedRowIndex?.(rowIndex) ??
props.adjustedRowIndexMap?.get(rowIndex) ??
(enableHeader ? rowIndex : rowIndex + 1);
if (startRowIndex !== undefined && adjustedRowIndex > 0) {
adjustedRowIndex = startRowIndex + adjustedRowIndex;
@@ -93,6 +94,7 @@ const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => {
const isActiveAndPlaying = isActive && status === PlayerStatus.PLAYING;
let adjustedRowIndex =
props.getAdjustedRowIndex?.(props.rowIndex) ??
props.adjustedRowIndexMap?.get(props.rowIndex) ??
(props.enableHeader ? props.rowIndex : props.rowIndex + 1);
@@ -100,7 +100,7 @@
}
}
.container.data-row.row-hover-highlight-enabled.row-hovered::before {
.container.data-row.row-hover-highlight-enabled[data-row-hovered='true']::before {
position: absolute;
top: 0;
right: 0;
@@ -137,7 +137,7 @@
opacity: 0.5;
}
.container.data-row.dragged-over-top::after {
.container.data-row[data-row-dragged-over='top']::after {
position: absolute;
top: -1px;
right: 0;
@@ -149,14 +149,14 @@
background-color: var(--theme-colors-primary);
}
.container.data-row.dragged-over-top.dragged-over-first-cell::after {
.container.data-row[data-row-dragged-over='top'][data-row-dragged-over-first='true']::after {
right: -9999px;
left: -9999px;
margin-right: 9999px;
margin-left: 9999px;
}
.container.data-row.dragged-over-bottom::after {
.container.data-row[data-row-dragged-over='bottom']::after {
position: absolute;
right: 0;
bottom: -1px;
@@ -168,7 +168,7 @@
background-color: var(--theme-colors-primary);
}
.container.data-row.dragged-over-bottom.dragged-over-first-cell::after {
.container.data-row[data-row-dragged-over='bottom'][data-row-dragged-over-first='true']::after {
right: -9999px;
left: -9999px;
margin-right: 9999px;
@@ -287,12 +287,12 @@
}
.container.data-row:hover :global(.hover-only),
.container.data-row.row-hovered :global(.hover-only) {
.container.data-row[data-row-hovered='true'] :global(.hover-only) {
display: block;
}
.container.data-row:hover :global(.hover-only-flex),
.container.data-row.row-hovered :global(.hover-only-flex) {
.container.data-row[data-row-hovered='true'] :global(.hover-only-flex) {
display: flex;
}
@@ -301,7 +301,7 @@
}
.container.data-row:hover :global(.hide-on-hover),
.container.data-row.row-hovered :global(.hide-on-hover) {
.container.data-row[data-row-hovered='true'] :global(.hide-on-hover) {
display: none;
}
@@ -93,44 +93,33 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
// to maintain proper styling and row heights
let groupHeader: 'GROUP_HEADER' | null | ReactElement = null;
if (props.groups && isDataRow && props.groups.length > 0) {
// Calculate which group this row index belongs to
let cumulativeDataIndex = 0;
const headerOffset = props.enableHeader ? 1 : 0;
const groupInfo = props.groupHeaderInfoByRowIndex?.get(props.rowIndex);
const group = groupInfo ? props.groups[groupInfo.groupIndex] : undefined;
const originalData = props.data.filter((item) => item !== null);
if (groupInfo && group) {
// Determine where to render the group header content:
// - If pinned left columns exist, render in the first pinned left column
// - Otherwise, render in the first column of the main grid
const hasPinnedLeftColumns = (props.pinnedLeftColumnCount || 0) > 0;
const isFirstPinnedLeftColumn = props.columnIndex === 0 && hasPinnedLeftColumns;
const isMainGridFirstColumn =
!hasPinnedLeftColumns &&
(props.columnIndex === (props.pinnedLeftColumnCount || 0) ||
(props.columnIndex === 0 && (props.pinnedLeftColumnCount || 0) === 0));
for (let groupIndex = 0; groupIndex < props.groups.length; groupIndex++) {
const group = props.groups[groupIndex];
const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex;
if (props.rowIndex === groupHeaderIndex) {
// Determine where to render the group header content:
// - If pinned left columns exist, render in the first pinned left column
// - Otherwise, render in the first column of the main grid
const hasPinnedLeftColumns = (props.pinnedLeftColumnCount || 0) > 0;
const isFirstPinnedLeftColumn = props.columnIndex === 0 && hasPinnedLeftColumns;
const isMainGridFirstColumn =
!hasPinnedLeftColumns &&
(props.columnIndex === (props.pinnedLeftColumnCount || 0) ||
(props.columnIndex === 0 && (props.pinnedLeftColumnCount || 0) === 0));
// Render group header content in the first pinned left column (if exists) or first main grid column
if (isFirstPinnedLeftColumn || isMainGridFirstColumn) {
groupHeader = group.render({
data: originalData,
groupIndex,
index: props.rowIndex,
internalState: props.internalState,
startDataIndex: cumulativeDataIndex,
});
} else {
// For other columns, mark as group header row for styled rendering
groupHeader = 'GROUP_HEADER';
}
break;
// Render group header content in the first pinned left column (if exists) or first main grid column
if (isFirstPinnedLeftColumn || isMainGridFirstColumn) {
groupHeader = group.render({
data: props.getGroupRenderData?.() ?? [],
groupIndex: groupInfo.groupIndex,
index: props.rowIndex,
internalState: props.internalState,
startDataIndex: groupInfo.startDataIndex,
});
} else {
// For other columns, mark as group header row for styled rendering
groupHeader = 'GROUP_HEADER';
}
cumulativeDataIndex += group.itemCount;
}
}
@@ -613,121 +602,22 @@ export const TableColumnTextContainer = (
? props.rowIndex === props.data.length
: props.rowIndex === props.data.length - 1);
useEffect(() => {
if (!isDataRow || !containerRef.current || !props.enableRowHoverHighlight) return;
const container = containerRef.current;
const rowIndex = props.rowIndex;
const rowSelector = `[data-row-index="${props.tableId}-${rowIndex}"]`;
let rafId: null | number = null;
let cachedCells: NodeListOf<Element> | null = null;
const getCells = () => {
if (!cachedCells) {
cachedCells = document.querySelectorAll(rowSelector);
}
return cachedCells;
};
const handleMouseEnter = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
const cells = getCells();
cells.forEach((cell) => cell.classList.add(styles.rowHovered));
});
};
const handleMouseLeave = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
const cells = getCells();
cells.forEach((cell) => cell.classList.remove(styles.rowHovered));
cachedCells = null;
});
};
container.addEventListener('mouseenter', handleMouseEnter);
container.addEventListener('mouseleave', handleMouseLeave);
return () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
container.removeEventListener('mouseenter', handleMouseEnter);
container.removeEventListener('mouseleave', handleMouseLeave);
cachedCells = null;
};
}, [isDataRow, props.rowIndex, props.enableRowHoverHighlight, props.tableId]);
// Apply dragged over state to all cells in the row so border can span entire row
useEffect(() => {
if (!isDataRow || !containerRef.current) return;
const rowKey = `${props.tableId}-${props.rowIndex}`;
const edge =
props.isDraggedOver === 'top' || props.isDraggedOver === 'bottom'
? props.isDraggedOver
: null;
const rowIndex = props.rowIndex;
const draggedOverState = props.isDraggedOver;
const rowSelector = `[data-row-index="${props.tableId}-${rowIndex}"]`;
let rafId: null | number = null;
let cachedCells: NodeListOf<Element> | null = null;
const getCells = () => {
if (!cachedCells) {
cachedCells = document.querySelectorAll(rowSelector);
}
return cachedCells;
};
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
const cells = getCells();
if (draggedOverState) {
cells.forEach((cell, index) => {
if (draggedOverState === 'top') {
cell.classList.add(styles.draggedOverTop);
cell.classList.remove(styles.draggedOverBottom);
// Mark first cell so border can span full width
if (index === 0) {
cell.classList.add(styles.draggedOverFirstCell);
} else {
cell.classList.remove(styles.draggedOverFirstCell);
}
} else if (draggedOverState === 'bottom') {
cell.classList.add(styles.draggedOverBottom);
cell.classList.remove(styles.draggedOverTop);
// Mark first cell so border can span full width
if (index === 0) {
cell.classList.add(styles.draggedOverFirstCell);
} else {
cell.classList.remove(styles.draggedOverFirstCell);
}
}
});
} else {
// Remove dragged over classes from all cells in the same row
cells.forEach((cell) => {
cell.classList.remove(styles.draggedOverTop);
cell.classList.remove(styles.draggedOverBottom);
cell.classList.remove(styles.draggedOverFirstCell);
});
// Clear cache when state is cleared
cachedCells = null;
}
});
return () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
cachedCells = null;
};
}, [isDataRow, props.rowIndex, props.isDraggedOver, props.tableId]);
containerRef.current.dispatchEvent(
new CustomEvent('itl:row-drag-over', {
bubbles: true,
detail: { edge, rowKey },
}),
);
}, [isDataRow, props.isDraggedOver, props.rowIndex, props.tableId]);
const handleClick = useDoubleClick({
onDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => {
@@ -793,8 +683,6 @@ export const TableColumnTextContainer = (
[styles.center]: props.columns[props.columnIndex].align === 'center',
[styles.compact]: props.size === 'compact',
[styles.dataRow]: isDataRow,
[styles.draggedOverBottom]: isDataRow && props.isDraggedOver === 'bottom',
[styles.draggedOverTop]: isDataRow && props.isDraggedOver === 'top',
[styles.dragging]: isDataRow && isDragging,
[styles.large]: props.size === 'large',
[styles.left]: props.columns[props.columnIndex].align === 'start',
@@ -865,121 +753,22 @@ export const TableColumnContainer = (
? props.rowIndex === props.data.length
: props.rowIndex === props.data.length - 1);
useEffect(() => {
if (!isDataRow || !containerRef.current || !props.enableRowHoverHighlight) return;
const container = containerRef.current;
const rowIndex = props.rowIndex;
const rowSelector = `[data-row-index="${props.tableId}-${rowIndex}"]`;
let rafId: null | number = null;
let cachedCells: NodeListOf<Element> | null = null;
const getCells = () => {
if (!cachedCells) {
cachedCells = document.querySelectorAll(rowSelector);
}
return cachedCells;
};
const handleMouseEnter = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
const cells = getCells();
cells.forEach((cell) => cell.classList.add(styles.rowHovered));
});
};
const handleMouseLeave = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
const cells = getCells();
cells.forEach((cell) => cell.classList.remove(styles.rowHovered));
cachedCells = null;
});
};
container.addEventListener('mouseenter', handleMouseEnter);
container.addEventListener('mouseleave', handleMouseLeave);
return () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
container.removeEventListener('mouseenter', handleMouseEnter);
container.removeEventListener('mouseleave', handleMouseLeave);
cachedCells = null;
};
}, [isDataRow, props.rowIndex, props.enableRowHoverHighlight, props.tableId]);
// Apply dragged over state to all cells in the row so border can span entire row
useEffect(() => {
if (!isDataRow || !containerRef.current) return;
const rowKey = `${props.tableId}-${props.rowIndex}`;
const edge =
props.isDraggedOver === 'top' || props.isDraggedOver === 'bottom'
? props.isDraggedOver
: null;
const rowIndex = props.rowIndex;
const draggedOverState = props.isDraggedOver;
const rowSelector = `[data-row-index="${props.tableId}-${rowIndex}"]`;
let rafId: null | number = null;
let cachedCells: NodeListOf<Element> | null = null;
const getCells = () => {
if (!cachedCells) {
cachedCells = document.querySelectorAll(rowSelector);
}
return cachedCells;
};
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
const cells = getCells();
if (draggedOverState) {
cells.forEach((cell, index) => {
if (draggedOverState === 'top') {
cell.classList.add(styles.draggedOverTop);
cell.classList.remove(styles.draggedOverBottom);
// Mark first cell so border can span full width
if (index === 0) {
cell.classList.add(styles.draggedOverFirstCell);
} else {
cell.classList.remove(styles.draggedOverFirstCell);
}
} else if (draggedOverState === 'bottom') {
cell.classList.add(styles.draggedOverBottom);
cell.classList.remove(styles.draggedOverTop);
// Mark first cell so border can span full width
if (index === 0) {
cell.classList.add(styles.draggedOverFirstCell);
} else {
cell.classList.remove(styles.draggedOverFirstCell);
}
}
});
} else {
// Remove dragged over classes from all cells in the same row
cells.forEach((cell) => {
cell.classList.remove(styles.draggedOverTop);
cell.classList.remove(styles.draggedOverBottom);
cell.classList.remove(styles.draggedOverFirstCell);
});
// Clear cache when state is cleared
cachedCells = null;
}
});
return () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
cachedCells = null;
};
}, [isDataRow, props.rowIndex, props.isDraggedOver, props.tableId]);
containerRef.current.dispatchEvent(
new CustomEvent('itl:row-drag-over', {
bubbles: true,
detail: { edge, rowKey },
}),
);
}, [isDataRow, props.isDraggedOver, props.rowIndex, props.tableId]);
const handleClick = useDoubleClick({
onDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => {
@@ -1045,8 +834,6 @@ export const TableColumnContainer = (
[styles.center]: props.columns[props.columnIndex].align === 'center',
[styles.compact]: props.size === 'compact',
[styles.dataRow]: isDataRow,
[styles.draggedOverBottom]: isDataRow && props.isDraggedOver === 'bottom',
[styles.draggedOverTop]: isDataRow && props.isDraggedOver === 'top',
[styles.dragging]: isDataRow && isDragging,
[styles.large]: props.size === 'large',
[styles.left]: props.columns[props.columnIndex].align === 'start',
@@ -137,7 +137,7 @@ const VirtualizedTableGrid = ({
CellComponent,
cellPadding,
controls,
// data,
data,
dataWithGroups,
enableAlternateRowColors,
enableColumnReorder,
@@ -174,11 +174,174 @@ const VirtualizedTableGrid = ({
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));
@@ -190,48 +353,65 @@ const VirtualizedTableGrid = ({
);
}, [pinnedRightColumnCount, pinnedLeftColumnCount, totalColumnCount, columnWidth]);
const adjustedRowIndexMap = useMemo(() => {
const map = new Map<number, number>();
const groupHeaderRowIndexes = useMemo(() => {
if (!groupHeaderInfoByRowIndex || groupHeaderInfoByRowIndex.size === 0) return [];
return Array.from(groupHeaderInfoByRowIndex.keys()).sort((a, b) => a - b);
}, [groupHeaderInfoByRowIndex]);
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);
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;
}
return map;
}
const groupIndexes: number[] = [];
let cumulativeDataIndex = 0;
const headerOffset = enableHeader ? 1 : 0;
if (enableHeader && rowIndex === 0) return 0;
if (groupHeaderInfoByRowIndex?.has(rowIndex)) return 0;
groups.forEach((group, groupIndex) => {
const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex;
groupIndexes.push(groupHeaderIndex);
cumulativeDataIndex += group.itemCount;
});
const headerOffset = enableHeader ? 1 : 0;
const cache = adjustedRowIndexCacheRef.current;
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);
// 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 {
// Data row
map.set(rowIndex, adjustedIndex);
adjustedIndex++;
// 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;
}
}
return map;
}, [dataWithGroups, enableHeader, groups]);
cache.lastRowIndex = rowIndex;
cache.pos = pos;
const groupHeadersBefore = pos;
const dataIndexZeroBased = rowIndex - headerOffset - groupHeadersBefore;
return dataIndexZeroBased + 1;
},
[enableHeader, groupHeaderInfoByRowIndex, groupHeaderRowIndexes, groups],
);
const stableConfigProps = useMemo(
() => ({
@@ -263,9 +443,11 @@ const VirtualizedTableGrid = ({
const dynamicDataProps = useMemo(
() => ({
activeRowId,
adjustedRowIndexMap,
calculatedColumnWidths,
data: dataWithGroups,
getAdjustedRowIndex,
getGroupRenderData,
groupHeaderInfoByRowIndex,
pinnedLeftColumnCount,
pinnedLeftColumnWidths,
pinnedRightColumnCount,
@@ -274,9 +456,11 @@ const VirtualizedTableGrid = ({
}),
[
activeRowId,
adjustedRowIndexMap,
calculatedColumnWidths,
dataWithGroups,
getAdjustedRowIndex,
getGroupRenderData,
groupHeaderInfoByRowIndex,
pinnedLeftColumnCount,
pinnedLeftColumnWidths,
pinnedRightColumnCount,
@@ -394,7 +578,7 @@ const VirtualizedTableGrid = ({
);
return (
<div className={styles.itemTableContainer}>
<div className={styles.itemTableContainer} ref={hoverDelegateRef}>
<div
className={styles.itemTablePinnedColumnsGridContainer}
style={
@@ -670,7 +854,10 @@ export interface TableItemProps {
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'];
@@ -782,39 +969,24 @@ const BaseItemTableList = ({
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;
});
// Build the expanded row model: [header?] + (groupHeader + groupItems)* + any remaining items.
let dataIndex = 0;
const startIndex = enableHeader ? 1 : 0;
let groupHeaderCount = 0;
for (const group of groups) {
// Group header row
result.push(null);
// 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) {
// Group items
const end = Math.min(data.length, dataIndex + group.itemCount);
for (; dataIndex < end; dataIndex++) {
result.push(data[dataIndex]);
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]);