useVirtualizedTree.tsx 27 KB

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