import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual' import { ComponentPropsWithRef, ComponentType, createContext, createElement, CSSProperties, ElementType, memo, ReactNode, Ref, useContext, useEffect, useMemo, useRef, type ComponentProps, type PropsWithChildren, } from 'react' import { cn, Skeleton } from 'ui' // Regular memo erases generics, so this helper adds them back // any here is intentional to allow for generic components and does not affect // type safety of the wrapped component // eslint-disable-next-line @typescript-eslint/no-explicit-any const typedMemo = ReactNode>( component: Component, propsAreEqual?: ( prevProps: Readonly[0]>, nextProps: Readonly[0]> ) => boolean ) => memo(component, propsAreEqual) as unknown as Component & { displayName?: string } const createStyleObject = ({ size, start }: { size: number; start: number }): CSSProperties => ({ position: 'absolute', top: 0, left: 0, width: '100%', height: `${size}px`, transform: `translateY(${start}px)`, }) type VirtualizerInstance = Virtualizer type VirtualItems = ReturnType type VirtualizerContextValue = { virtualizer: VirtualizerInstance virtualItems: VirtualItems } const VirtualizerContext = createContext(null) export const VirtualizerProvider = ({ children, value, }: PropsWithChildren<{ value: VirtualizerContextValue }>) => { return {children} } export const useVirtualizerContext = () => { const context = useContext(VirtualizerContext) if (!context) { throw new Error('useVirtualizerContext must be used within a VirtualizerProvider') } return context } type ExtractRefType = ComponentPropsWithRef extends { ref?: Ref } ? RefType : never type ExtractScrollElementFromRefComponent = Extract< ExtractRefType, Element > type ScrollWrapperComponentConstraints = ComponentPropsWithRef extends { className?: string } ? ComponentPropsWithRef extends { children?: ReactNode | ReactNode[] } ? ExtractRefType extends never ? { ERROR_WRAPPER_COMPONENT_REQUIRES_REF_SUPPORT: never } : ExtractRefType extends Element ? {} : { ERROR_WRAPPER_COMPONENT_REF_MUST_EXTEND_ELEMENT: never } : { ERROR_WRAPPER_COMPONENT_REQUIRES_CHILDREN: never } : { ERROR_WRAPPER_COMPONENT_REQUIRES_CLASSNAME: never } type InfiniteListWrapperProps = { className?: string items: Item[] getItemKey?: (index: number) => string getItemSize: (index: number) => number gap?: number hasNextPage?: boolean isLoadingNextPage?: boolean onLoadNextPage?: () => void Component?: Component } & ScrollWrapperComponentConstraints export const InfiniteListScrollWrapper = ({ children, items, getItemKey, getItemSize, gap, hasNextPage = false, isLoadingNextPage = false, onLoadNextPage = () => {}, className, Component, }: PropsWithChildren>) => { const scrollRef = useRef | null>(null) const rowVirtualizer = useVirtualizer, Element>({ count: hasNextPage ? items.length + 1 : items.length, getScrollElement: () => scrollRef.current, getItemKey, estimateSize: getItemSize, overscan: 5, gap, }) const virtualItems = rowVirtualizer.getVirtualItems() const virtualizerContextValue = useMemo( () => ({ virtualizer: rowVirtualizer as unknown as Virtualizer, virtualItems, }), [rowVirtualizer, virtualItems] ) useEffect(() => { const lastItem = virtualItems[virtualItems.length - 1] if (!lastItem) return if (lastItem.index >= items.length - 1 && hasNextPage && !isLoadingNextPage) { onLoadNextPage() } }, [virtualItems, items.length, hasNextPage, isLoadingNextPage, onLoadNextPage]) const WrapperToRender: Wrapper = Component ?? ('div' as Wrapper) const wrapperProps = { ref: (node: ExtractScrollElementFromRefComponent | null) => { scrollRef.current = node }, className: cn('overflow-auto', className), children, } as ComponentPropsWithRef return ( ) } type ComponentWithStylePropConstraint = ComponentProps extends { style?: CSSProperties } ? {} : { ERROR_SIZER_COMPONENT_MUST_TAKE_STYLE_PROP: never } type InfiniteListSizerProps = { Component?: ElementType } & ComponentWithStylePropConstraint export const InfiniteListSizer = ({ children, Component = 'div', }: PropsWithChildren) => { const { virtualizer } = useVirtualizerContext() return ( {children} ) } export type RowComponentBaseProps = { index: number item: Item style?: CSSProperties } type InfiniteListItemProps< Item, ExtraProps extends object = Record, RowComponent extends ComponentType & ExtraProps> = ComponentType< RowComponentBaseProps & ExtraProps >, > = { index: number start: number size: number item: Item itemProps?: ExtraProps ItemComponent: RowComponent } const MemoizedInfiniteListItem = typedMemo( < Item, ExtraProps extends object = Record, RowComponent extends ComponentType & ExtraProps> = ComponentType< RowComponentBaseProps & ExtraProps >, >({ index, start, size, item, itemProps, ItemComponent, }: InfiniteListItemProps) => { const styleObject = useMemo( () => createStyleObject({ size, start }), [size, start] ) const baseProps = useMemo>( () => ({ index, item, style: styleObject, }), [index, item, styleObject] ) const combinedProps = useMemo( () => ({ ...baseProps, ...(itemProps ?? ({} as ExtraProps)), }) as RowComponentBaseProps & ExtraProps, [baseProps, itemProps] ) // Not JSX to avoid type error with generic function component return createElement(ItemComponent, combinedProps) } ) MemoizedInfiniteListItem.displayName = 'MemoizedInfiniteListItem' type InfiniteListItemsProps< Item, ExtraProps extends object = Record, RowComponent extends ComponentType & ExtraProps> = ComponentType< RowComponentBaseProps & ExtraProps >, > = { items: Item[] itemProps?: ExtraProps ItemComponent: RowComponent LoaderComponent: ComponentType<{ style?: CSSProperties }> } export const InfiniteListItems = < Item, ExtraProps extends object = Record, RowComponent extends ComponentType & ExtraProps> = ComponentType< RowComponentBaseProps & ExtraProps >, >({ items, itemProps, ItemComponent, LoaderComponent, }: InfiniteListItemsProps) => { const { virtualItems } = useVirtualizerContext() return ( <> {virtualItems.map((virtualRow) => { const isLoaderRow = virtualRow.index > items.length - 1 const item = items[virtualRow.index] return isLoaderRow ? ( ) : ( // Not JSX so we can pass type arguments to the generic function component createElement(MemoizedInfiniteListItem, { key: virtualRow.index, index: virtualRow.index, start: virtualRow.start, size: virtualRow.size, item, itemProps, ItemComponent, }) ) })} ) } type InfiniteListDefaultProps> = { className?: string items: Item[] itemProps?: ItemComponentProps getItemKey?: (index: number) => string getItemSize: (index: number) => number gap?: number hasNextPage?: boolean isLoadingNextPage?: boolean onLoadNextPage?: () => void ItemComponent: ComponentType & ItemComponentProps> LoaderComponent: ComponentType<{ style?: CSSProperties }> } export const InfiniteListDefault = < Item, ItemComponentProps extends object = Record, >({ className, items, itemProps, getItemKey, getItemSize, gap, hasNextPage = false, isLoadingNextPage = false, onLoadNextPage = () => {}, ItemComponent, LoaderComponent, }: InfiniteListDefaultProps) => { return ( ) } export const LoaderForIconMenuItems = ({ style }: { style?: CSSProperties }) => (
)