import * as React from 'react'; import IReactComponentProps from '../../../common/structures/IReactComponentProps'; import ResizeObserver, { DOMRect } from 'react-resize-observer'; import { VirtualListHelper, IVirtualListHelperCalculations } from './helpers/VirtualListHelper'; import classnames from 'classnames'; import styles from './VirtualList.scss'; import { ReactElement } from 'react'; import { ScrollShadow } from '../../overlays/ScrollShadow/ScrollShadow'; export interface IVirtualListProps extends IReactComponentProps { /* container render function used to generate the faked height of all items as if they were drawn in their entirety * note: children must be renderered manaually * note: customRendererStyles should only be set if the ReactNode returned by the renderer is a React.Fragment */ containerRenderer? (children: React.ReactNode, customRendererStyles: {minHeight: number}): ReactElement | null; /** list data passed to the list item renderer */ data: any[] | undefined | null; /** whether to disable the resize listener used to adjust calculated height (if true, it's highly recommended to set 'wrapperSizePx') */ disableResizeObserver?: boolean; /** whether to disable virtualized renderersFactory and draw all list items or not (if true) */ disableVirtualization?: boolean; /** the height of an individual list item (must be predictble, exact, and consistent) */ itemHeight: number; /** * item render function * note: the element returned by this factory function *must* be able to have its 'style' attribute set */ itemRenderer (listItemData: any, listItemIndex: number, customRendererStyles: {transform: string}, extraData: any): React.ReactNode; /** this is any extra data that needs to be passed to 'itemRenderer' */ itemRendererExtraData?: any; /** callback function for when list items are calculated but not necessarily rendered */ onCalculate? (calculations: IVirtualListHelperCalculations): void; /** callback function for when list items are rendered */ onRender? (calculations: IVirtualListHelperCalculations): void; /** the height (in pixels) for both before and after overscan areas */ overscan?: number | 'auto'; /** Whether to show alternating row stripes. */ striped?: boolean; /** manually set wrapper size in pixels (note: this will be overridden if disableResizeObserver is 'false', but should be used if 'disableResizeObserver' is set to 'true') */ wrapperSizePx?: number; /** wrapper render function used to generate the scrolling element */ wrapperRenderer? (children: React.ReactNode): ReactElement; } export interface IVirtualListState { containerSizePx: number; itemsRenderedList: ReturnType[]; lastDataLen: number; } export class VirtualList extends React.Component { static defaultProps: Partial = { disableResizeObserver: false, disableVirtualization: false, overscan: 'auto', }; protected _calcs!: IVirtualListHelperCalculations; protected _helper = new VirtualListHelper(); protected _scrollTop: number = 0; protected _wrapperRef: HTMLDivElement | null = null; protected _wrapperSizePx: number; constructor (props: IVirtualListProps) { super(props); this._wrapperSizePx = props.wrapperSizePx || 2000; this.state = { containerSizePx: 0, itemsRenderedList: [], lastDataLen: 0, }; this.setWrapperRef = this.setWrapperRef.bind(this); } setWrapperRef = (element: HTMLDivElement | null) => { if (this._wrapperRef) { this._wrapperRef.removeEventListener('scroll', this._onScroll as any); } if (element) { this._wrapperRef = element; this._wrapperRef.addEventListener('scroll', this._onScroll as any, { passive: true, }); } }; componentDidMount (): void { this._onDidMountOrUpdate(); } componentWillUnmount () { if (this._wrapperRef) { this._wrapperRef.removeEventListener('scroll', this._onScroll as any); } } componentDidUpdate (prevProps: IVirtualListProps, prevState: IVirtualListState) { this._onDidMountOrUpdate(); } UNSAFE_componentWillReceiveProps (nextProps: IVirtualListProps) { if (nextProps.data !== this.props.data || (nextProps.data && nextProps.data.length !== this.state.lastDataLen) || nextProps.itemHeight !== this.props.itemHeight || nextProps.itemRenderer !== this.props.itemRenderer || nextProps.overscan !== this.props.overscan || nextProps.itemRendererExtraData !== this.props.itemRendererExtraData ) { this._calculateItemsAndRenderIfChanged(nextProps, true); } } protected _calculateItemsAndRenderIfChanged (props: IVirtualListProps, forceReRender: boolean) { const prevCalcs = this._calcs; const calculations = this._helper.calculateItems(props.data ? props.data.length : 0, props.itemHeight, this._wrapperSizePx, this._scrollTop, props.overscan, props.disableVirtualization); // if virtual height has changed and is dangerously high if (calculations.containerSizePx > 10000000 && this._calcs && calculations.containerSizePx !== this._calcs.containerSizePx) { console.warn(`The virtual height of ${calculations.containerSizePx.toLocaleString()}px is dangerously high and may result in scrolling and/or rendering issues.`) } this._calcs = calculations; if (!props.data || !props.data.length) { this.setState(() => ({ containerSizePx: 0, itemsRenderedList: [], // clear out all list items lastDataLen: 0, })); } else if (forceReRender || !prevCalcs || calculations.virtualStartIndexInt !== prevCalcs.virtualStartIndexInt || calculations.virtualEndIndexInt !== prevCalcs.virtualEndIndexInt || calculations.containerSizePx !== prevCalcs.containerSizePx || calculations.viewportItemsCount !== prevCalcs.viewportItemsCount ) { const itemsRenderedList = this._helper.generateAndGetRenderedItems(props.data, props.itemRenderer, calculations, props.itemRendererExtraData, !!props.striped); this.setState(() => ({ containerSizePx: calculations.containerSizePx, itemsRenderedList: itemsRenderedList, lastDataLen: props.data!.length, })); } if (this.props.onCalculate) { this.props.onCalculate(this._calcs); } } protected _onDidMountOrUpdate () { if (this.props.onRender) { this.props.onRender(this._calcs); } } protected _onScroll = (event: UIEvent) => { if (this._wrapperRef && event.target === this._wrapperRef) { this._scrollTop = this._wrapperRef.scrollTop; this._calculateItemsAndRenderIfChanged(this.props, false); } }; protected _onResize = (rect: DOMRect) => { if (rect.height !== this._wrapperSizePx) { // get clientHeight from ref if it's available (may not immediately be) otherwise get resize height (which is effectively its offsetHeight) this._wrapperSizePx = this._wrapperRef ? this._wrapperRef.clientHeight : rect.height; this._calculateItemsAndRenderIfChanged(this.props, false); } }; protected _renderContainer () { const containerStyle = {minHeight: this.state.containerSizePx}; const containerRenderer: ReactElement | undefined | null = this.props.containerRenderer && this.props.containerRenderer(this.state.itemsRenderedList, containerStyle); const containerClassName: string = classnames( styles.VirtualList, { [styles.VirtualList__Striped]: this.props.striped, } ); return ( <> {!this.props.disableResizeObserver && } {containerRenderer ? React.cloneElement( containerRenderer as React.ReactElement, { ...containerRenderer.props, className: classnames(containerRenderer.props.className || '', containerClassName), style: {...containerStyle, ...(containerRenderer.props.style || {})}, }, ) : (
{this.state.itemsRenderedList}
) } ); } protected _renderWrapper () { const wrapperChildren: React.ReactNode = this._renderContainer(); const wrapperRenderer: ReactElement | undefined = this.props.wrapperRenderer && this.props.wrapperRenderer(wrapperChildren); const wrapperClassName: string = classnames( styles.VirtualList_Wrapper, this.props.className, ); return (wrapperRenderer ? React.cloneElement( wrapperRenderer as React.ReactElement, { ...wrapperRenderer.props, className: classnames(wrapperRenderer.props.className || '', wrapperClassName), }, ) : (
{wrapperChildren}
) ); } render () { return ( {this._renderWrapper()} ); } }