useVirtualizedTree.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863
  1. import {
  2. MutableRefObject,
  3. useCallback,
  4. useEffect,
  5. useMemo,
  6. useReducer,
  7. useRef,
  8. useState,
  9. } from 'react';
  10. import theme from 'sentry/utils/theme';
  11. import {useEffectAfterFirstRender} from 'sentry/utils/useEffectAfterFirstRender';
  12. import {VirtualizedTree} from './VirtualizedTree';
  13. import {VirtualizedTreeNode} from './VirtualizedTreeNode';
  14. type AnimationTimeoutId = {
  15. id: number;
  16. };
  17. const cancelAnimationTimeout = (frame: AnimationTimeoutId) =>
  18. window.cancelAnimationFrame(frame.id);
  19. /**
  20. * Recursively calls requestAnimationFrame until a specified delay has been met or exceeded.
  21. * When the delay time has been reached the function you're timing out will be called.
  22. * This was copied from react-virtualized, with credits to the original author.
  23. *
  24. * Credit: Joe Lambert (https://gist.github.com/joelambert/1002116#file-requesttimeout-js)
  25. */
  26. const requestAnimationTimeout = (
  27. callback: Function,
  28. delay: number
  29. ): AnimationTimeoutId => {
  30. let start;
  31. // wait for end of processing current event handler, because event handler may be long
  32. Promise.resolve().then(() => {
  33. start = Date.now();
  34. });
  35. const timeout = () => {
  36. if (start === undefined) {
  37. frame.id = window.requestAnimationFrame(timeout);
  38. return;
  39. }
  40. if (Date.now() - start >= delay) {
  41. callback();
  42. } else {
  43. frame.id = window.requestAnimationFrame(timeout);
  44. }
  45. };
  46. const frame: AnimationTimeoutId = {
  47. id: window.requestAnimationFrame(timeout),
  48. };
  49. return frame;
  50. };
  51. export interface TreeLike {
  52. children: TreeLike[];
  53. }
  54. interface VirtualizedState<T> {
  55. overscroll: number;
  56. roots: T[];
  57. scrollHeight: number;
  58. scrollTop: number;
  59. tabIndexKey: number | null;
  60. }
  61. interface SetScrollTop {
  62. payload: number;
  63. type: 'set scroll top';
  64. }
  65. interface SetTabIndexKey {
  66. payload: number | null;
  67. type: 'set tab index key';
  68. }
  69. interface SetContainerHeight {
  70. payload: number;
  71. type: 'set scroll height';
  72. }
  73. type VirtualizedStateAction = SetScrollTop | SetContainerHeight | SetTabIndexKey;
  74. export function VirtualizedTreeStateReducer<T>(
  75. state: VirtualizedState<T>,
  76. action: VirtualizedStateAction
  77. ): VirtualizedState<T> {
  78. switch (action.type) {
  79. case 'set tab index key': {
  80. return {...state, tabIndexKey: action.payload};
  81. }
  82. case 'set scroll top': {
  83. return {...state, scrollTop: action.payload};
  84. }
  85. case 'set scroll height': {
  86. return {...state, scrollHeight: action.payload};
  87. }
  88. default: {
  89. return state;
  90. }
  91. }
  92. }
  93. function hideGhostRow({ref}: {ref: MutableRefObject<HTMLElement | null>}) {
  94. if (ref.current) {
  95. ref.current.style.opacity = '0';
  96. }
  97. }
  98. function updateGhostRow({
  99. ref,
  100. tabIndexKey,
  101. rowHeight,
  102. scrollTop,
  103. interaction,
  104. }: {
  105. interaction: 'hover' | 'active';
  106. ref: MutableRefObject<HTMLElement | null>;
  107. rowHeight: number;
  108. scrollTop: number;
  109. tabIndexKey: number;
  110. }) {
  111. if (!ref.current) {
  112. return;
  113. }
  114. ref.current.style.left = '0';
  115. ref.current.style.right = '0';
  116. ref.current.style.height = `${rowHeight}px`;
  117. ref.current.style.position = 'absolute';
  118. ref.current.style.backgroundColor =
  119. interaction === 'active' ? theme.blue300 : theme.blue100;
  120. ref.current.style.pointerEvents = 'none';
  121. ref.current.style.willChange = 'transform, opacity';
  122. ref.current.style.transform = `translateY(${rowHeight * tabIndexKey - scrollTop}px)`;
  123. ref.current.style.opacity = '1';
  124. }
  125. function findOptimisticStartIndex<T extends TreeLike>({
  126. items,
  127. overscroll,
  128. rowHeight,
  129. scrollTop,
  130. viewport,
  131. }: {
  132. items: VirtualizedTreeNode<T>[];
  133. overscroll: number;
  134. rowHeight: number;
  135. scrollTop: number;
  136. viewport: {bottom: number; top: number};
  137. }): number {
  138. if (!items.length || viewport.top === 0) {
  139. return 0;
  140. }
  141. return Math.max(Math.floor(scrollTop / rowHeight) - overscroll, 0);
  142. }
  143. function findVisibleItems<T extends TreeLike>({
  144. items,
  145. overscroll,
  146. rowHeight,
  147. scrollHeight,
  148. scrollTop,
  149. }: {
  150. items: VirtualizedTreeNode<T>[];
  151. overscroll: NonNullable<UseVirtualizedListProps<T>['overscroll']>;
  152. rowHeight: UseVirtualizedListProps<T>['rowHeight'];
  153. scrollHeight: VirtualizedState<T>['scrollHeight'];
  154. scrollTop: VirtualizedState<T>['scrollTop'];
  155. }) {
  156. // This is overscroll height for single direction, when computing the total,
  157. // we need to multiply this by 2 because we overscroll in both directions.
  158. const OVERSCROLL_HEIGHT = overscroll * rowHeight;
  159. const visibleItems: VisibleItem<T>[] = [];
  160. // Clamp viewport to scrollHeight bounds [0, length * rowHeight] because some browsers may fire
  161. // scrollTop with negative values when the user scrolls up past the top of the list (overscroll behavior)
  162. const viewport = {
  163. top: Math.max(scrollTop - OVERSCROLL_HEIGHT, 0),
  164. bottom: Math.min(
  165. scrollTop + scrollHeight + OVERSCROLL_HEIGHT,
  166. items.length * rowHeight
  167. ),
  168. };
  169. // Points to the position inside the visible array
  170. let visibleItemIndex = 0;
  171. // Points to the currently iterated item
  172. let indexPointer = findOptimisticStartIndex({
  173. items,
  174. viewport,
  175. scrollTop,
  176. rowHeight,
  177. overscroll,
  178. });
  179. // Max number of visible items in our list
  180. const MAX_VISIBLE_ITEMS = Math.ceil((scrollHeight + OVERSCROLL_HEIGHT * 2) / rowHeight);
  181. const ALL_ITEMS = items.length;
  182. // While number of visible items is less than max visible items, and we haven't reached the end of the list
  183. while (visibleItemIndex < MAX_VISIBLE_ITEMS && indexPointer < ALL_ITEMS) {
  184. const elementTop = indexPointer * rowHeight;
  185. const elementBottom = elementTop + rowHeight;
  186. // An element is inside a viewport if the top of the element is below the top of the viewport
  187. // and the bottom of the element is above the bottom of the viewport
  188. if (elementTop >= viewport.top && elementBottom <= viewport.bottom) {
  189. visibleItems[visibleItemIndex] = {
  190. key: indexPointer,
  191. ref: null,
  192. styles: {position: 'absolute', top: elementTop},
  193. item: items[indexPointer],
  194. };
  195. visibleItemIndex++;
  196. }
  197. indexPointer++;
  198. }
  199. return visibleItems;
  200. }
  201. interface VisibleItem<T> {
  202. item: VirtualizedTreeNode<T>;
  203. key: number;
  204. ref: HTMLElement | null;
  205. styles: React.CSSProperties;
  206. }
  207. export interface UseVirtualizedListProps<T extends TreeLike> {
  208. renderRow: (
  209. item: VisibleItem<T>,
  210. itemHandlers: {
  211. handleExpandTreeNode: (
  212. node: VirtualizedTreeNode<T>,
  213. opts?: {expandChildren: boolean}
  214. ) => void;
  215. handleRowClick: (evt: React.MouseEvent<HTMLElement>) => void;
  216. handleRowKeyDown: (event: React.KeyboardEvent) => void;
  217. handleRowMouseEnter: (event: React.MouseEvent<HTMLElement>) => void;
  218. tabIndexKey: number | null;
  219. }
  220. ) => React.ReactNode;
  221. roots: T[];
  222. rowHeight: number;
  223. scrollContainer: HTMLElement | null;
  224. overscroll?: number;
  225. skipFunction?: (node: VirtualizedTreeNode<T>) => boolean;
  226. sortFunction?: (a: VirtualizedTreeNode<T>, b: VirtualizedTreeNode<T>) => number;
  227. }
  228. const DEFAULT_OVERSCROLL_ITEMS = 5;
  229. function findCarryOverIndex<T extends TreeLike>(
  230. previousNode: VirtualizedTreeNode<T> | null | undefined,
  231. newTree: VirtualizedTree<T>
  232. ): number | null {
  233. if (!newTree.flattened.length || !previousNode) {
  234. return null;
  235. }
  236. const newIndex = newTree.flattened.findIndex(n => n.node === previousNode.node);
  237. if (newIndex === -1) {
  238. return null;
  239. }
  240. return newIndex;
  241. }
  242. export function useVirtualizedTree<T extends TreeLike>(
  243. props: UseVirtualizedListProps<T>
  244. ) {
  245. const clickedGhostRowRef = useRef<HTMLDivElement | null>(null);
  246. const hoveredGhostRowRef = useRef<HTMLDivElement | null>(null);
  247. const previousHoveredRow = useRef<number | null>(null);
  248. const [state, dispatch] = useReducer(VirtualizedTreeStateReducer, {
  249. scrollTop: 0,
  250. roots: props.roots,
  251. tabIndexKey: null,
  252. overscroll: props.overscroll ?? DEFAULT_OVERSCROLL_ITEMS,
  253. scrollHeight: props.scrollContainer?.getBoundingClientRect()?.height ?? 0,
  254. });
  255. // Keep a ref to latest state to avoid re-rendering
  256. const latestStateRef = useRef<typeof state>(state);
  257. latestStateRef.current = state;
  258. const [tree, setTree] = useState(() => {
  259. const initialTree = VirtualizedTree.fromRoots(props.roots, props.skipFunction);
  260. if (props.sortFunction) {
  261. initialTree.sort(props.sortFunction);
  262. }
  263. return initialTree;
  264. });
  265. const cleanupAllHoveredRows = useCallback(() => {
  266. previousHoveredRow.current = null;
  267. for (const row of latestItemsRef.current) {
  268. if (row.ref && row.ref.dataset.hovered) {
  269. delete row.ref.dataset.hovered;
  270. }
  271. }
  272. }, []);
  273. const expandedHistory = useRef<Set<T>>(new Set());
  274. useEffectAfterFirstRender(() => {
  275. const expandedNodes = tree.getAllExpandedNodes(expandedHistory.current);
  276. const newTree = VirtualizedTree.fromRoots(
  277. props.roots,
  278. props.skipFunction,
  279. expandedNodes
  280. );
  281. expandedHistory.current = expandedNodes;
  282. if (props.sortFunction) {
  283. newTree.sort(props.sortFunction);
  284. }
  285. const tabIndex = findCarryOverIndex(
  286. latestStateRef.current.tabIndexKey
  287. ? tree.flattened[latestStateRef.current.tabIndexKey]
  288. : null,
  289. newTree
  290. );
  291. if (tabIndex) {
  292. updateGhostRow({
  293. ref: clickedGhostRowRef,
  294. tabIndexKey: tabIndex,
  295. rowHeight: props.rowHeight,
  296. scrollTop: latestStateRef.current.scrollTop,
  297. interaction: 'active',
  298. });
  299. } else {
  300. hideGhostRow({ref: clickedGhostRowRef});
  301. }
  302. cleanupAllHoveredRows();
  303. hideGhostRow({ref: hoveredGhostRowRef});
  304. dispatch({type: 'set tab index key', payload: tabIndex});
  305. setTree(newTree);
  306. }, [props.roots, props.skipFunction, cleanupAllHoveredRows]);
  307. const items = useMemo(() => {
  308. return findVisibleItems<T>({
  309. items: tree.flattened,
  310. scrollHeight: state.scrollHeight,
  311. scrollTop: state.scrollTop,
  312. overscroll: state.overscroll,
  313. rowHeight: props.rowHeight,
  314. });
  315. }, [tree, state.overscroll, state.scrollHeight, state.scrollTop, props.rowHeight]);
  316. const latestItemsRef = useRef(items);
  317. latestItemsRef.current = items;
  318. // On scroll, we update scrollTop position.
  319. // Keep a rafId reference in the unlikely event where component unmounts before raf is executed.
  320. const scrollEndTimeoutId = useRef<AnimationTimeoutId | undefined>(undefined);
  321. useEffect(() => {
  322. const scrollContainer = props.scrollContainer;
  323. if (!scrollContainer) {
  324. return undefined;
  325. }
  326. const handleScroll = evt => {
  327. evt.target.firstChild.style.pointerEvents = 'none';
  328. if (scrollEndTimeoutId.current !== undefined) {
  329. cancelAnimationTimeout(scrollEndTimeoutId.current);
  330. }
  331. scrollEndTimeoutId.current = requestAnimationTimeout(() => {
  332. evt.target.firstChild.style.pointerEvents = 'auto';
  333. }, 150);
  334. dispatch({
  335. type: 'set scroll top',
  336. payload: Math.max(evt.target.scrollTop, 0),
  337. });
  338. // On scroll, we need to update the selected ghost row and clear the hovered ghost row
  339. if (latestStateRef.current.tabIndexKey !== null) {
  340. updateGhostRow({
  341. ref: clickedGhostRowRef,
  342. tabIndexKey: latestStateRef.current.tabIndexKey,
  343. scrollTop: Math.max(evt.target.scrollTop, 0),
  344. interaction: 'active',
  345. rowHeight: props.rowHeight,
  346. });
  347. }
  348. cleanupAllHoveredRows();
  349. hideGhostRow({
  350. ref: hoveredGhostRowRef,
  351. });
  352. };
  353. scrollContainer.addEventListener('scroll', handleScroll, {
  354. passive: true,
  355. });
  356. return () => {
  357. scrollContainer.removeEventListener('scroll', handleScroll);
  358. };
  359. }, [props.scrollContainer, props.rowHeight, cleanupAllHoveredRows]);
  360. useEffect(() => {
  361. const scrollContainer = props.scrollContainer;
  362. if (!scrollContainer) {
  363. return undefined;
  364. }
  365. // Because nodes dont span the full width, it's possible for users to
  366. // click or hover on a node at the far right end which is outside of the row width.
  367. // In that case, we check if the cursor position overlaps with a row and select that row.
  368. const handleClick = (evt: MouseEvent) => {
  369. if (evt.target !== scrollContainer) {
  370. // user clicked on an element inside the container, defer to onClick
  371. return;
  372. }
  373. const rect = (evt.target as HTMLDivElement).getBoundingClientRect();
  374. const index = Math.floor(
  375. (latestStateRef.current.scrollTop + evt.clientY - rect.top) / props.rowHeight
  376. );
  377. // If a node exists at the index, select it
  378. if (tree.flattened[index]) {
  379. dispatch({type: 'set tab index key', payload: index});
  380. updateGhostRow({
  381. ref: clickedGhostRowRef,
  382. tabIndexKey: index,
  383. scrollTop: latestStateRef.current.scrollTop,
  384. rowHeight: props.rowHeight,
  385. interaction: 'active',
  386. });
  387. }
  388. };
  389. // Because nodes dont span the full width, it's possible for users to
  390. // click on a node at the far right end which is outside of the row width.
  391. // In that case, check if the top position where the user clicked overlaps
  392. // with a row and select that row.
  393. const handleMouseMove = (evt: MouseEvent) => {
  394. if (evt.target !== scrollContainer) {
  395. // user clicked on an element inside the container, defer to onClick
  396. return;
  397. }
  398. const rect = (evt.target as HTMLDivElement).getBoundingClientRect();
  399. const index = Math.floor(
  400. (latestStateRef.current.scrollTop + evt.clientY - rect.top) / props.rowHeight
  401. );
  402. cleanupAllHoveredRows();
  403. const element = latestItemsRef.current.find(item => item.key === index);
  404. if (element?.ref) {
  405. element.ref.dataset.hovered = 'true';
  406. }
  407. // If a node exists at the index, select it, else clear whatever is selected
  408. if (tree.flattened[index] && index !== latestStateRef.current.tabIndexKey) {
  409. updateGhostRow({
  410. ref: hoveredGhostRowRef,
  411. tabIndexKey: index,
  412. scrollTop: latestStateRef.current.scrollTop,
  413. rowHeight: props.rowHeight,
  414. interaction: 'hover',
  415. });
  416. } else {
  417. hideGhostRow({
  418. ref: hoveredGhostRowRef,
  419. });
  420. }
  421. };
  422. scrollContainer.addEventListener('click', handleClick);
  423. scrollContainer.addEventListener('mousemove', handleMouseMove);
  424. return () => {
  425. scrollContainer.removeEventListener('click', handleClick);
  426. scrollContainer.removeEventListener('mousemove', handleMouseMove);
  427. };
  428. }, [props.rowHeight, props.scrollContainer, tree.flattened, cleanupAllHoveredRows]);
  429. // When mouseleave is triggered on the contianer,
  430. // we need to hide the ghost row to avoid an orphaned row
  431. useEffect(() => {
  432. const container = props.scrollContainer;
  433. if (!container) {
  434. return undefined;
  435. }
  436. function onMouseLeave() {
  437. cleanupAllHoveredRows();
  438. hideGhostRow({
  439. ref: hoveredGhostRowRef,
  440. });
  441. }
  442. container.addEventListener('mouseleave', onMouseLeave, {
  443. passive: true,
  444. });
  445. return () => {
  446. container.removeEventListener('mouseleave', onMouseLeave);
  447. };
  448. }, [cleanupAllHoveredRows, props.scrollContainer]);
  449. // When a node is expanded, the underlying tree is recomputed (the flattened tree is updated)
  450. // We copy the properties of the old tree by creating a new instance of VirtualizedTree
  451. // and passing in the roots and its flattened representation so that no extra work is done.
  452. const handleExpandTreeNode = useCallback(
  453. (node: VirtualizedTreeNode<T>, opts?: {expandChildren: boolean}) => {
  454. // When we expand nodes, tree.expand will mutate the underlying tree which then
  455. // gets copied to the new tree instance. To get the right index, we need to read
  456. // it before any mutations are made
  457. const previousNode = latestStateRef.current.tabIndexKey
  458. ? tree.flattened[latestStateRef.current.tabIndexKey] ?? null
  459. : null;
  460. tree.expandNode(node, !node.expanded, opts);
  461. const newTree = new VirtualizedTree(tree.roots, tree.flattened);
  462. expandedHistory.current = newTree.getAllExpandedNodes(new Set());
  463. // Hide or update the ghost if necessary
  464. const tabIndex = findCarryOverIndex(previousNode, newTree);
  465. if (tabIndex === null) {
  466. hideGhostRow({ref: clickedGhostRowRef});
  467. } else {
  468. updateGhostRow({
  469. ref: clickedGhostRowRef,
  470. tabIndexKey: tabIndex,
  471. scrollTop: Math.max(latestStateRef.current.scrollTop, 0),
  472. interaction: 'active',
  473. rowHeight: props.rowHeight,
  474. });
  475. }
  476. dispatch({type: 'set tab index key', payload: tabIndex});
  477. setTree(newTree);
  478. },
  479. [tree, props.rowHeight]
  480. );
  481. // When a tree is sorted, we sort all of the nodes in the tree and not just the visible ones
  482. // We could probably optimize this to lazily sort as we scroll, but since we want the least amount
  483. // of work during scrolling, we just sort the entire tree every time.
  484. const handleSortingChange = useCallback(
  485. (sortFn: (a: VirtualizedTreeNode<T>, b: VirtualizedTreeNode<T>) => number) => {
  486. // When we sort nodes, tree.sort will mutate the underlying tree which then
  487. // gets copied to the new tree instance. To get the right index, we need to read
  488. // it before any mutations are made
  489. const previousNode = latestStateRef.current.tabIndexKey
  490. ? tree.flattened[latestStateRef.current.tabIndexKey] ?? null
  491. : null;
  492. tree.sort(sortFn);
  493. const newTree = new VirtualizedTree(tree.roots, tree.flattened);
  494. // Hide or update the ghost if necessary
  495. const tabIndex = findCarryOverIndex(previousNode, newTree);
  496. if (tabIndex === null) {
  497. hideGhostRow({ref: clickedGhostRowRef});
  498. } else {
  499. updateGhostRow({
  500. ref: clickedGhostRowRef,
  501. tabIndexKey: tabIndex,
  502. scrollTop: Math.max(latestStateRef.current.scrollTop, 0),
  503. interaction: 'active',
  504. rowHeight: props.rowHeight,
  505. });
  506. }
  507. dispatch({type: 'set tab index key', payload: tabIndex});
  508. setTree(newTree);
  509. },
  510. [tree, props.rowHeight]
  511. );
  512. // When a row is clicked, we update the selected node
  513. const handleRowClick = useCallback(
  514. (tabIndexKey: number) => {
  515. return (_evt: React.MouseEvent<HTMLElement>) => {
  516. dispatch({type: 'set tab index key', payload: tabIndexKey});
  517. updateGhostRow({
  518. ref: clickedGhostRowRef,
  519. tabIndexKey,
  520. scrollTop: state.scrollTop,
  521. rowHeight: props.rowHeight,
  522. interaction: 'active',
  523. });
  524. };
  525. },
  526. [state.scrollTop, props.rowHeight]
  527. );
  528. // Keyboard navigation for row
  529. const handleRowKeyDown = useCallback(
  530. (event: React.KeyboardEvent) => {
  531. if (latestStateRef.current.tabIndexKey === null) {
  532. return;
  533. }
  534. // Cant move anywhere if there are no nodes
  535. if (!tree.flattened.length) {
  536. return;
  537. }
  538. if (event.key === 'Enter') {
  539. handleExpandTreeNode(tree.flattened[latestStateRef.current.tabIndexKey], {
  540. expandChildren: true,
  541. });
  542. }
  543. if (event.key === 'ArrowDown') {
  544. event.preventDefault();
  545. if (event.metaKey || event.ctrlKey) {
  546. const index = tree.flattened.length - 1;
  547. props.scrollContainer!.scrollTo({
  548. // We need to offset for the scrollMargin
  549. top: index * props.rowHeight + props.rowHeight,
  550. });
  551. dispatch({type: 'set tab index key', payload: index});
  552. updateGhostRow({
  553. ref: clickedGhostRowRef,
  554. tabIndexKey: index,
  555. scrollTop: latestStateRef.current.scrollTop,
  556. rowHeight: props.rowHeight,
  557. interaction: 'active',
  558. });
  559. return;
  560. }
  561. // This is fine because we are only searching visible items
  562. // and not the entire tree of nodes
  563. const indexInVisibleItems = items.findIndex(
  564. i => i.key === latestStateRef.current.tabIndexKey
  565. );
  566. if (indexInVisibleItems !== -1) {
  567. const nextIndex = indexInVisibleItems + 1;
  568. // Bounds check if we are at end of list
  569. if (nextIndex > tree.flattened.length - 1) {
  570. return;
  571. }
  572. dispatch({type: 'set tab index key', payload: items[nextIndex].key});
  573. updateGhostRow({
  574. ref: clickedGhostRowRef,
  575. tabIndexKey: items[nextIndex].key,
  576. scrollTop: latestStateRef.current.scrollTop,
  577. rowHeight: props.rowHeight,
  578. interaction: 'active',
  579. });
  580. items[nextIndex].ref?.focus({preventScroll: true});
  581. items[nextIndex].ref?.scrollIntoView({block: 'nearest'});
  582. }
  583. }
  584. if (event.key === 'ArrowUp') {
  585. event.preventDefault();
  586. if (event.metaKey || event.ctrlKey) {
  587. props.scrollContainer!.scrollTo({top: 0});
  588. dispatch({type: 'set tab index key', payload: 0});
  589. updateGhostRow({
  590. ref: clickedGhostRowRef,
  591. tabIndexKey: 0,
  592. scrollTop: latestStateRef.current.scrollTop,
  593. rowHeight: props.rowHeight,
  594. interaction: 'active',
  595. });
  596. return;
  597. }
  598. // This is fine because we are only searching visible items
  599. // and not the entire tree of nodes
  600. const indexInVisibleItems = items.findIndex(
  601. i => i.key === latestStateRef.current.tabIndexKey
  602. );
  603. if (indexInVisibleItems !== -1) {
  604. const nextIndex = indexInVisibleItems - 1;
  605. // Bound check if we are at start of list
  606. if (nextIndex < 0) {
  607. return;
  608. }
  609. dispatch({type: 'set tab index key', payload: items[nextIndex].key});
  610. updateGhostRow({
  611. ref: clickedGhostRowRef,
  612. tabIndexKey: items[nextIndex].key,
  613. scrollTop: latestStateRef.current.scrollTop,
  614. rowHeight: props.rowHeight,
  615. interaction: 'active',
  616. });
  617. items[nextIndex].ref?.focus({preventScroll: true});
  618. items[nextIndex].ref?.scrollIntoView({block: 'nearest'});
  619. }
  620. }
  621. },
  622. [handleExpandTreeNode, items, tree.flattened, props.rowHeight, props.scrollContainer]
  623. );
  624. // When a row is hovered, we update the ghost row
  625. const handleRowMouseEnter = useCallback(
  626. (key: number) => {
  627. return (_evt: React.MouseEvent<HTMLElement>) => {
  628. if (previousHoveredRow.current !== key) {
  629. cleanupAllHoveredRows();
  630. (_evt.currentTarget as HTMLElement).dataset.hovered = 'true';
  631. previousHoveredRow.current = key;
  632. }
  633. updateGhostRow({
  634. ref: hoveredGhostRowRef,
  635. tabIndexKey: key,
  636. scrollTop: state.scrollTop,
  637. rowHeight: props.rowHeight,
  638. interaction: 'hover',
  639. });
  640. };
  641. },
  642. [state.scrollTop, props.rowHeight, cleanupAllHoveredRows]
  643. );
  644. // Register a resize observer for when the scroll container is resized.
  645. // When the container is resized, update the scroll height in our state.
  646. // Similarly to handleScroll, we use requestAnimationFrame to avoid overupdating the UI
  647. useEffect(() => {
  648. if (!props.scrollContainer) {
  649. return undefined;
  650. }
  651. let rafId: number | undefined;
  652. const resizeObserver = new window.ResizeObserver(elements => {
  653. rafId = window.requestAnimationFrame(() => {
  654. dispatch({
  655. type: 'set scroll height',
  656. payload: elements[0]?.contentRect?.height ?? 0,
  657. });
  658. cleanupAllHoveredRows();
  659. hideGhostRow({
  660. ref: hoveredGhostRowRef,
  661. });
  662. });
  663. });
  664. resizeObserver.observe(props.scrollContainer);
  665. return () => {
  666. if (typeof rafId === 'number') {
  667. window.cancelAnimationFrame(rafId);
  668. }
  669. resizeObserver.disconnect();
  670. };
  671. }, [props.scrollContainer, cleanupAllHoveredRows]);
  672. // Basic required styles for the scroll container
  673. const scrollContainerStyles: React.CSSProperties = useMemo(() => {
  674. return {
  675. height: '100%',
  676. overflow: 'auto',
  677. position: 'relative',
  678. willChange: 'transform',
  679. };
  680. }, []);
  681. // Basic styles for the element container. We fake the height so that the
  682. // scrollbar is sized according to the number of items in the list.
  683. const containerStyles: React.CSSProperties = useMemo(() => {
  684. const height = tree.flattened.length * props.rowHeight;
  685. return {height, maxHeight: height};
  686. }, [tree.flattened.length, props.rowHeight]);
  687. const renderRow = props.renderRow;
  688. const renderedItems: React.ReactNode[] = useMemo(() => {
  689. const renderered: React.ReactNode[] = [];
  690. // It is important that we do not create a copy of item
  691. // because refs will assign the dom node to the item.
  692. // If we map, we get a new object that our internals will not be able to access.
  693. for (let i = 0; i < items.length; i++) {
  694. const item = items[i];
  695. renderered.push(
  696. renderRow(item, {
  697. handleRowClick: handleRowClick(item.key),
  698. handleExpandTreeNode,
  699. handleRowKeyDown,
  700. handleRowMouseEnter: handleRowMouseEnter(item.key),
  701. tabIndexKey: state.tabIndexKey,
  702. })
  703. );
  704. }
  705. return renderered;
  706. }, [
  707. items,
  708. handleRowClick,
  709. handleRowKeyDown,
  710. state.tabIndexKey,
  711. handleRowMouseEnter,
  712. handleExpandTreeNode,
  713. renderRow,
  714. ]);
  715. // Register a resize observer for when the scroll container is resized.
  716. // When the container is resized, update the scroll height in our state.
  717. // Similarly to handleScroll, we use requestAnimationFrame to avoid overupdating the UI
  718. useEffect(() => {
  719. if (!props.scrollContainer) {
  720. return undefined;
  721. }
  722. let rafId: number | undefined;
  723. const resizeObserver = new window.ResizeObserver(elements => {
  724. rafId = window.requestAnimationFrame(() => {
  725. dispatch({
  726. type: 'set scroll height',
  727. payload: elements[0]?.contentRect?.height ?? 0,
  728. });
  729. });
  730. });
  731. resizeObserver.observe(props.scrollContainer);
  732. return () => {
  733. if (typeof rafId === 'number') {
  734. window.cancelAnimationFrame(rafId);
  735. }
  736. resizeObserver.disconnect();
  737. };
  738. }, [props.scrollContainer]);
  739. return {
  740. tree,
  741. items,
  742. renderedItems,
  743. tabIndexKey: state.tabIndexKey,
  744. dispatch,
  745. handleRowClick,
  746. handleRowKeyDown,
  747. handleRowMouseEnter,
  748. handleExpandTreeNode,
  749. handleSortingChange,
  750. scrollContainerStyles,
  751. containerStyles,
  752. clickedGhostRowRef,
  753. hoveredGhostRowRef,
  754. };
  755. }