@wordpress/compose
Version:
WordPress higher-order components (HOCs).
215 lines (200 loc) • 6.78 kB
text/typescript
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 ];
}