mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
refactor item list table drag/hover
This commit is contained in:
@@ -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 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<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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user