Created
January 21, 2020 00:28
-
-
Save okvv/1934a120d05d91d692ab2f4f07daa52e to your computer and use it in GitHub Desktop.
VirtualScrollTable
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
used @adamkleingit article:
https://dev.to/adamklein/build-your-own-virtual-scroll-part-ii-3j86