From 79e7d7a0101838db3b73e46519b4ad64d9cfe042 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 16 Jan 2026 11:38:45 -0800 Subject: [PATCH] refactor item list table drag/hover --- .../columns/row-index-column.tsx | 6 +- .../item-table-list-column.module.css | 16 +- .../item-table-list-column.tsx | 309 +++--------------- .../item-table-list/item-table-list.tsx | 304 +++++++++++++---- 4 files changed, 298 insertions(+), 337 deletions(-) diff --git a/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx b/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx index 1a399e2e9..0fd335b84 100644 --- a/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/row-index-column.tsx @@ -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); diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css b/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css index 1b1dbd9f9..58d0846f3 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css @@ -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; } diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx index b99eda0ff..aef62a76f 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx @@ -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 | 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 | 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) => { @@ -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 | 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 | 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) => { @@ -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', diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.tsx b/src/renderer/components/item-list/item-table-list/item-table-list.tsx index ab96e8052..eca6eacd2 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list.tsx @@ -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(null); + const columnWidth = useCallback( (index: number) => calculatedColumnWidths[index], [calculatedColumnWidths], ); + const groupHeaderInfoByRowIndex = useMemo(() => { + if (!groups || groups.length === 0) return undefined; + + const map = new Map(); + const headerOffset = enableHeader ? 1 : 0; + let cumulativeDataIndex = 0; + + for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) { + const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex; + map.set(groupHeaderIndex, { groupIndex, startDataIndex: cumulativeDataIndex }); + cumulativeDataIndex += groups[groupIndex].itemCount; + } + + return map; + }, [groups, enableHeader]); + + const getGroupRenderData = useCallback(() => data, [data]); + + // Row hover highlight: do one delegated listener per table rather than per cell + // This is intentionally imperative to avoid React re-rendering the entire visible grid on hover + useEffect(() => { + if (!enableRowHoverHighlight) return; + const root = hoverDelegateRef.current; + if (!root) return; + + let hoveredKey: null | string = null; + let rafId: null | number = null; + + const getRowKey = (target: EventTarget | null): null | string => { + const el = target instanceof Element ? target : null; + const rowEl = el?.closest?.('[data-row-index]') as HTMLElement | null; + return rowEl?.getAttribute('data-row-index') ?? null; + }; + + const apply = (prev: null | string, next: null | string) => { + if (rafId !== null) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + if (prev) { + root.querySelectorAll(`[data-row-index="${prev}"]`).forEach((node) => { + (node as HTMLElement).removeAttribute('data-row-hovered'); + }); + } + if (next) { + root.querySelectorAll(`[data-row-index="${next}"]`).forEach((node) => { + (node as HTMLElement).setAttribute('data-row-hovered', 'true'); + }); + } + }); + }; + + const setHovered = (next: null | string) => { + if (next === hoveredKey) return; + const prev = hoveredKey; + hoveredKey = next; + apply(prev, next); + }; + + const onPointerOver = (e: PointerEvent) => { + setHovered(getRowKey(e.target)); + }; + + const onPointerOut = (e: PointerEvent) => { + // If moving within the same row, keep it hovered + const relatedKey = getRowKey((e as any).relatedTarget); + if (relatedKey === hoveredKey) return; + setHovered(relatedKey); + }; + + root.addEventListener('pointerover', onPointerOver); + root.addEventListener('pointerout', onPointerOut); + + return () => { + root.removeEventListener('pointerover', onPointerOver); + root.removeEventListener('pointerout', onPointerOut); + if (rafId !== null) cancelAnimationFrame(rafId); + // Ensure we don't leave stale attributes behind + if (hoveredKey) apply(hoveredKey, null); + }; + }, [enableRowHoverHighlight]); + + useEffect(() => { + const root = hoverDelegateRef.current; + if (!root) return; + + let current: null | { edge: 'bottom' | 'top'; rowKey: string } = null; + let pending: null | { edge: 'bottom' | 'top' | null; rowKey: string } = null; + let rafId: null | number = null; + + const clearRow = (rowKey: string) => { + root.querySelectorAll(`[data-row-index="${rowKey}"]`).forEach((node) => { + const el = node as HTMLElement; + el.removeAttribute('data-row-dragged-over'); + el.removeAttribute('data-row-dragged-over-first'); + }); + }; + + const applyRow = (rowKey: string, edge: 'bottom' | 'top') => { + const nodes = root.querySelectorAll(`[data-row-index="${rowKey}"]`); + nodes.forEach((node, idx) => { + const el = node as HTMLElement; + el.setAttribute('data-row-dragged-over', edge); + if (idx === 0) { + el.setAttribute('data-row-dragged-over-first', 'true'); + } else { + el.removeAttribute('data-row-dragged-over-first'); + } + }); + }; + + const flush = () => { + rafId = null; + const next = pending; + pending = null; + if (!next) return; + + // Clear previous row if we’re moving rows or clearing. + if (current && current.rowKey !== next.rowKey) { + clearRow(current.rowKey); + current = null; + } + + if (!next.edge) { + if (current) { + clearRow(current.rowKey); + current = null; + } + return; + } + + // If same row + edge, no-op. + if (current && current.rowKey === next.rowKey && current.edge === next.edge) return; + + if (current) clearRow(current.rowKey); + applyRow(next.rowKey, next.edge); + current = { edge: next.edge, rowKey: next.rowKey }; + }; + + const scheduleFlush = () => { + if (rafId !== null) return; + rafId = requestAnimationFrame(flush); + }; + + const onRowDragOver = (e: Event) => { + const ev = e as CustomEvent<{ edge?: 'bottom' | 'top' | null; rowKey?: string }>; + const rowKey = ev.detail?.rowKey; + const edge = ev.detail?.edge ?? null; + if (!rowKey) return; + + pending = { edge, rowKey }; + scheduleFlush(); + }; + + root.addEventListener('itl:row-drag-over', onRowDragOver as any); + + return () => { + root.removeEventListener('itl:row-drag-over', onRowDragOver as any); + if (rafId !== null) cancelAnimationFrame(rafId); + if (current) clearRow(current.rowKey); + }; + }, []); + // Calculate pinned column widths for group header positioning const pinnedLeftColumnWidths = useMemo(() => { return Array.from({ length: pinnedLeftColumnCount }, (_, i) => columnWidth(i)); @@ -190,48 +353,65 @@ const VirtualizedTableGrid = ({ ); }, [pinnedRightColumnCount, pinnedLeftColumnCount, totalColumnCount, columnWidth]); - const adjustedRowIndexMap = useMemo(() => { - const map = new Map(); + 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 ( -
+
number; + getGroupRenderData?: () => unknown[]; getRowHeight: (index: number, cellProps: TableItemProps) => number; + groupHeaderInfoByRowIndex?: Map; 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]);