11"use client" ;
22
3- import type { ReactNode } from "react" ;
3+ import { memo , useMemo , useRef } from "react" ;
44import type { DataTableProps , DataTableColumn , DataTableShellCol } from "./dataTable.type" ;
5- import { TH_BASE_CLASS , TD_CELL_WRAPPER_BASE } from "./dataTableConstants" ;
5+ import { TH_BASE_CLASS , VIRTUAL_ROW_HEIGHT } from "./dataTableConstants" ;
66import DataTableShell from "./DataTableShell" ;
7+ import VirtualRow from "./VirtualRow" ;
78import { cn } from "@/utils/style" ;
89import ArrowUpIcon from "@/assets/icons/components/arrow-up.svg" ;
910import ArrowDownIcon from "@/assets/icons/components/arrow-down.svg" ;
11+ import { useVirtualizer } from "@tanstack/react-virtual" ;
1012
1113// --- 정렬 아이콘 ---
12- const SortIcon = ( { order } : { order : "asc" | "desc" } ) => {
14+ const SortIcon = memo ( function SortIcon ( { order } : { order : "asc" | "desc" } ) {
1315 const Icon = order === "asc" ? ArrowUpIcon : ArrowDownIcon ;
1416
1517 return (
@@ -20,7 +22,7 @@ const SortIcon = ({ order }: { order: "asc" | "desc" }) => {
2022 < Icon aria-hidden = "true" />
2123 </ span >
2224 ) ;
23- } ;
25+ } ) ;
2426
2527// --- 헤더 셀 렌더 ---
2628const renderHeaderCell = < T , > (
@@ -68,45 +70,6 @@ const renderHeaderCell = <T,>(
6870 ) ;
6971} ;
7072
71- // --- 바디 셀 렌더 ---
72- const renderBodyCell = < T , > (
73- col : DataTableColumn < T > ,
74- row : T ,
75- rowIndex : number ,
76- selectedCell : DataTableProps < T > [ "selectedCell" ] ,
77- mode : "read" | "edit" ,
78- onCellClick : DataTableProps < T > [ "onCellClick" ] ,
79- renderCell : ( col : DataTableColumn < T > , row : T , rowIndex : number ) => ReactNode ,
80- ) => {
81- const isSelected = selectedCell ?. rowIndex === rowIndex && selectedCell ?. accessor === col . accessor ;
82- const isLifted = mode === "edit" && isSelected ;
83-
84- return (
85- < td
86- key = { `${ rowIndex } -${ String ( col . accessor ) } ` }
87- className = { cn (
88- "h-[48px] align-top overflow-visible bg-transparent p-0 border-b border-gray-50" ,
89- isLifted ? "relative z-10" : "" ,
90- ) }
91- style = { { width : col . width } }
92- data-row-index = { rowIndex }
93- data-accessor = { String ( col . accessor ) }
94- data-cell-id = { `${ rowIndex } -${ String ( col . accessor ) } ` }
95- onClick = { onCellClick ? ( ) => onCellClick ( rowIndex , col . accessor ) : undefined }
96- >
97- < div
98- className = { cn (
99- TD_CELL_WRAPPER_BASE ,
100- isSelected ? "ring-2 ring-yellow-300 ring-inset" : "" ,
101- isLifted ? "-translate-y-0.5 shadow-sm ring-2 ring-yellow-300 ring-inset" : "" ,
102- ) }
103- >
104- { renderCell ( col , row , rowIndex ) }
105- </ div >
106- </ td >
107- ) ;
108- } ;
109-
11073/**
11174 * 정렬·셀 클릭·키보드 네비게이션을 지원하는 Client 전용 DataTable
11275 */
@@ -122,52 +85,89 @@ const ClientDataTable = <T,>({
12285 onCellClick,
12386 onKeyDown,
12487 bottomSlot,
125- scrollContainerRef,
88+ scrollContainerRef : externalRef ,
12689 ...rest
12790} : DataTableProps < T > ) => {
91+ "use no memo" ;
92+
12893 const hasCellInteraction = mode === "edit" && ( onCellClick ?? onKeyDown ) ;
12994
130- const renderCell = ( col : DataTableColumn < T > , row : T , rowIndex : number ) => {
131- return col . render ? (
132- col . render ( row [ col . accessor ] , row , rowIndex )
133- ) : mode === "edit" && col . editor ? (
134- col . editor ( row [ col . accessor ] , row , rowIndex )
135- ) : (
136- < span >
137- { row [ col . accessor ] == null || row [ col . accessor ] === "" ? "-" : String ( row [ col . accessor ] ) }
138- </ span >
139- ) ;
140- } ;
141-
142- const colgroupColumns : DataTableShellCol [ ] = columns . map ( ( c ) => ( {
143- accessor : String ( c . accessor ) ,
144- width : c . width ,
145- } ) ) ;
95+ // 외부 ref가 없을 경우 내부 fallback ref 사용 (읽기 전용 테이블에서 사용)
96+ const internalRef = useRef < HTMLDivElement > ( null ) ;
97+ const scrollRef = externalRef ?? internalRef ;
98+
99+ const virtualizer = useVirtualizer ( {
100+ count : data . length ,
101+ getScrollElement : ( ) => scrollRef . current ,
102+ estimateSize : ( ) => VIRTUAL_ROW_HEIGHT ,
103+ overscan : 5 ,
104+ } ) ;
105+
106+ const virtualItems = virtualizer . getVirtualItems ( ) ;
107+
108+ const colgroupColumns = useMemo < DataTableShellCol [ ] > (
109+ ( ) => columns . map ( ( c ) => ( { accessor : String ( c . accessor ) , width : c . width } ) ) ,
110+ [ columns ] ,
111+ ) ;
112+
113+ const headerSlot = useMemo (
114+ ( ) => (
115+ < thead className = "bg-gray-50" >
116+ < tr > { columns . map ( ( col ) => renderHeaderCell ( col , sortConfig , onSort ) ) } </ tr >
117+ </ thead >
118+ ) ,
119+ [ columns , sortConfig , onSort ] ,
120+ ) ;
146121
147122 return (
148123 < DataTableShell
149124 className = { className }
150125 columns = { colgroupColumns }
151126 tabIndex = { hasCellInteraction ? 0 : undefined }
152127 onKeyDown = { hasCellInteraction ? onKeyDown : undefined }
128+ headerSlot = { headerSlot }
153129 bottomSlot = { bottomSlot }
154- scrollContainerRef = { scrollContainerRef }
130+ scrollContainerRef = { scrollRef }
155131 { ...rest }
156132 >
157- < thead className = "bg-gray-50 sticky top-0 z-10" >
158- < tr > { columns . map ( ( col ) => renderHeaderCell ( col , sortConfig , onSort ) ) } </ tr >
159- </ thead >
160133 < tbody >
161- { data . map ( ( row , rowIndex ) => (
134+ { /* 위쪽 spacer */ }
135+ { virtualItems . length > 0 && virtualItems [ 0 ] && (
136+ < tr style = { { height : `${ virtualItems [ 0 ] . start } px` } } aria-hidden = "true" >
137+ < td colSpan = { columns . length } />
138+ </ tr >
139+ ) }
140+
141+ { virtualItems . map ( ( virtualRow ) => {
142+ const row = data [ virtualRow . index ] ;
143+ if ( ! row ) return null ;
144+
145+ return (
146+ < VirtualRow
147+ key = { rowKey ? rowKey ( row , virtualRow . index ) : virtualRow . index }
148+ virtualIndex = { virtualRow . index }
149+ row = { row }
150+ columns = { columns }
151+ selectedAccessor = {
152+ selectedCell ?. rowIndex === virtualRow . index ? selectedCell . accessor : null
153+ }
154+ mode = { mode }
155+ onCellClick = { onCellClick }
156+ />
157+ ) ;
158+ } ) }
159+
160+ { /* 아래쪽 spacer */ }
161+ { virtualItems . length > 0 && virtualItems [ virtualItems . length - 1 ] && (
162162 < tr
163- key = { rowKey ? rowKey ( row , rowIndex ) : rowIndex }
164- className = "border-b border-gray-50 last:border-b-0"
163+ style = { {
164+ height : `${ virtualizer . getTotalSize ( ) - ( virtualItems [ virtualItems . length - 1 ] ?. end ?? 0 ) } px` ,
165+ } }
166+ aria-hidden = "true"
165167 >
166- { columns . map ( ( col ) =>
167- renderBodyCell ( col , row , rowIndex , selectedCell , mode , onCellClick , renderCell ) ,
168- ) }
168+ < td colSpan = { columns . length } />
169169 </ tr >
170- ) ) }
170+ ) }
171171 </ tbody >
172172 </ DataTableShell >
173173 ) ;
0 commit comments