InfiniteList.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual'
  2. import {
  3. ComponentPropsWithRef,
  4. ComponentType,
  5. createContext,
  6. createElement,
  7. CSSProperties,
  8. ElementType,
  9. memo,
  10. ReactNode,
  11. Ref,
  12. useContext,
  13. useEffect,
  14. useMemo,
  15. useRef,
  16. type ComponentProps,
  17. type PropsWithChildren,
  18. } from 'react'
  19. import { cn, Skeleton } from 'ui'
  20. // Regular memo erases generics, so this helper adds them back
  21. // any here is intentional to allow for generic components and does not affect
  22. // type safety of the wrapped component
  23. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  24. const typedMemo = <Component extends (props: any) => ReactNode>(
  25. component: Component,
  26. propsAreEqual?: (
  27. prevProps: Readonly<Parameters<Component>[0]>,
  28. nextProps: Readonly<Parameters<Component>[0]>
  29. ) => boolean
  30. ) => memo(component, propsAreEqual) as unknown as Component & { displayName?: string }
  31. const createStyleObject = ({ size, start }: { size: number; start: number }): CSSProperties => ({
  32. position: 'absolute',
  33. top: 0,
  34. left: 0,
  35. width: '100%',
  36. height: `${size}px`,
  37. transform: `translateY(${start}px)`,
  38. })
  39. type VirtualizerInstance = Virtualizer<Element, Element>
  40. type VirtualItems = ReturnType<VirtualizerInstance['getVirtualItems']>
  41. type VirtualizerContextValue = {
  42. virtualizer: VirtualizerInstance
  43. virtualItems: VirtualItems
  44. }
  45. const VirtualizerContext = createContext<VirtualizerContextValue | null>(null)
  46. export const VirtualizerProvider = ({
  47. children,
  48. value,
  49. }: PropsWithChildren<{ value: VirtualizerContextValue }>) => {
  50. return <VirtualizerContext.Provider value={value}>{children}</VirtualizerContext.Provider>
  51. }
  52. export const useVirtualizerContext = () => {
  53. const context = useContext(VirtualizerContext)
  54. if (!context) {
  55. throw new Error('useVirtualizerContext must be used within a VirtualizerProvider')
  56. }
  57. return context
  58. }
  59. type ExtractRefType<Elem extends ElementType> =
  60. ComponentPropsWithRef<Elem> extends { ref?: Ref<infer RefType> } ? RefType : never
  61. type ExtractScrollElementFromRefComponent<RefComponent extends ElementType> = Extract<
  62. ExtractRefType<RefComponent>,
  63. Element
  64. >
  65. type ScrollWrapperComponentConstraints<Component extends ElementType> =
  66. ComponentPropsWithRef<Component> extends { className?: string }
  67. ? ComponentPropsWithRef<Component> extends { children?: ReactNode | ReactNode[] }
  68. ? ExtractRefType<Component> extends never
  69. ? { ERROR_WRAPPER_COMPONENT_REQUIRES_REF_SUPPORT: never }
  70. : ExtractRefType<Component> extends Element
  71. ? {}
  72. : { ERROR_WRAPPER_COMPONENT_REF_MUST_EXTEND_ELEMENT: never }
  73. : { ERROR_WRAPPER_COMPONENT_REQUIRES_CHILDREN: never }
  74. : { ERROR_WRAPPER_COMPONENT_REQUIRES_CLASSNAME: never }
  75. type InfiniteListWrapperProps<Item, Component extends ElementType = 'div'> = {
  76. className?: string
  77. items: Item[]
  78. getItemKey?: (index: number) => string
  79. getItemSize: (index: number) => number
  80. gap?: number
  81. hasNextPage?: boolean
  82. isLoadingNextPage?: boolean
  83. onLoadNextPage?: () => void
  84. Component?: Component
  85. } & ScrollWrapperComponentConstraints<Component>
  86. export const InfiniteListScrollWrapper = <Item, Wrapper extends ElementType = 'div'>({
  87. children,
  88. items,
  89. getItemKey,
  90. getItemSize,
  91. gap,
  92. hasNextPage = false,
  93. isLoadingNextPage = false,
  94. onLoadNextPage = () => {},
  95. className,
  96. Component,
  97. }: PropsWithChildren<InfiniteListWrapperProps<Item, Wrapper>>) => {
  98. const scrollRef = useRef<ExtractScrollElementFromRefComponent<Wrapper> | null>(null)
  99. const rowVirtualizer = useVirtualizer<ExtractScrollElementFromRefComponent<Wrapper>, Element>({
  100. count: hasNextPage ? items.length + 1 : items.length,
  101. getScrollElement: () => scrollRef.current,
  102. getItemKey,
  103. estimateSize: getItemSize,
  104. overscan: 5,
  105. gap,
  106. })
  107. const virtualItems = rowVirtualizer.getVirtualItems()
  108. const virtualizerContextValue = useMemo(
  109. () => ({
  110. virtualizer: rowVirtualizer as unknown as Virtualizer<Element, Element>,
  111. virtualItems,
  112. }),
  113. [rowVirtualizer, virtualItems]
  114. )
  115. useEffect(() => {
  116. const lastItem = virtualItems[virtualItems.length - 1]
  117. if (!lastItem) return
  118. if (lastItem.index >= items.length - 1 && hasNextPage && !isLoadingNextPage) {
  119. onLoadNextPage()
  120. }
  121. }, [virtualItems, items.length, hasNextPage, isLoadingNextPage, onLoadNextPage])
  122. const WrapperToRender: Wrapper = Component ?? ('div' as Wrapper)
  123. const wrapperProps = {
  124. ref: (node: ExtractScrollElementFromRefComponent<Wrapper> | null) => {
  125. scrollRef.current = node
  126. },
  127. className: cn('overflow-auto', className),
  128. children,
  129. } as ComponentPropsWithRef<Wrapper>
  130. return (
  131. <VirtualizerProvider value={virtualizerContextValue}>
  132. <WrapperToRender {...wrapperProps} />
  133. </VirtualizerProvider>
  134. )
  135. }
  136. type ComponentWithStylePropConstraint<Component extends ElementType> =
  137. ComponentProps<Component> extends { style?: CSSProperties }
  138. ? {}
  139. : { ERROR_SIZER_COMPONENT_MUST_TAKE_STYLE_PROP: never }
  140. type InfiniteListSizerProps = {
  141. Component?: ElementType
  142. } & ComponentWithStylePropConstraint<ElementType>
  143. export const InfiniteListSizer = ({
  144. children,
  145. Component = 'div',
  146. }: PropsWithChildren<InfiniteListSizerProps>) => {
  147. const { virtualizer } = useVirtualizerContext()
  148. return (
  149. <Component
  150. style={{
  151. height: `${virtualizer.getTotalSize()}px`,
  152. width: '100%',
  153. position: 'relative',
  154. }}
  155. >
  156. {children}
  157. </Component>
  158. )
  159. }
  160. export type RowComponentBaseProps<Item> = {
  161. index: number
  162. item: Item
  163. style?: CSSProperties
  164. }
  165. type InfiniteListItemProps<
  166. Item,
  167. ExtraProps extends object = Record<string, never>,
  168. RowComponent extends ComponentType<RowComponentBaseProps<Item> & ExtraProps> = ComponentType<
  169. RowComponentBaseProps<Item> & ExtraProps
  170. >,
  171. > = {
  172. index: number
  173. start: number
  174. size: number
  175. item: Item
  176. itemProps?: ExtraProps
  177. ItemComponent: RowComponent
  178. }
  179. const MemoizedInfiniteListItem = typedMemo(
  180. <
  181. Item,
  182. ExtraProps extends object = Record<string, never>,
  183. RowComponent extends ComponentType<RowComponentBaseProps<Item> & ExtraProps> = ComponentType<
  184. RowComponentBaseProps<Item> & ExtraProps
  185. >,
  186. >({
  187. index,
  188. start,
  189. size,
  190. item,
  191. itemProps,
  192. ItemComponent,
  193. }: InfiniteListItemProps<Item, ExtraProps, RowComponent>) => {
  194. const styleObject = useMemo<CSSProperties>(
  195. () => createStyleObject({ size, start }),
  196. [size, start]
  197. )
  198. const baseProps = useMemo<RowComponentBaseProps<Item>>(
  199. () => ({
  200. index,
  201. item,
  202. style: styleObject,
  203. }),
  204. [index, item, styleObject]
  205. )
  206. const combinedProps = useMemo(
  207. () =>
  208. ({
  209. ...baseProps,
  210. ...(itemProps ?? ({} as ExtraProps)),
  211. }) as RowComponentBaseProps<Item> & ExtraProps,
  212. [baseProps, itemProps]
  213. )
  214. // Not JSX to avoid type error with generic function component
  215. return createElement(ItemComponent, combinedProps)
  216. }
  217. )
  218. MemoizedInfiniteListItem.displayName = 'MemoizedInfiniteListItem'
  219. type InfiniteListItemsProps<
  220. Item,
  221. ExtraProps extends object = Record<string, never>,
  222. RowComponent extends ComponentType<RowComponentBaseProps<Item> & ExtraProps> = ComponentType<
  223. RowComponentBaseProps<Item> & ExtraProps
  224. >,
  225. > = {
  226. items: Item[]
  227. itemProps?: ExtraProps
  228. ItemComponent: RowComponent
  229. LoaderComponent: ComponentType<{ style?: CSSProperties }>
  230. }
  231. export const InfiniteListItems = <
  232. Item,
  233. ExtraProps extends object = Record<string, never>,
  234. RowComponent extends ComponentType<RowComponentBaseProps<Item> & ExtraProps> = ComponentType<
  235. RowComponentBaseProps<Item> & ExtraProps
  236. >,
  237. >({
  238. items,
  239. itemProps,
  240. ItemComponent,
  241. LoaderComponent,
  242. }: InfiniteListItemsProps<Item, ExtraProps, RowComponent>) => {
  243. const { virtualItems } = useVirtualizerContext()
  244. return (
  245. <>
  246. {virtualItems.map((virtualRow) => {
  247. const isLoaderRow = virtualRow.index > items.length - 1
  248. const item = items[virtualRow.index]
  249. return isLoaderRow ? (
  250. <LoaderComponent
  251. key={virtualRow.index}
  252. style={createStyleObject({ size: virtualRow.size, start: virtualRow.start })}
  253. />
  254. ) : (
  255. // Not JSX so we can pass type arguments to the generic function component
  256. createElement(MemoizedInfiniteListItem<Item, ExtraProps>, {
  257. key: virtualRow.index,
  258. index: virtualRow.index,
  259. start: virtualRow.start,
  260. size: virtualRow.size,
  261. item,
  262. itemProps,
  263. ItemComponent,
  264. })
  265. )
  266. })}
  267. </>
  268. )
  269. }
  270. type InfiniteListDefaultProps<Item, ItemComponentProps extends object = Record<string, never>> = {
  271. className?: string
  272. items: Item[]
  273. itemProps?: ItemComponentProps
  274. getItemKey?: (index: number) => string
  275. getItemSize: (index: number) => number
  276. gap?: number
  277. hasNextPage?: boolean
  278. isLoadingNextPage?: boolean
  279. onLoadNextPage?: () => void
  280. ItemComponent: ComponentType<RowComponentBaseProps<Item> & ItemComponentProps>
  281. LoaderComponent: ComponentType<{ style?: CSSProperties }>
  282. }
  283. export const InfiniteListDefault = <
  284. Item,
  285. ItemComponentProps extends object = Record<string, never>,
  286. >({
  287. className,
  288. items,
  289. itemProps,
  290. getItemKey,
  291. getItemSize,
  292. gap,
  293. hasNextPage = false,
  294. isLoadingNextPage = false,
  295. onLoadNextPage = () => {},
  296. ItemComponent,
  297. LoaderComponent,
  298. }: InfiniteListDefaultProps<Item, ItemComponentProps>) => {
  299. return (
  300. <InfiniteListScrollWrapper
  301. className={className}
  302. items={items}
  303. getItemKey={getItemKey}
  304. getItemSize={getItemSize}
  305. gap={gap}
  306. hasNextPage={hasNextPage}
  307. isLoadingNextPage={isLoadingNextPage}
  308. onLoadNextPage={onLoadNextPage}
  309. >
  310. <InfiniteListSizer>
  311. <InfiniteListItems
  312. items={items}
  313. itemProps={itemProps}
  314. ItemComponent={ItemComponent}
  315. LoaderComponent={LoaderComponent}
  316. />
  317. </InfiniteListSizer>
  318. </InfiniteListScrollWrapper>
  319. )
  320. }
  321. export const LoaderForIconMenuItems = ({ style }: { style?: CSSProperties }) => (
  322. <div className="space-y-1 my-1" style={style}>
  323. <div className="flex flex-col gap-y-1">
  324. <div className="flex flex-row h-6 px-4 items-center gap-2">
  325. <Skeleton className="h-4 w-5" />
  326. <Skeleton className="w-40 h-4" />
  327. </div>
  328. <div className="flex flex-row h-6 px-4 items-center gap-2">
  329. <Skeleton className="h-4 w-5" />
  330. <Skeleton className="w-32 h-4" />
  331. </div>
  332. <div className="flex flex-row h-6 px-4 items-center gap-2 opacity-75">
  333. <Skeleton className="h-4 w-5" />
  334. <Skeleton className="w-20 h-4" />
  335. </div>
  336. <div className="flex flex-row h-6 px-4 items-center gap-2 opacity-50">
  337. <Skeleton className="h-4 w-5" />
  338. <Skeleton className="w-40 h-4" />
  339. </div>
  340. <div className="flex flex-row h-6 px-4 items-center gap-2 opacity-25">
  341. <Skeleton className="h-4 w-5" />
  342. <Skeleton className="w-20 h-4" />
  343. </div>
  344. </div>
  345. </div>
  346. )