mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +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 DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => {
|
||||||
const {
|
const {
|
||||||
adjustedRowIndexMap,
|
|
||||||
controls,
|
controls,
|
||||||
data,
|
data,
|
||||||
enableExpansion,
|
enableExpansion,
|
||||||
@@ -45,7 +44,9 @@ const DefaultRowIndexColumn = (props: ItemTableListInnerColumn) => {
|
|||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
let adjustedRowIndex =
|
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) {
|
if (startRowIndex !== undefined && adjustedRowIndex > 0) {
|
||||||
adjustedRowIndex = startRowIndex + adjustedRowIndex;
|
adjustedRowIndex = startRowIndex + adjustedRowIndex;
|
||||||
@@ -93,6 +94,7 @@ const QueueSongRowIndexColumn = (props: ItemTableListInnerColumn) => {
|
|||||||
const isActiveAndPlaying = isActive && status === PlayerStatus.PLAYING;
|
const isActiveAndPlaying = isActive && status === PlayerStatus.PLAYING;
|
||||||
|
|
||||||
let adjustedRowIndex =
|
let adjustedRowIndex =
|
||||||
|
props.getAdjustedRowIndex?.(props.rowIndex) ??
|
||||||
props.adjustedRowIndexMap?.get(props.rowIndex) ??
|
props.adjustedRowIndexMap?.get(props.rowIndex) ??
|
||||||
(props.enableHeader ? props.rowIndex : props.rowIndex + 1);
|
(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;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container.data-row.dragged-over-top::after {
|
.container.data-row[data-row-dragged-over='top']::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -1px;
|
top: -1px;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -149,14 +149,14 @@
|
|||||||
background-color: var(--theme-colors-primary);
|
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;
|
right: -9999px;
|
||||||
left: -9999px;
|
left: -9999px;
|
||||||
margin-right: 9999px;
|
margin-right: 9999px;
|
||||||
margin-left: 9999px;
|
margin-left: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container.data-row.dragged-over-bottom::after {
|
.container.data-row[data-row-dragged-over='bottom']::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: -1px;
|
bottom: -1px;
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
background-color: var(--theme-colors-primary);
|
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;
|
right: -9999px;
|
||||||
left: -9999px;
|
left: -9999px;
|
||||||
margin-right: 9999px;
|
margin-right: 9999px;
|
||||||
@@ -287,12 +287,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.container.data-row:hover :global(.hover-only),
|
.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;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container.data-row:hover :global(.hover-only-flex),
|
.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;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +301,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.container.data-row:hover :global(.hide-on-hover),
|
.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;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,44 +93,33 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
|
|||||||
// to maintain proper styling and row heights
|
// to maintain proper styling and row heights
|
||||||
let groupHeader: 'GROUP_HEADER' | null | ReactElement = null;
|
let groupHeader: 'GROUP_HEADER' | null | ReactElement = null;
|
||||||
if (props.groups && isDataRow && props.groups.length > 0) {
|
if (props.groups && isDataRow && props.groups.length > 0) {
|
||||||
// Calculate which group this row index belongs to
|
const groupInfo = props.groupHeaderInfoByRowIndex?.get(props.rowIndex);
|
||||||
let cumulativeDataIndex = 0;
|
const group = groupInfo ? props.groups[groupInfo.groupIndex] : undefined;
|
||||||
const headerOffset = props.enableHeader ? 1 : 0;
|
|
||||||
|
|
||||||
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++) {
|
// Render group header content in the first pinned left column (if exists) or first main grid column
|
||||||
const group = props.groups[groupIndex];
|
if (isFirstPinnedLeftColumn || isMainGridFirstColumn) {
|
||||||
const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex;
|
groupHeader = group.render({
|
||||||
|
data: props.getGroupRenderData?.() ?? [],
|
||||||
if (props.rowIndex === groupHeaderIndex) {
|
groupIndex: groupInfo.groupIndex,
|
||||||
// Determine where to render the group header content:
|
index: props.rowIndex,
|
||||||
// - If pinned left columns exist, render in the first pinned left column
|
internalState: props.internalState,
|
||||||
// - Otherwise, render in the first column of the main grid
|
startDataIndex: groupInfo.startDataIndex,
|
||||||
const hasPinnedLeftColumns = (props.pinnedLeftColumnCount || 0) > 0;
|
});
|
||||||
const isFirstPinnedLeftColumn = props.columnIndex === 0 && hasPinnedLeftColumns;
|
} else {
|
||||||
const isMainGridFirstColumn =
|
// For other columns, mark as group header row for styled rendering
|
||||||
!hasPinnedLeftColumns &&
|
groupHeader = 'GROUP_HEADER';
|
||||||
(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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cumulativeDataIndex += group.itemCount;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,121 +602,22 @@ export const TableColumnTextContainer = (
|
|||||||
? props.rowIndex === props.data.length
|
? props.rowIndex === props.data.length
|
||||||
: props.rowIndex === props.data.length - 1);
|
: 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
|
// Apply dragged over state to all cells in the row so border can span entire row
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDataRow || !containerRef.current) return;
|
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;
|
containerRef.current.dispatchEvent(
|
||||||
const draggedOverState = props.isDraggedOver;
|
new CustomEvent('itl:row-drag-over', {
|
||||||
const rowSelector = `[data-row-index="${props.tableId}-${rowIndex}"]`;
|
bubbles: true,
|
||||||
let rafId: null | number = null;
|
detail: { edge, rowKey },
|
||||||
let cachedCells: NodeListOf<Element> | null = null;
|
}),
|
||||||
|
);
|
||||||
const getCells = () => {
|
}, [isDataRow, props.isDraggedOver, props.rowIndex, props.tableId]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleClick = useDoubleClick({
|
const handleClick = useDoubleClick({
|
||||||
onDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => {
|
onDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
@@ -793,8 +683,6 @@ export const TableColumnTextContainer = (
|
|||||||
[styles.center]: props.columns[props.columnIndex].align === 'center',
|
[styles.center]: props.columns[props.columnIndex].align === 'center',
|
||||||
[styles.compact]: props.size === 'compact',
|
[styles.compact]: props.size === 'compact',
|
||||||
[styles.dataRow]: isDataRow,
|
[styles.dataRow]: isDataRow,
|
||||||
[styles.draggedOverBottom]: isDataRow && props.isDraggedOver === 'bottom',
|
|
||||||
[styles.draggedOverTop]: isDataRow && props.isDraggedOver === 'top',
|
|
||||||
[styles.dragging]: isDataRow && isDragging,
|
[styles.dragging]: isDataRow && isDragging,
|
||||||
[styles.large]: props.size === 'large',
|
[styles.large]: props.size === 'large',
|
||||||
[styles.left]: props.columns[props.columnIndex].align === 'start',
|
[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
|
||||||
: props.rowIndex === props.data.length - 1);
|
: 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
|
// Apply dragged over state to all cells in the row so border can span entire row
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDataRow || !containerRef.current) return;
|
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;
|
containerRef.current.dispatchEvent(
|
||||||
const draggedOverState = props.isDraggedOver;
|
new CustomEvent('itl:row-drag-over', {
|
||||||
const rowSelector = `[data-row-index="${props.tableId}-${rowIndex}"]`;
|
bubbles: true,
|
||||||
let rafId: null | number = null;
|
detail: { edge, rowKey },
|
||||||
let cachedCells: NodeListOf<Element> | null = null;
|
}),
|
||||||
|
);
|
||||||
const getCells = () => {
|
}, [isDataRow, props.isDraggedOver, props.rowIndex, props.tableId]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleClick = useDoubleClick({
|
const handleClick = useDoubleClick({
|
||||||
onDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => {
|
onDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
@@ -1045,8 +834,6 @@ export const TableColumnContainer = (
|
|||||||
[styles.center]: props.columns[props.columnIndex].align === 'center',
|
[styles.center]: props.columns[props.columnIndex].align === 'center',
|
||||||
[styles.compact]: props.size === 'compact',
|
[styles.compact]: props.size === 'compact',
|
||||||
[styles.dataRow]: isDataRow,
|
[styles.dataRow]: isDataRow,
|
||||||
[styles.draggedOverBottom]: isDataRow && props.isDraggedOver === 'bottom',
|
|
||||||
[styles.draggedOverTop]: isDataRow && props.isDraggedOver === 'top',
|
|
||||||
[styles.dragging]: isDataRow && isDragging,
|
[styles.dragging]: isDataRow && isDragging,
|
||||||
[styles.large]: props.size === 'large',
|
[styles.large]: props.size === 'large',
|
||||||
[styles.left]: props.columns[props.columnIndex].align === 'start',
|
[styles.left]: props.columns[props.columnIndex].align === 'start',
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ const VirtualizedTableGrid = ({
|
|||||||
CellComponent,
|
CellComponent,
|
||||||
cellPadding,
|
cellPadding,
|
||||||
controls,
|
controls,
|
||||||
// data,
|
data,
|
||||||
dataWithGroups,
|
dataWithGroups,
|
||||||
enableAlternateRowColors,
|
enableAlternateRowColors,
|
||||||
enableColumnReorder,
|
enableColumnReorder,
|
||||||
@@ -174,11 +174,174 @@ const VirtualizedTableGrid = ({
|
|||||||
totalColumnCount,
|
totalColumnCount,
|
||||||
totalRowCount,
|
totalRowCount,
|
||||||
}: VirtualizedTableGridProps) => {
|
}: VirtualizedTableGridProps) => {
|
||||||
|
const hoverDelegateRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const columnWidth = useCallback(
|
const columnWidth = useCallback(
|
||||||
(index: number) => calculatedColumnWidths[index],
|
(index: number) => calculatedColumnWidths[index],
|
||||||
[calculatedColumnWidths],
|
[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
|
// Calculate pinned column widths for group header positioning
|
||||||
const pinnedLeftColumnWidths = useMemo(() => {
|
const pinnedLeftColumnWidths = useMemo(() => {
|
||||||
return Array.from({ length: pinnedLeftColumnCount }, (_, i) => columnWidth(i));
|
return Array.from({ length: pinnedLeftColumnCount }, (_, i) => columnWidth(i));
|
||||||
@@ -190,48 +353,65 @@ const VirtualizedTableGrid = ({
|
|||||||
);
|
);
|
||||||
}, [pinnedRightColumnCount, pinnedLeftColumnCount, totalColumnCount, columnWidth]);
|
}, [pinnedRightColumnCount, pinnedLeftColumnCount, totalColumnCount, columnWidth]);
|
||||||
|
|
||||||
const adjustedRowIndexMap = useMemo(() => {
|
const groupHeaderRowIndexes = useMemo(() => {
|
||||||
const map = new Map<number, number>();
|
if (!groupHeaderInfoByRowIndex || groupHeaderInfoByRowIndex.size === 0) return [];
|
||||||
|
return Array.from(groupHeaderInfoByRowIndex.keys()).sort((a, b) => a - b);
|
||||||
|
}, [groupHeaderInfoByRowIndex]);
|
||||||
|
|
||||||
if (!groups || groups.length === 0) {
|
const adjustedRowIndexCacheRef = useRef<{ lastRowIndex: number; pos: number }>({
|
||||||
const startIndex = enableHeader ? 1 : 0;
|
lastRowIndex: -1,
|
||||||
const endIndex = enableHeader ? dataWithGroups.length : dataWithGroups.length;
|
pos: 0,
|
||||||
for (let rowIndex = startIndex; rowIndex < endIndex; rowIndex++) {
|
});
|
||||||
map.set(rowIndex, enableHeader ? rowIndex : rowIndex + 1);
|
|
||||||
|
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[] = [];
|
if (enableHeader && rowIndex === 0) return 0;
|
||||||
let cumulativeDataIndex = 0;
|
if (groupHeaderInfoByRowIndex?.has(rowIndex)) return 0;
|
||||||
const headerOffset = enableHeader ? 1 : 0;
|
|
||||||
|
|
||||||
groups.forEach((group, groupIndex) => {
|
const headerOffset = enableHeader ? 1 : 0;
|
||||||
const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex;
|
const cache = adjustedRowIndexCacheRef.current;
|
||||||
groupIndexes.push(groupHeaderIndex);
|
|
||||||
cumulativeDataIndex += group.itemCount;
|
|
||||||
});
|
|
||||||
|
|
||||||
let adjustedIndex = 1;
|
// Count group header rows strictly before this rowIndex.
|
||||||
const startIndex = enableHeader ? 0 : 0;
|
let pos: number;
|
||||||
const endIndex = dataWithGroups.length;
|
if (cache.lastRowIndex !== -1 && rowIndex >= cache.lastRowIndex) {
|
||||||
|
pos = cache.pos;
|
||||||
for (let rowIndex = startIndex; rowIndex < endIndex; rowIndex++) {
|
while (
|
||||||
if (enableHeader && rowIndex === 0) {
|
pos < groupHeaderRowIndexes.length &&
|
||||||
// Header row
|
groupHeaderRowIndexes[pos] < rowIndex
|
||||||
map.set(rowIndex, 0);
|
) {
|
||||||
} else if (groupIndexes.includes(rowIndex)) {
|
pos++;
|
||||||
// Group header row - don't increment adjustedIndex
|
}
|
||||||
map.set(rowIndex, 0);
|
|
||||||
} else {
|
} else {
|
||||||
// Data row
|
// upperBound(groupHeaderRowIndexes, rowIndex - 1)
|
||||||
map.set(rowIndex, adjustedIndex);
|
let lo = 0;
|
||||||
adjustedIndex++;
|
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;
|
cache.lastRowIndex = rowIndex;
|
||||||
}, [dataWithGroups, enableHeader, groups]);
|
cache.pos = pos;
|
||||||
|
|
||||||
|
const groupHeadersBefore = pos;
|
||||||
|
const dataIndexZeroBased = rowIndex - headerOffset - groupHeadersBefore;
|
||||||
|
return dataIndexZeroBased + 1;
|
||||||
|
},
|
||||||
|
[enableHeader, groupHeaderInfoByRowIndex, groupHeaderRowIndexes, groups],
|
||||||
|
);
|
||||||
|
|
||||||
const stableConfigProps = useMemo(
|
const stableConfigProps = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -263,9 +443,11 @@ const VirtualizedTableGrid = ({
|
|||||||
const dynamicDataProps = useMemo(
|
const dynamicDataProps = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
activeRowId,
|
activeRowId,
|
||||||
adjustedRowIndexMap,
|
|
||||||
calculatedColumnWidths,
|
calculatedColumnWidths,
|
||||||
data: dataWithGroups,
|
data: dataWithGroups,
|
||||||
|
getAdjustedRowIndex,
|
||||||
|
getGroupRenderData,
|
||||||
|
groupHeaderInfoByRowIndex,
|
||||||
pinnedLeftColumnCount,
|
pinnedLeftColumnCount,
|
||||||
pinnedLeftColumnWidths,
|
pinnedLeftColumnWidths,
|
||||||
pinnedRightColumnCount,
|
pinnedRightColumnCount,
|
||||||
@@ -274,9 +456,11 @@ const VirtualizedTableGrid = ({
|
|||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
activeRowId,
|
activeRowId,
|
||||||
adjustedRowIndexMap,
|
|
||||||
calculatedColumnWidths,
|
calculatedColumnWidths,
|
||||||
dataWithGroups,
|
dataWithGroups,
|
||||||
|
getAdjustedRowIndex,
|
||||||
|
getGroupRenderData,
|
||||||
|
groupHeaderInfoByRowIndex,
|
||||||
pinnedLeftColumnCount,
|
pinnedLeftColumnCount,
|
||||||
pinnedLeftColumnWidths,
|
pinnedLeftColumnWidths,
|
||||||
pinnedRightColumnCount,
|
pinnedRightColumnCount,
|
||||||
@@ -394,7 +578,7 @@ const VirtualizedTableGrid = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.itemTableContainer}>
|
<div className={styles.itemTableContainer} ref={hoverDelegateRef}>
|
||||||
<div
|
<div
|
||||||
className={styles.itemTablePinnedColumnsGridContainer}
|
className={styles.itemTablePinnedColumnsGridContainer}
|
||||||
style={
|
style={
|
||||||
@@ -670,7 +854,10 @@ export interface TableItemProps {
|
|||||||
enableRowHoverHighlight?: ItemTableListProps['enableRowHoverHighlight'];
|
enableRowHoverHighlight?: ItemTableListProps['enableRowHoverHighlight'];
|
||||||
enableSelection?: ItemTableListProps['enableSelection'];
|
enableSelection?: ItemTableListProps['enableSelection'];
|
||||||
enableVerticalBorders?: ItemTableListProps['enableVerticalBorders'];
|
enableVerticalBorders?: ItemTableListProps['enableVerticalBorders'];
|
||||||
|
getAdjustedRowIndex?: (rowIndex: number) => number;
|
||||||
|
getGroupRenderData?: () => unknown[];
|
||||||
getRowHeight: (index: number, cellProps: TableItemProps) => number;
|
getRowHeight: (index: number, cellProps: TableItemProps) => number;
|
||||||
|
groupHeaderInfoByRowIndex?: Map<number, { groupIndex: number; startDataIndex: number }>;
|
||||||
groups?: TableGroupHeader[];
|
groups?: TableGroupHeader[];
|
||||||
internalState: ItemListStateActions;
|
internalState: ItemListStateActions;
|
||||||
itemType: ItemTableListProps['itemType'];
|
itemType: ItemTableListProps['itemType'];
|
||||||
@@ -782,39 +969,24 @@ const BaseItemTableList = ({
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate group header indexes based on itemCounts
|
// Build the expanded row model: [header?] + (groupHeader + groupItems)* + any remaining items.
|
||||||
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;
|
let dataIndex = 0;
|
||||||
const startIndex = enableHeader ? 1 : 0;
|
for (const group of groups) {
|
||||||
let groupHeaderCount = 0;
|
// Group header row
|
||||||
|
result.push(null);
|
||||||
|
|
||||||
// Iterate through the expanded row space (data + group headers)
|
// Group items
|
||||||
for (
|
const end = Math.min(data.length, dataIndex + group.itemCount);
|
||||||
let rowIndex = startIndex;
|
for (; dataIndex < end; dataIndex++) {
|
||||||
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]);
|
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;
|
return result;
|
||||||
}, [data, enableHeader, groups]);
|
}, [data, enableHeader, groups]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user