Skip to content

Instantly share code, notes, and snippets.

@okvv
Created January 21, 2020 00:28
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save okvv/1934a120d05d91d692ab2f4f07daa52e to your computer and use it in GitHub Desktop.
Save okvv/1934a120d05d91d692ab2f4f07daa52e to your computer and use it in GitHub Desktop.
VirtualScrollTable
import React, {
memo,
useMemo,
useRef,
useState,
useEffect,
useCallback
} from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import format from "date-fns/fp/format";
import formatDistanceToNow from "date-fns/formatDistanceToNow";
// Virtual Scroll Table component
// TODO: for a11y, user may be in tabbing sequence and arrow navigation sequence interchangebly, this will cause after the tab to reset on the first row in visibleRows (as it's not attached to any data)
const VirtualScrollTable = ({
tableData,
rowCount,
height = 0,
headerHeight = 80,
getChildHeight,
caption,
renderAhead = 20
}) => {
const childPositions = useMemo(() => {
let results = [0];
for (let i = 1; i < rowCount; i++) {
results.push(results[i - 1] + getChildHeight(i - 1));
}
return results;
}, [getChildHeight, rowCount]);
const [scrollTop, ref] = useScrollAware();
const totalHeight = rowCount
? childPositions[rowCount - 1] + getChildHeight(rowCount - 1)
: 0;
const firstVisibleNode = useMemo(
() => findStartNode(scrollTop, childPositions, rowCount),
[scrollTop, childPositions, rowCount]
);
const startNode = Math.max(0, firstVisibleNode - renderAhead);
const lastVisibleNode = useMemo(
() => findEndNode(childPositions, firstVisibleNode, rowCount, height),
[childPositions, firstVisibleNode, rowCount, height]
);
const endNode = Math.min(rowCount - 1, lastVisibleNode + renderAhead);
const visibleNodeCount = endNode - startNode + 1;
const offsetY = childPositions[startNode];
// console.log(height, scrollTop, startNode, endNode);
const visibleChildren = useMemo(
() =>
new Array(visibleNodeCount)
.fill(null)
.map((_, index) => (
<TableRow
key={index + startNode}
index={index + startNode}
url={tableData[index + startNode]["url"]}
description={tableData[index + startNode]["description"]}
delta={tableData[index + startNode]["delta"]}
balance={tableData[index + startNode]["balance"]}
timestamp={tableData[index + startNode]["timestamp"]}
/>
)),
[startNode, visibleNodeCount, tableData]
);
return (
<div style={{ height, overflow: "auto" }} ref={ref}>
<div
className="viewport"
style={{
overflow: "hidden",
willChange: "transform",
height: totalHeight + headerHeight,
position: "relative"
}}
>
<Table role="grid">
<caption>{caption}</caption>
<tbody
style={{
willChange: "transform",
transform: `translateY(${offsetY}px)`
}}
>
<tr>
<th scope="col">Description</th>
<th scope="col">Amount</th>
<th scope="col">Balance</th>
<th scope="col">Time of billing</th>
</tr>
{visibleChildren}
</tbody>
</Table>
</div>
</div>
);
};
// Generic hook for detecting scroll:
const useScrollAware = () => {
const [scrollTop, setScrollTop] = useState(0);
const ref = useRef();
const animationFrame = useRef();
const onScroll = useCallback(e => {
if (animationFrame.current) {
cancelAnimationFrame(animationFrame.current);
}
animationFrame.current = requestAnimationFrame(() => {
setScrollTop(e.target.scrollTop);
});
}, []);
useEffect(() => {
const scrollContainer = ref.current;
setScrollTop(scrollContainer.scrollTop);
scrollContainer.addEventListener("scroll", onScroll);
return () => scrollContainer.removeEventListener("scroll", onScroll);
// eslint-disable-next-line
}, []);
return [scrollTop, ref];
};
function findStartNode(scrollTop, nodePositions, rowCount) {
let startRange = 0;
let endRange = rowCount ? rowCount - 1 : rowCount;
while (endRange !== startRange) {
// console.log(startRange, endRange);
const middle = Math.floor((endRange - startRange) / 2 + startRange);
if (
nodePositions[middle] <= scrollTop &&
nodePositions[middle + 1] > scrollTop
) {
// console.log("middle", middle);
return middle;
}
if (middle === startRange) {
// edge case - start and end range are consecutive
// console.log("endRange", endRange);
return endRange;
} else {
if (nodePositions[middle] <= scrollTop) {
startRange = middle;
} else {
endRange = middle;
}
}
}
return rowCount;
}
function findEndNode(nodePositions, startNode, rowCount, height) {
let endNode;
for (endNode = startNode; endNode < rowCount; endNode++) {
// console.log(nodePositions[endNode], nodePositions[startNode]);
if (nodePositions[endNode] > nodePositions[startNode] + height) {
// console.log(endNode);
return endNode;
}
}
return endNode;
}
const TableRow = memo(
({ index, url, description, delta, balance, timestamp = 0 }) => (
<tr key={index}>
<td tabIndex="0">
{url ? (
<BillLink to={url} tabIndex="0">
{description}
</BillLink>
) : (
description
)}
</td>
<td tabIndex="0">
{-delta}
</td>
<td tabIndex="0">
{balance}
</td>
<td tabIndex="0">
{format("PPPP, pp", timestamp)}
<TimeAgo>
{" "}
{formatDistanceToNow(timestamp, { addSuffix: true })}{" "}
</TimeAgo>
</td>
</tr>
)
);
const Table = styled.table`
width: 100%;
border-collapse: collapse;
th {
position: sticky;
top: 0;
background-color: ${props => props.theme.palette.secondary};
padding-top: 10px;
}
tfoot {
td {
position: sticky;
bottom: 0;
background-color: ${props => props.theme.palette.secondary};
}
}
tbody {
tr {
height: 30px;
&:nth-child(even) {
background-color: rgba(255, 255, 255, 0.1);
}
td {
border-spacing: 0;
}
}
}
`;
const BillLink = styled(Link)`
text-decoration: none;
color: ${props => props.theme.palette.primary};
`;
const TimeAgo = styled.span`
text-indent: 10px;
display: inline-block;
opacity: 0.4;
`;
export default memo(VirtualScrollTable);
@okvv
Copy link
Author

okvv commented Jan 21, 2020

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment