VirtualizedTable.tsx 6.9 KB

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