import type { VirtualItem, Virtualizer } from '@tanstack/react-virtual' import { useVirtualizer } from '@tanstack/react-virtual' import { mergeRefs } from 'common' import type { HTMLAttributes, ReactElement, ReactNode, Ref } from 'react' import { cloneElement, createContext, forwardRef, isValidElement, useCallback, useContext, useMemo, useRef, } from 'react' import { cn, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from 'ui' type TableComponentProps = React.ComponentProps interface VirtualizedTableProps extends TableComponentProps { scrollContainerProps?: HTMLAttributes scrollContainerRef: React.Ref data: TItem[] children: ReactNode overscan?: number estimateSize: (index: number) => number getItemKey?: (item: TItem, index: number) => string } type VirtualizedTableContextValue = { virtualizer: Virtualizer virtualItems: VirtualItem[] data: TItem[] paddingTop: number paddingBottom: number getRowKey: (item: TItem, index: number) => string | number } const VirtualizedTableContext = createContext | null>(null) const useVirtualizedTableContext = () => { const context = useContext(VirtualizedTableContext) if (!context) { throw new Error('VirtualizedTable components must be used within a VirtualizedTable') } return context as VirtualizedTableContextValue } export const VirtualizedTable = ({ scrollContainerProps, scrollContainerRef: externalScrollContainerRef, containerProps, data, children, overscan = 5, estimateSize, getItemKey, ...tableProps }: VirtualizedTableProps) => { const scrollContainerRef = useRef(null) const scrollContainerMergedRef = mergeRefs(scrollContainerRef, externalScrollContainerRef) const rowKeyGetter = useCallback( (item: TItem, index: number) => { return getItemKey ? getItemKey(item, index) : index }, [getItemKey] ) const getItemKeyFromIndex = useCallback( (index: number) => { const item = data[index] return item ? rowKeyGetter(item, index) : index }, [data, rowKeyGetter] ) const virtualizer = useVirtualizer({ count: data.length, getScrollElement: () => scrollContainerRef.current, overscan, estimateSize, getItemKey: getItemKeyFromIndex, }) const virtualItems = virtualizer.getVirtualItems() const totalSize = virtualizer.getTotalSize() const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0 const paddingBottom = virtualItems.length > 0 ? totalSize - virtualItems[virtualItems.length - 1].end : 0 const contextValue = useMemo>( () => ({ virtualizer, virtualItems, data, paddingTop, paddingBottom, getRowKey: rowKeyGetter, }), [virtualizer, virtualItems, data, paddingTop, paddingBottom, rowKeyGetter] ) const mergedContainerProps = useMemo( () => ({ ...containerProps, className: cn('overflow-visible', containerProps?.className), }), [containerProps] ) const { className: scrollClassName, ...restScrollContainerProps } = scrollContainerProps ?? {} return (
} > {children}
) } interface VirtualizedTableBodyProps extends Omit< React.ComponentProps, 'children' > { emptyContent?: ReactNode leadingContent?: ReactNode trailingContent?: ReactNode children: (item: TItem, index: number) => ReactElement paddingColSpan?: number paddingCellClassName?: string } export const VirtualizedTableBody = ({ emptyContent, leadingContent, trailingContent, children, paddingColSpan = 1, paddingCellClassName, ...props }: VirtualizedTableBodyProps) => { const { virtualizer, virtualItems, data, paddingTop, paddingBottom, getRowKey } = useVirtualizedTableContext() const measurementRef = virtualizer.measureElement as unknown as Ref return ( {leadingContent} {data.length === 0 ? ( (emptyContent ?? null) ) : ( <> {paddingTop > 0 && ( )} {virtualItems.map((virtualItem) => { const item = data[virtualItem.index] if (item === undefined) return null const renderedRow = children(item, virtualItem.index) if ( !isValidElement< Record & { ref?: Ref | null ['data-index']?: number } >(renderedRow) ) { return renderedRow } const key = renderedRow.key ?? getRowKey(item, virtualItem.index) const existingRef = ( renderedRow as unknown as { ref?: Ref | null } ).ref const combinedRef = existingRef != null ? mergeRefs(measurementRef, existingRef) : measurementRef return cloneElement(renderedRow, { key, ref: combinedRef, 'data-index': virtualItem.index, }) })} {paddingBottom > 0 && ( )} )} {trailingContent} ) } export const VirtualizedTableHeader = TableHeader export const VirtualizedTableHead = forwardRef< HTMLTableCellElement, React.ComponentProps >(({ className, ...props }, ref) => { return }) VirtualizedTableHead.displayName = 'VirtualizedTableHead' export const VirtualizedTableRow = TableRow export const VirtualizedTableCell = TableCell export const VirtualizedTableFooter = TableFooter export const VirtualizedTableCaption = TableCaption