UNPKG

@wordpress/compose

Version:
215 lines (200 loc) 6.78 kB
import { useState, useLayoutEffect, useRef } from '@wordpress/element'; import { getScrollContainer } from '@wordpress/dom'; import { PAGEUP, PAGEDOWN, HOME, END } from '@wordpress/keycodes'; import useEvent from '../use-event'; const DEFAULT_INIT_WINDOW_SIZE = 30; interface FixedWindowList { /** Items visible in the viewport, as of the last rendered window */ visibleItems: number; /** Start index of the window */ start: number; /** End index of the window */ end: number; /** Returns true if item is in the window */ itemInView: ( index: number ) => boolean; } interface FixedWindowListOptions { /** Renders windowOverscan number of items before and after the calculated visible window. */ windowOverscan?: number; /** When false avoids calculating the window size */ useWindowing?: boolean; /** Initial window size to use on first render before we can calculate the window size. */ initWindowSize?: number; /** Used to recalculate the window size when the expanded state of a list changes. */ expandedState?: any; } /** * * @param elementRef Used to find the closest scroll container that contains element. * @param itemHeight Fixed item height in pixels * @param totalItems Total items in list * @param [options] Options object * @return Array with the fixed window list and setter */ export default function useFixedWindowList( elementRef: React.RefObject< HTMLElement >, itemHeight: number, totalItems: number, options?: FixedWindowListOptions ): [ FixedWindowList, React.Dispatch< React.SetStateAction< FixedWindowList > >, ] { const { windowOverscan, useWindowing = true, initWindowSize = DEFAULT_INIT_WINDOW_SIZE, expandedState, } = options ?? {}; const [ fixedListWindow, setFixedListWindow ] = useState< FixedWindowList >( { visibleItems: initWindowSize, start: 0, end: initWindowSize, itemInView: ( index: number ) => { return index >= 0 && index <= initWindowSize; }, } ); // Kept out of state so that measuring the list does not, on its own, force a // re-render. Only the rendered window does that. const visibleItemsRef = useRef( initWindowSize ); // The measuring effect re-runs whenever the list changes, so the effect // running can't on its own tell the very first measurement from later ones. const isFirstMeasurementRef = useRef( true ); // Stable identity, so the listeners below never have to be re-attached, and // reads the latest props on every call. const measureWindow = useEvent( ( initRender?: boolean ) => { const scrollContainer = getScrollContainer( elementRef.current ); if ( ! scrollContainer ) { return; } const visibleItems = Math.ceil( scrollContainer.clientHeight / itemHeight ); visibleItemsRef.current = visibleItems; const isFirstMeasurement = isFirstMeasurementRef.current; isFirstMeasurementRef.current = false; // Aim to keep opening list view fast, afterward we can optimize for scrolling. const overscan = initRender ? visibleItems : windowOverscan ?? visibleItems; const firstViewableIndex = Math.floor( scrollContainer.scrollTop / itemHeight ); const start = Math.max( 0, firstViewableIndex - overscan ); const end = Math.min( totalItems - 1, firstViewableIndex + visibleItems + overscan ); setFixedListWindow( ( lastWindow ) => { // Rendering the window is the expensive part, and for the items in // `lastWindow` it is already paid for. When the next window has // nothing to add, keep the current one: a window that only shrinks // drops nodes that are known to be needed again and forces another // style recalculation, with nothing new to show for it. if ( lastWindow.start <= start && lastWindow.end >= end ) { return lastWindow; } // The first window is rendered before the list can be measured, so // it is sized by `initWindowSize` rather than by the viewport, and // the measured window is normally wider than it by the overscan. // Nothing has painted yet though, so there is no scrolling for the // overscan to absorb: covering the visible items is enough, and // keeping that window saves a render pass while the list opens. if ( isFirstMeasurement && lastWindow.start <= firstViewableIndex && lastWindow.end >= firstViewableIndex + visibleItems ) { return lastWindow; } return { visibleItems, start, end, itemInView: ( index: number ) => { return start <= index && index <= end; }, }; } ); } ); const handleKeyDown = useEvent( ( event: KeyboardEvent, scrollContainer: Element ) => { switch ( event.keyCode ) { case HOME: { return scrollContainer.scrollTo( { top: 0 } ); } case END: { return scrollContainer.scrollTo( { top: totalItems * itemHeight, } ); } case PAGEUP: { return scrollContainer.scrollTo( { top: scrollContainer.scrollTop - visibleItemsRef.current * itemHeight, } ); } case PAGEDOWN: { return scrollContainer.scrollTo( { top: scrollContainer.scrollTop + visibleItemsRef.current * itemHeight, } ); } } } ); // Measure whenever something that the window is derived from changes. useLayoutEffect( () => { if ( ! useWindowing ) { return; } measureWindow( true ); }, [ useWindowing, measureWindow, itemHeight, totalItems, windowOverscan, expandedState, ] ); // Only ever attaches and detaches listeners. The deps are the things that // can change which element the scroll container is, or whether there is one // at all: a list that is too short to overflow has no scroll container to // attach to, and it grows into one by gaining items or by being expanded. useLayoutEffect( () => { if ( ! useWindowing ) { return; } const scrollContainer = getScrollContainer( elementRef.current ); if ( ! scrollContainer ) { return; } const { defaultView } = scrollContainer.ownerDocument; // `scroll` and `resize` already fire at about the rendering rate, so // there is nothing to gain from debouncing them. const onMeasure = () => measureWindow(); const onKeyDown = ( event: KeyboardEvent ) => handleKeyDown( event, scrollContainer ); scrollContainer.addEventListener( 'scroll', onMeasure ); defaultView?.addEventListener( 'resize', onMeasure ); defaultView?.addEventListener( 'keydown', onKeyDown ); return () => { scrollContainer.removeEventListener( 'scroll', onMeasure ); defaultView?.removeEventListener( 'resize', onMeasure ); defaultView?.removeEventListener( 'keydown', onKeyDown ); }; }, [ useWindowing, elementRef, itemHeight, totalItems, expandedState, measureWindow, handleKeyDown, ] ); return [ fixedListWindow, setFixedListWindow ]; }