VirtualizedTable.tsx242 lines · main
1import type { VirtualItem, Virtualizer } from '@tanstack/react-virtual'
2import { useVirtualizer } from '@tanstack/react-virtual'
3import { mergeRefs } from 'common'
4import type { HTMLAttributes, ReactElement, ReactNode, Ref } from 'react'
5import {
6 cloneElement,
7 createContext,
8 forwardRef,
9 isValidElement,
10 useCallback,
11 useContext,
12 useMemo,
13 useRef,
14} from 'react'
15import {
16 cn,
17 Table,
18 TableBody,
19 TableCaption,
20 TableCell,
21 TableFooter,
22 TableHead,
23 TableHeader,
24 TableRow,
25} from 'ui'
26
27type TableComponentProps = React.ComponentProps<typeof Table>
28
29interface VirtualizedTableProps<TItem> extends TableComponentProps {
30 scrollContainerProps?: HTMLAttributes<HTMLDivElement>
31 scrollContainerRef: React.Ref<HTMLDivElement>
32 data: TItem[]
33 children: ReactNode
34 overscan?: number
35 estimateSize: (index: number) => number
36 getItemKey?: (item: TItem, index: number) => string
37}
38
39type VirtualizedTableContextValue<TItem> = {
40 virtualizer: Virtualizer<HTMLDivElement, HTMLTableRowElement>
41 virtualItems: VirtualItem[]
42 data: TItem[]
43 paddingTop: number
44 paddingBottom: number
45 getRowKey: (item: TItem, index: number) => string | number
46}
47
48const VirtualizedTableContext = createContext<VirtualizedTableContextValue<unknown> | null>(null)
49
50const useVirtualizedTableContext = <TItem,>() => {
51 const context = useContext(VirtualizedTableContext)
52 if (!context) {
53 throw new Error('VirtualizedTable components must be used within a VirtualizedTable')
54 }
55 return context as VirtualizedTableContextValue<TItem>
56}
57
58export const VirtualizedTable = <TItem,>({
59 scrollContainerProps,
60 scrollContainerRef: externalScrollContainerRef,
61 containerProps,
62 data,
63 children,
64 overscan = 5,
65 estimateSize,
66 getItemKey,
67 ...tableProps
68}: VirtualizedTableProps<TItem>) => {
69 const scrollContainerRef = useRef<HTMLDivElement>(null)
70 const scrollContainerMergedRef = mergeRefs(scrollContainerRef, externalScrollContainerRef)
71
72 const rowKeyGetter = useCallback(
73 (item: TItem, index: number) => {
74 return getItemKey ? getItemKey(item, index) : index
75 },
76 [getItemKey]
77 )
78
79 const getItemKeyFromIndex = useCallback(
80 (index: number) => {
81 const item = data[index]
82 return item ? rowKeyGetter(item, index) : index
83 },
84 [data, rowKeyGetter]
85 )
86
87 const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
88 count: data.length,
89 getScrollElement: () => scrollContainerRef.current,
90 overscan,
91 estimateSize,
92 getItemKey: getItemKeyFromIndex,
93 })
94
95 const virtualItems = virtualizer.getVirtualItems()
96 const totalSize = virtualizer.getTotalSize()
97
98 const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0
99 const paddingBottom =
100 virtualItems.length > 0 ? totalSize - virtualItems[virtualItems.length - 1].end : 0
101
102 const contextValue = useMemo<VirtualizedTableContextValue<TItem>>(
103 () => ({
104 virtualizer,
105 virtualItems,
106 data,
107 paddingTop,
108 paddingBottom,
109 getRowKey: rowKeyGetter,
110 }),
111 [virtualizer, virtualItems, data, paddingTop, paddingBottom, rowKeyGetter]
112 )
113
114 const mergedContainerProps = useMemo(
115 () => ({
116 ...containerProps,
117 className: cn('overflow-visible', containerProps?.className),
118 }),
119 [containerProps]
120 )
121
122 const { className: scrollClassName, ...restScrollContainerProps } = scrollContainerProps ?? {}
123
124 return (
125 <div
126 ref={scrollContainerMergedRef}
127 className={cn('h-full overflow-auto', scrollClassName)}
128 {...restScrollContainerProps}
129 >
130 <VirtualizedTableContext.Provider
131 value={contextValue as VirtualizedTableContextValue<unknown>}
132 >
133 <Table containerProps={mergedContainerProps} {...tableProps}>
134 {children}
135 </Table>
136 </VirtualizedTableContext.Provider>
137 </div>
138 )
139}
140
141interface VirtualizedTableBodyProps<TItem> extends Omit<
142 React.ComponentProps<typeof TableBody>,
143 'children'
144> {
145 emptyContent?: ReactNode
146 leadingContent?: ReactNode
147 trailingContent?: ReactNode
148 children: (item: TItem, index: number) => ReactElement
149 paddingColSpan?: number
150 paddingCellClassName?: string
151}
152
153export const VirtualizedTableBody = <TItem,>({
154 emptyContent,
155 leadingContent,
156 trailingContent,
157 children,
158 paddingColSpan = 1,
159 paddingCellClassName,
160 ...props
161}: VirtualizedTableBodyProps<TItem>) => {
162 const { virtualizer, virtualItems, data, paddingTop, paddingBottom, getRowKey } =
163 useVirtualizedTableContext<TItem>()
164
165 const measurementRef = virtualizer.measureElement as unknown as Ref<HTMLTableRowElement>
166
167 return (
168 <TableBody {...props}>
169 {leadingContent}
170 {data.length === 0 ? (
171 (emptyContent ?? null)
172 ) : (
173 <>
174 {paddingTop > 0 && (
175 <TableRow aria-hidden="true" style={{ height: paddingTop }}>
176 <VirtualizedTableCell
177 colSpan={paddingColSpan}
178 className={cn('p-0', paddingCellClassName)}
179 />
180 </TableRow>
181 )}
182 {virtualItems.map((virtualItem) => {
183 const item = data[virtualItem.index]
184 if (item === undefined) return null
185
186 const renderedRow = children(item, virtualItem.index)
187 if (
188 !isValidElement<
189 Record<string, unknown> & {
190 ref?: Ref<HTMLTableRowElement> | null
191 ['data-index']?: number
192 }
193 >(renderedRow)
194 ) {
195 return renderedRow
196 }
197
198 const key = renderedRow.key ?? getRowKey(item, virtualItem.index)
199
200 const existingRef = (
201 renderedRow as unknown as { ref?: Ref<HTMLTableRowElement> | null }
202 ).ref
203 const combinedRef =
204 existingRef != null
205 ? mergeRefs<HTMLTableRowElement>(measurementRef, existingRef)
206 : measurementRef
207
208 return cloneElement(renderedRow, {
209 key,
210 ref: combinedRef,
211 'data-index': virtualItem.index,
212 })
213 })}
214 {paddingBottom > 0 && (
215 <TableRow aria-hidden="true" style={{ height: paddingBottom }}>
216 <VirtualizedTableCell
217 colSpan={paddingColSpan}
218 className={cn('p-0', paddingCellClassName)}
219 />
220 </TableRow>
221 )}
222 </>
223 )}
224 {trailingContent}
225 </TableBody>
226 )
227}
228
229export const VirtualizedTableHeader = TableHeader
230
231export const VirtualizedTableHead = forwardRef<
232 HTMLTableCellElement,
233 React.ComponentProps<typeof TableHead>
234>(({ className, ...props }, ref) => {
235 return <TableHead ref={ref} className={cn('sticky top-0 z-10 bg-200', className)} {...props} />
236})
237VirtualizedTableHead.displayName = 'VirtualizedTableHead'
238
239export const VirtualizedTableRow = TableRow
240export const VirtualizedTableCell = TableCell
241export const VirtualizedTableFooter = TableFooter
242export const VirtualizedTableCaption = TableCaption