Implement virtual scrolling in Table component for improved performance
- Added useRef to track container and first row for height measurement. - Introduced rowHeight state to dynamically calculate row height. - Implemented range state to manage visible rows based on scroll position. - Added scroll event listener to compute visible range efficiently. - Adjusted rendering logic to display only a subset of rows based on the computed range. - Included top and bottom spacers to maintain table height during scrolling. - Refactored input change handling to remain functional with new rendering logic.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getTableData, readCachedTableData } from "../utils/userHandler";
|
||||
@@ -20,6 +20,14 @@ const Table: React.FC = () => {
|
||||
const [rows, setRows] = useState<DataPackage[]>([]); // holds normalized cache view
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const firstRowRef = useRef<HTMLTableRowElement | null>(null);
|
||||
const [rowHeight, setRowHeight] = useState<number>(0);
|
||||
const [range, setRange] = useState<{ start: number; end: number }>({
|
||||
start: 0,
|
||||
end: 0,
|
||||
});
|
||||
const OVERSCAN = 100; // Render 100 rows above and below the viewport
|
||||
|
||||
// Einheitliche Input-Styles (nur Tailwind)
|
||||
const inputClasses =
|
||||
@@ -69,19 +77,6 @@ const Table: React.FC = () => {
|
||||
return () => window.removeEventListener("storage", handler);
|
||||
}, []);
|
||||
|
||||
// Handles input changes for table rows
|
||||
const handleInputChange = (
|
||||
losnummer: string,
|
||||
field: keyof DataPackage,
|
||||
value: string
|
||||
) => {
|
||||
setRows((prevRows) =>
|
||||
prevRows.map((row) =>
|
||||
row.losnummer === losnummer ? { ...row, [field]: value } : row
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Filter rows by search query (case-insensitive)
|
||||
const filteredRows = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
@@ -101,6 +96,80 @@ const Table: React.FC = () => {
|
||||
});
|
||||
}, [rows, search]);
|
||||
|
||||
// Measure row height once the first visible row mounts
|
||||
useEffect(() => {
|
||||
if (!firstRowRef.current) return;
|
||||
const h = firstRowRef.current.offsetHeight;
|
||||
if (h && h !== rowHeight) setRowHeight(h);
|
||||
}, [firstRowRef.current, rowHeight, range.start]);
|
||||
|
||||
// Compute the current visible range based on scroll position and container size
|
||||
const recomputeRange = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const total = filteredRows.length;
|
||||
if (total === 0) {
|
||||
if (range.start !== 0 || range.end !== 0) setRange({ start: 0, end: 0 });
|
||||
return;
|
||||
}
|
||||
const rh = rowHeight || 52; // Fallback estimate until measured
|
||||
const scrollTop = container.scrollTop;
|
||||
const containerHeight = container.clientHeight || 0;
|
||||
const firstVisible = Math.floor(scrollTop / rh);
|
||||
const visibleCount = Math.max(1, Math.ceil(containerHeight / rh));
|
||||
const start = Math.max(0, firstVisible - OVERSCAN);
|
||||
const end = Math.min(total, firstVisible + visibleCount + OVERSCAN);
|
||||
if (start !== range.start || end !== range.end) setRange({ start, end });
|
||||
};
|
||||
|
||||
// Attach scroll listener
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
// use requestAnimationFrame to keep it smooth
|
||||
requestAnimationFrame(recomputeRange);
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
// Initial compute
|
||||
recomputeRange();
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rowHeight, filteredRows.length]);
|
||||
|
||||
// Recompute range whenever data set changes significantly (search, data load)
|
||||
useEffect(() => {
|
||||
// Reset scroll position when filter changes to show top of list
|
||||
const el = containerRef.current;
|
||||
if (el) el.scrollTop = 0;
|
||||
// Next frame compute range for new data
|
||||
requestAnimationFrame(recomputeRange);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search, rows.length]);
|
||||
|
||||
// Handles input changes for table rows
|
||||
const handleInputChange = (
|
||||
losnummer: string,
|
||||
field: keyof DataPackage,
|
||||
value: string
|
||||
) => {
|
||||
setRows((prevRows) =>
|
||||
prevRows.map((row) =>
|
||||
row.losnummer === losnummer ? { ...row, [field]: value } : row
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// (filteredRows defined above)
|
||||
|
||||
const start = range.start;
|
||||
const end =
|
||||
range.end > 0 ? range.end : Math.min(filteredRows.length, 2 * OVERSCAN);
|
||||
const visibleSlice = filteredRows.slice(start, end);
|
||||
const rh = rowHeight || 52;
|
||||
const topPadding = start * rh;
|
||||
const bottomPadding = Math.max(0, (filteredRows.length - end) * rh);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubHeaderAdmin
|
||||
@@ -128,7 +197,10 @@ const Table: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
*/}
|
||||
<div className="overflow-auto rounded-lg shadow ring-1 ring-black/5">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="overflow-auto rounded-lg shadow ring-1 ring-black/5"
|
||||
>
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
@@ -191,10 +263,16 @@ const Table: React.FC = () => {
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{filteredRows.map((row, idx) => (
|
||||
{filteredRows.length > 0 && (
|
||||
<tr key="top-spacer" style={{ height: topPadding }}>
|
||||
<td colSpan={8} />
|
||||
</tr>
|
||||
)}
|
||||
{visibleSlice.map((row, i) => (
|
||||
<tr
|
||||
key={row.losnummer ?? idx}
|
||||
key={row.losnummer ?? start + i}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
ref={i === 0 ? firstRowRef : undefined}
|
||||
>
|
||||
<td className="px-4 py-2 font-mono text-xs text-gray-900 w-10 min-w-[2.5rem]">
|
||||
<input
|
||||
@@ -289,6 +367,11 @@ const Table: React.FC = () => {
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredRows.length > 0 && (
|
||||
<tr key="bottom-spacer" style={{ height: bottomPadding }}>
|
||||
<td colSpan={8} />
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user