123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916 |
- import {
- MutableRefObject,
- useCallback,
- useEffect,
- useMemo,
- useReducer,
- useRef,
- useState,
- } from 'react';
- import {Theme, useTheme} from '@emotion/react';
- import {useEffectAfterFirstRender} from 'sentry/utils/useEffectAfterFirstRender';
- import {VirtualizedTree} from './VirtualizedTree';
- import {VirtualizedTreeNode} from './VirtualizedTreeNode';
- type AnimationTimeoutId = {
- id: number;
- };
- const cancelAnimationTimeout = (frame: AnimationTimeoutId) =>
- window.cancelAnimationFrame(frame.id);
- /**
- * Recursively calls requestAnimationFrame until a specified delay has been met or exceeded.
- * When the delay time has been reached the function you're timing out will be called.
- * This was copied from react-virtualized, with credits to the original author.
- *
- * Credit: Joe Lambert (https://gist.github.com/joelambert/1002116#file-requesttimeout-js)
- */
- const requestAnimationTimeout = (
- callback: Function,
- delay: number
- ): AnimationTimeoutId => {
- let start;
- // wait for end of processing current event handler, because event handler may be long
- Promise.resolve().then(() => {
- start = Date.now();
- });
- const timeout = () => {
- if (start === undefined) {
- frame.id = window.requestAnimationFrame(timeout);
- return;
- }
- if (Date.now() - start >= delay) {
- callback();
- } else {
- frame.id = window.requestAnimationFrame(timeout);
- }
- };
- const frame: AnimationTimeoutId = {
- id: window.requestAnimationFrame(timeout),
- };
- return frame;
- };
- export interface TreeLike {
- children: TreeLike[];
- }
- interface VirtualizedState<T> {
- overscroll: number;
- roots: T[];
- scrollHeight: number;
- scrollTop: number;
- tabIndexKey: number | null;
- }
- interface SetScrollTop {
- payload: number;
- type: 'set scroll top';
- }
- interface SetTabIndexKey {
- payload: number | null;
- type: 'set tab index key';
- }
- interface SetContainerHeight {
- payload: number;
- type: 'set scroll height';
- }
- type VirtualizedStateAction = SetScrollTop | SetContainerHeight | SetTabIndexKey;
- export function VirtualizedTreeStateReducer<T>(
- state: VirtualizedState<T>,
- action: VirtualizedStateAction
- ): VirtualizedState<T> {
- switch (action.type) {
- case 'set tab index key': {
- return {...state, tabIndexKey: action.payload};
- }
- case 'set scroll top': {
- return {...state, scrollTop: action.payload};
- }
- case 'set scroll height': {
- return {...state, scrollHeight: action.payload};
- }
- default: {
- return state;
- }
- }
- }
- function hideGhostRow({ref}: {ref: MutableRefObject<HTMLElement | null>}) {
- if (ref.current) {
- ref.current.style.opacity = '0';
- }
- }
- function updateGhostRow({
- ref,
- tabIndexKey,
- rowHeight,
- scrollTop,
- interaction,
- theme,
- }: {
- interaction: 'hover' | 'active';
- ref: MutableRefObject<HTMLElement | null>;
- rowHeight: number;
- scrollTop: number;
- tabIndexKey: number;
- theme: Theme;
- }) {
- if (!ref.current) {
- return;
- }
- ref.current.style.left = '0';
- ref.current.style.right = '0';
- ref.current.style.height = `${rowHeight}px`;
- ref.current.style.position = 'absolute';
- ref.current.style.backgroundColor =
- interaction === 'active' ? theme.blue300 : theme.surface100;
- ref.current.style.pointerEvents = 'none';
- ref.current.style.willChange = 'transform, opacity';
- ref.current.style.transform = `translateY(${rowHeight * tabIndexKey - scrollTop}px)`;
- ref.current.style.opacity = '1';
- }
- function findOptimisticStartIndex<T extends TreeLike>({
- items,
- overscroll,
- rowHeight,
- scrollTop,
- viewport,
- }: {
- items: VirtualizedTreeNode<T>[];
- overscroll: number;
- rowHeight: number;
- scrollTop: number;
- viewport: {bottom: number; top: number};
- }): number {
- if (!items.length || viewport.top === 0) {
- return 0;
- }
- return Math.max(Math.floor(scrollTop / rowHeight) - overscroll, 0);
- }
- function findVisibleItems<T extends TreeLike>({
- items,
- overscroll,
- rowHeight,
- scrollHeight,
- scrollTop,
- }: {
- items: VirtualizedTreeNode<T>[];
- overscroll: NonNullable<UseVirtualizedListProps<T>['overscroll']>;
- rowHeight: UseVirtualizedListProps<T>['rowHeight'];
- scrollHeight: VirtualizedState<T>['scrollHeight'];
- scrollTop: VirtualizedState<T>['scrollTop'];
- }) {
- // This is overscroll height for single direction, when computing the total,
- // we need to multiply this by 2 because we overscroll in both directions.
- const OVERSCROLL_HEIGHT = overscroll * rowHeight;
- const visibleItems: VisibleItem<T>[] = [];
- // Clamp viewport to scrollHeight bounds [0, length * rowHeight] because some browsers may fire
- // scrollTop with negative values when the user scrolls up past the top of the list (overscroll behavior)
- const viewport = {
- top: Math.max(scrollTop - OVERSCROLL_HEIGHT, 0),
- bottom: Math.min(
- scrollTop + scrollHeight + OVERSCROLL_HEIGHT,
- items.length * rowHeight
- ),
- };
- // Points to the position inside the visible array
- let visibleItemIndex = 0;
- // Points to the currently iterated item
- let indexPointer = findOptimisticStartIndex({
- items,
- viewport,
- scrollTop,
- rowHeight,
- overscroll,
- });
- // Max number of visible items in our list
- const MAX_VISIBLE_ITEMS = Math.ceil((scrollHeight + OVERSCROLL_HEIGHT * 2) / rowHeight);
- const ALL_ITEMS = items.length;
- // While number of visible items is less than max visible items, and we haven't reached the end of the list
- while (visibleItemIndex < MAX_VISIBLE_ITEMS && indexPointer < ALL_ITEMS) {
- const elementTop = indexPointer * rowHeight;
- const elementBottom = elementTop + rowHeight;
- // An element is inside a viewport if the top of the element is below the top of the viewport
- // and the bottom of the element is above the bottom of the viewport
- if (elementTop >= viewport.top && elementBottom <= viewport.bottom) {
- visibleItems[visibleItemIndex] = {
- key: indexPointer,
- ref: null,
- styles: {position: 'absolute', top: elementTop},
- item: items[indexPointer],
- };
- visibleItemIndex++;
- }
- indexPointer++;
- }
- return visibleItems;
- }
- interface VisibleItem<T> {
- item: VirtualizedTreeNode<T>;
- key: number;
- ref: HTMLElement | null;
- styles: React.CSSProperties;
- }
- export interface UseVirtualizedListProps<T extends TreeLike> {
- renderRow: (
- item: VisibleItem<T>,
- itemHandlers: {
- handleExpandTreeNode: (
- node: VirtualizedTreeNode<T>,
- opts?: {expandChildren: boolean}
- ) => void;
- handleRowClick: (evt: React.MouseEvent<HTMLElement>) => void;
- handleRowKeyDown: (event: React.KeyboardEvent) => void;
- handleRowMouseEnter: (event: React.MouseEvent<HTMLElement>) => void;
- tabIndexKey: number | null;
- }
- ) => React.ReactNode;
- rowHeight: number;
- scrollContainer: HTMLElement | null;
- tree: T[];
- expanded?: boolean;
- overscroll?: number;
- skipFunction?: (node: VirtualizedTreeNode<T>) => boolean;
- sortFunction?: (a: VirtualizedTreeNode<T>, b: VirtualizedTreeNode<T>) => number;
- }
- const DEFAULT_OVERSCROLL_ITEMS = 5;
- function findCarryOverIndex<T extends TreeLike>(
- previousNode: VirtualizedTreeNode<T> | null | undefined,
- newTree: VirtualizedTree<T>
- ): number | null {
- if (!newTree.flattened.length || !previousNode) {
- return null;
- }
- const newIndex = newTree.flattened.findIndex(n => n.node === previousNode.node);
- if (newIndex === -1) {
- return null;
- }
- return newIndex;
- }
- export function useVirtualizedTree<T extends TreeLike>(
- props: UseVirtualizedListProps<T>
- ) {
- const theme = useTheme();
- const clickedGhostRowRef = useRef<HTMLDivElement | null>(null);
- const hoveredGhostRowRef = useRef<HTMLDivElement | null>(null);
- const previousHoveredRow = useRef<number | null>(null);
- const [state, dispatch] = useReducer(VirtualizedTreeStateReducer, {
- scrollTop: 0,
- roots: props.tree,
- tabIndexKey: null,
- overscroll: props.overscroll ?? DEFAULT_OVERSCROLL_ITEMS,
- scrollHeight: props.scrollContainer?.getBoundingClientRect()?.height ?? 0,
- });
- // Keep a ref to latest state to avoid re-rendering
- const latestStateRef = useRef<typeof state>(state);
- latestStateRef.current = state;
- const [tree, setTree] = useState(() => {
- const initialTree = VirtualizedTree.fromRoots(
- props.tree,
- props.expanded,
- props.skipFunction
- );
- if (props.sortFunction) {
- initialTree.sort(props.sortFunction);
- }
- return initialTree;
- });
- const cleanupAllHoveredRows = useCallback(() => {
- previousHoveredRow.current = null;
- for (const row of latestItemsRef.current) {
- if (row.ref && row.ref.dataset.hovered) {
- delete row.ref.dataset.hovered;
- }
- }
- }, []);
- const flattenedHistory = useRef<ReadonlyArray<VirtualizedTreeNode<T>>>(tree.flattened);
- const expandedHistory = useRef<Set<T>>(tree.getAllExpandedNodes(new Set()));
- useEffectAfterFirstRender(() => {
- const newTree = VirtualizedTree.fromRoots(
- props.tree,
- props.expanded,
- props.skipFunction,
- expandedHistory.current
- );
- if (props.sortFunction) {
- newTree.sort(props.sortFunction);
- }
- const tabIndex = findCarryOverIndex(
- typeof latestStateRef.current.tabIndexKey === 'number'
- ? flattenedHistory.current[latestStateRef.current.tabIndexKey]
- : null,
- newTree
- );
- if (tabIndex) {
- updateGhostRow({
- ref: clickedGhostRowRef,
- tabIndexKey: tabIndex,
- rowHeight: props.rowHeight,
- scrollTop: latestStateRef.current.scrollTop,
- interaction: 'active',
- theme,
- });
- } else {
- hideGhostRow({ref: clickedGhostRowRef});
- }
- cleanupAllHoveredRows();
- hideGhostRow({ref: hoveredGhostRowRef});
- dispatch({type: 'set tab index key', payload: tabIndex});
- setTree(newTree);
- expandedHistory.current = newTree.getAllExpandedNodes(expandedHistory.current);
- flattenedHistory.current = newTree.flattened;
- }, [
- props.tree,
- props.expanded,
- props.skipFunction,
- props.sortFunction,
- props.rowHeight,
- cleanupAllHoveredRows,
- theme,
- ]);
- const items = useMemo(() => {
- return findVisibleItems<T>({
- items: tree.flattened,
- scrollHeight: state.scrollHeight,
- scrollTop: state.scrollTop,
- overscroll: state.overscroll,
- rowHeight: props.rowHeight,
- });
- }, [tree, state.overscroll, state.scrollHeight, state.scrollTop, props.rowHeight]);
- const latestItemsRef = useRef(items);
- latestItemsRef.current = items;
- // On scroll, we update scrollTop position.
- // Keep a rafId reference in the unlikely event where component unmounts before raf is executed.
- const scrollEndTimeoutId = useRef<AnimationTimeoutId | undefined>(undefined);
- const previousScrollHeight = useRef<number>(0);
- useEffect(() => {
- const scrollContainer = props.scrollContainer;
- if (!scrollContainer) {
- return undefined;
- }
- function handleScroll(evt) {
- const top = Math.max(evt.target.scrollTop, 0);
- if (previousScrollHeight.current === top) {
- return;
- }
- evt.target.firstChild.style.pointerEvents = 'none';
- if (scrollEndTimeoutId.current !== undefined) {
- cancelAnimationTimeout(scrollEndTimeoutId.current);
- }
- scrollEndTimeoutId.current = requestAnimationTimeout(() => {
- evt.target.firstChild.style.pointerEvents = 'auto';
- }, 150);
- dispatch({
- type: 'set scroll top',
- payload: top,
- });
- // On scroll, we need to update the selected ghost row and clear the hovered ghost row
- if (latestStateRef.current.tabIndexKey !== null) {
- updateGhostRow({
- ref: clickedGhostRowRef,
- tabIndexKey: latestStateRef.current.tabIndexKey,
- scrollTop: Math.max(evt.target.scrollTop, 0),
- interaction: 'active',
- rowHeight: props.rowHeight,
- theme,
- });
- }
- cleanupAllHoveredRows();
- hideGhostRow({
- ref: hoveredGhostRowRef,
- });
- previousScrollHeight.current = top;
- }
- scrollContainer.addEventListener('scroll', handleScroll, {
- passive: true,
- });
- return () => {
- scrollContainer.removeEventListener('scroll', handleScroll);
- };
- }, [props.scrollContainer, props.rowHeight, cleanupAllHoveredRows, theme]);
- useEffect(() => {
- const scrollContainer = props.scrollContainer;
- if (!scrollContainer) {
- return undefined;
- }
- // Because nodes dont span the full width, it's possible for users to
- // click or hover on a node at the far right end which is outside of the row width.
- // In that case, we check if the cursor position overlaps with a row and select that row.
- const handleClick = (evt: MouseEvent) => {
- if (evt.target !== scrollContainer) {
- // user clicked on an element inside the container, defer to onClick
- return;
- }
- const rect = (evt.target as HTMLDivElement).getBoundingClientRect();
- const index = Math.floor(
- (latestStateRef.current.scrollTop + evt.clientY - rect.top) / props.rowHeight
- );
- // If a node exists at the index, select it
- if (tree.flattened[index]) {
- dispatch({type: 'set tab index key', payload: index});
- updateGhostRow({
- ref: clickedGhostRowRef,
- tabIndexKey: index,
- scrollTop: latestStateRef.current.scrollTop,
- rowHeight: props.rowHeight,
- interaction: 'active',
- theme,
- });
- }
- };
- // Because nodes dont span the full width, it's possible for users to
- // click on a node at the far right end which is outside of the row width.
- // In that case, check if the top position where the user clicked overlaps
- // with a row and select that row.
- const handleMouseMove = (evt: MouseEvent) => {
- if (evt.target !== scrollContainer) {
- // user clicked on an element inside the container, defer to onClick
- return;
- }
- const rect = (evt.target as HTMLDivElement).getBoundingClientRect();
- const index = Math.floor(
- (latestStateRef.current.scrollTop + evt.clientY - rect.top) / props.rowHeight
- );
- cleanupAllHoveredRows();
- const element = latestItemsRef.current.find(item => item.key === index);
- if (element?.ref) {
- element.ref.dataset.hovered = 'true';
- }
- // If a node exists at the index, select it, else clear whatever is selected
- if (tree.flattened[index] && index !== latestStateRef.current.tabIndexKey) {
- updateGhostRow({
- ref: hoveredGhostRowRef,
- tabIndexKey: index,
- scrollTop: latestStateRef.current.scrollTop,
- rowHeight: props.rowHeight,
- interaction: 'hover',
- theme,
- });
- } else {
- hideGhostRow({
- ref: hoveredGhostRowRef,
- });
- }
- };
- scrollContainer.addEventListener('click', handleClick);
- scrollContainer.addEventListener('mousemove', handleMouseMove);
- return () => {
- scrollContainer.removeEventListener('click', handleClick);
- scrollContainer.removeEventListener('mousemove', handleMouseMove);
- };
- }, [
- props.rowHeight,
- props.scrollContainer,
- tree.flattened,
- cleanupAllHoveredRows,
- theme,
- ]);
- // When mouseleave is triggered on the contianer,
- // we need to hide the ghost row to avoid an orphaned row
- useEffect(() => {
- const container = props.scrollContainer;
- if (!container) {
- return undefined;
- }
- function onMouseLeave() {
- cleanupAllHoveredRows();
- hideGhostRow({
- ref: hoveredGhostRowRef,
- });
- }
- container.addEventListener('mouseleave', onMouseLeave, {
- passive: true,
- });
- return () => {
- container.removeEventListener('mouseleave', onMouseLeave);
- };
- }, [cleanupAllHoveredRows, props.scrollContainer]);
- // When a node is expanded, the underlying tree is recomputed (the flattened tree is updated)
- // We copy the properties of the old tree by creating a new instance of VirtualizedTree
- // and passing in the roots and its flattened representation so that no extra work is done.
- const handleExpandTreeNode = useCallback(
- (node: VirtualizedTreeNode<T>, opts?: {expandChildren: boolean}) => {
- // When we expand nodes, tree.expand will mutate the underlying tree which then
- // gets copied to the new tree instance. To get the right index, we need to read
- // it before any mutations are made
- const previousNode = latestStateRef.current.tabIndexKey
- ? tree.flattened[latestStateRef.current.tabIndexKey] ?? null
- : null;
- tree.expandNode(node, !node.expanded, opts);
- const newTree = new VirtualizedTree(tree.roots, tree.flattened);
- expandedHistory.current = newTree.getAllExpandedNodes(new Set());
- // Hide or update the ghost if necessary
- const tabIndex = findCarryOverIndex(previousNode, newTree);
- if (tabIndex === null) {
- hideGhostRow({ref: clickedGhostRowRef});
- } else {
- updateGhostRow({
- ref: clickedGhostRowRef,
- tabIndexKey: tabIndex,
- scrollTop: Math.max(latestStateRef.current.scrollTop, 0),
- interaction: 'active',
- rowHeight: props.rowHeight,
- theme,
- });
- }
- dispatch({type: 'set tab index key', payload: tabIndex});
- setTree(newTree);
- },
- [tree, props.rowHeight, theme]
- );
- // When a tree is sorted, we sort all of the nodes in the tree and not just the visible ones
- // We could probably optimize this to lazily sort as we scroll, but since we want the least amount
- // of work during scrolling, we just sort the entire tree every time.
- const handleSortingChange = useCallback(
- (sortFn: (a: VirtualizedTreeNode<T>, b: VirtualizedTreeNode<T>) => number) => {
- // When we sort nodes, tree.sort will mutate the underlying tree which then
- // gets copied to the new tree instance. To get the right index, we need to read
- // it before any mutations are made
- const previousNode = latestStateRef.current.tabIndexKey
- ? tree.flattened[latestStateRef.current.tabIndexKey] ?? null
- : null;
- tree.sort(sortFn);
- const newTree = new VirtualizedTree(tree.roots, tree.flattened);
- // Hide or update the ghost if necessary
- const tabIndex = findCarryOverIndex(previousNode, newTree);
- if (tabIndex === null) {
- hideGhostRow({ref: clickedGhostRowRef});
- } else {
- updateGhostRow({
- ref: clickedGhostRowRef,
- tabIndexKey: tabIndex,
- scrollTop: Math.max(latestStateRef.current.scrollTop, 0),
- interaction: 'active',
- rowHeight: props.rowHeight,
- theme,
- });
- }
- dispatch({type: 'set tab index key', payload: tabIndex});
- setTree(newTree);
- },
- [tree, props.rowHeight, theme]
- );
- // When a row is clicked, we update the selected node
- const handleRowClick = useCallback(
- (tabIndexKey: number) => {
- return (_evt: React.MouseEvent<HTMLElement>) => {
- dispatch({type: 'set tab index key', payload: tabIndexKey});
- updateGhostRow({
- ref: clickedGhostRowRef,
- tabIndexKey,
- scrollTop: state.scrollTop,
- rowHeight: props.rowHeight,
- interaction: 'active',
- theme,
- });
- };
- },
- [state.scrollTop, props.rowHeight, theme]
- );
- // Keyboard navigation for row
- const handleRowKeyDown = useCallback(
- (event: React.KeyboardEvent) => {
- if (latestStateRef.current.tabIndexKey === null) {
- return;
- }
- // Cant move anywhere if there are no nodes
- if (!tree.flattened.length) {
- return;
- }
- if (event.key === 'Enter') {
- handleExpandTreeNode(tree.flattened[latestStateRef.current.tabIndexKey], {
- expandChildren: true,
- });
- }
- if (event.key === 'ArrowDown') {
- event.preventDefault();
- if (event.metaKey || event.ctrlKey) {
- const index = tree.flattened.length - 1;
- props.scrollContainer!.scrollTo({
- // We need to offset for the scrollMargin
- top: index * props.rowHeight + props.rowHeight,
- });
- dispatch({type: 'set tab index key', payload: index});
- updateGhostRow({
- ref: clickedGhostRowRef,
- tabIndexKey: index,
- scrollTop: latestStateRef.current.scrollTop,
- rowHeight: props.rowHeight,
- interaction: 'active',
- theme,
- });
- return;
- }
- // This is fine because we are only searching visible items
- // and not the entire tree of nodes
- const indexInVisibleItems = items.findIndex(
- i => i.key === latestStateRef.current.tabIndexKey
- );
- if (indexInVisibleItems !== -1) {
- const nextIndex = indexInVisibleItems + 1;
- // Bounds check if we are at end of list
- if (nextIndex > tree.flattened.length - 1) {
- return;
- }
- dispatch({type: 'set tab index key', payload: items[nextIndex].key});
- updateGhostRow({
- ref: clickedGhostRowRef,
- tabIndexKey: items[nextIndex].key,
- scrollTop: latestStateRef.current.scrollTop,
- rowHeight: props.rowHeight,
- interaction: 'active',
- theme,
- });
- items[nextIndex].ref?.focus({preventScroll: true});
- items[nextIndex].ref?.scrollIntoView({block: 'nearest'});
- }
- }
- if (event.key === 'ArrowUp') {
- event.preventDefault();
- if (event.metaKey || event.ctrlKey) {
- props.scrollContainer!.scrollTo({top: 0});
- dispatch({type: 'set tab index key', payload: 0});
- updateGhostRow({
- ref: clickedGhostRowRef,
- tabIndexKey: 0,
- scrollTop: latestStateRef.current.scrollTop,
- rowHeight: props.rowHeight,
- interaction: 'active',
- theme,
- });
- return;
- }
- // This is fine because we are only searching visible items
- // and not the entire tree of nodes
- const indexInVisibleItems = items.findIndex(
- i => i.key === latestStateRef.current.tabIndexKey
- );
- if (indexInVisibleItems !== -1) {
- const nextIndex = indexInVisibleItems - 1;
- // Bound check if we are at start of list
- if (nextIndex < 0) {
- return;
- }
- dispatch({type: 'set tab index key', payload: items[nextIndex].key});
- updateGhostRow({
- ref: clickedGhostRowRef,
- tabIndexKey: items[nextIndex].key,
- scrollTop: latestStateRef.current.scrollTop,
- rowHeight: props.rowHeight,
- interaction: 'active',
- theme,
- });
- items[nextIndex].ref?.focus({preventScroll: true});
- items[nextIndex].ref?.scrollIntoView({block: 'nearest'});
- }
- }
- },
- [
- handleExpandTreeNode,
- items,
- tree.flattened,
- props.rowHeight,
- props.scrollContainer,
- theme,
- ]
- );
- // When a row is hovered, we update the ghost row
- const handleRowMouseEnter = useCallback(
- (key: number) => {
- return (_evt: React.MouseEvent<HTMLElement>) => {
- if (previousHoveredRow.current !== key) {
- cleanupAllHoveredRows();
- (_evt.currentTarget as HTMLElement).dataset.hovered = 'true';
- previousHoveredRow.current = key;
- }
- updateGhostRow({
- ref: hoveredGhostRowRef,
- tabIndexKey: key,
- scrollTop: state.scrollTop,
- rowHeight: props.rowHeight,
- interaction: 'hover',
- theme,
- });
- };
- },
- [state.scrollTop, props.rowHeight, cleanupAllHoveredRows, theme]
- );
- // Register a resize observer for when the scroll container is resized.
- // When the container is resized, update the scroll height in our state.
- // Similarly to handleScroll, we use requestAnimationFrame to avoid overupdating the UI
- useEffect(() => {
- if (!props.scrollContainer) {
- return undefined;
- }
- let rafId: number | undefined;
- const resizeObserver = new window.ResizeObserver(elements => {
- rafId = window.requestAnimationFrame(() => {
- dispatch({
- type: 'set scroll height',
- payload: elements[0]?.contentRect?.height ?? 0,
- });
- cleanupAllHoveredRows();
- hideGhostRow({
- ref: hoveredGhostRowRef,
- });
- });
- });
- resizeObserver.observe(props.scrollContainer);
- return () => {
- if (typeof rafId === 'number') {
- window.cancelAnimationFrame(rafId);
- }
- resizeObserver.disconnect();
- };
- }, [props.scrollContainer, cleanupAllHoveredRows]);
- // Basic required styles for the scroll container
- const scrollContainerStyles: React.CSSProperties = useMemo(() => {
- return {
- height: '100%',
- overflow: 'auto',
- position: 'relative',
- willChange: 'transform',
- };
- }, []);
- // Basic styles for the element container. We fake the height so that the
- // scrollbar is sized according to the number of items in the list.
- const containerStyles: React.CSSProperties = useMemo(() => {
- const height = tree.flattened.length * props.rowHeight;
- return {height, maxHeight: height};
- }, [tree.flattened.length, props.rowHeight]);
- const renderRow = props.renderRow;
- const renderedItems: React.ReactNode[] = useMemo(() => {
- const renderered: React.ReactNode[] = [];
- // It is important that we do not create a copy of item
- // because refs will assign the dom node to the item.
- // If we map, we get a new object that our internals will not be able to access.
- for (let i = 0; i < items.length; i++) {
- const item = items[i];
- renderered.push(
- renderRow(item, {
- handleRowClick: handleRowClick(item.key),
- handleExpandTreeNode,
- handleRowKeyDown,
- handleRowMouseEnter: handleRowMouseEnter(item.key),
- tabIndexKey: state.tabIndexKey,
- })
- );
- }
- return renderered;
- }, [
- items,
- handleRowClick,
- handleRowKeyDown,
- state.tabIndexKey,
- handleRowMouseEnter,
- handleExpandTreeNode,
- renderRow,
- ]);
- // Register a resize observer for when the scroll container is resized.
- // When the container is resized, update the scroll height in our state.
- // Similarly to handleScroll, we use requestAnimationFrame to avoid overupdating the UI
- useEffect(() => {
- if (!props.scrollContainer) {
- return undefined;
- }
- let rafId: number | undefined;
- const resizeObserver = new window.ResizeObserver(elements => {
- rafId = window.requestAnimationFrame(() => {
- dispatch({
- type: 'set scroll height',
- payload: elements[0]?.contentRect?.height ?? 0,
- });
- });
- });
- resizeObserver.observe(props.scrollContainer);
- return () => {
- if (typeof rafId === 'number') {
- window.cancelAnimationFrame(rafId);
- }
- resizeObserver.disconnect();
- };
- }, [props.scrollContainer]);
- return {
- tree,
- items,
- renderedItems,
- tabIndexKey: state.tabIndexKey,
- dispatch,
- handleRowClick,
- handleRowKeyDown,
- handleRowMouseEnter,
- handleExpandTreeNode,
- handleSortingChange,
- scrollContainerStyles,
- containerStyles,
- clickedGhostRowRef,
- hoveredGhostRowRef,
- };
- }
|