useVirtualizedTree.tsx 27 KB

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