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