diff options
| author | joonhoekim <26rote@gmail.com> | 2025-12-08 12:08:00 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-12-08 12:08:00 +0900 |
| commit | 71c0ba1f01b98770ec2c60cdb935ffb36c1830a9 (patch) | |
| tree | 01b5dab4888f865d605c46d41896a2432e53015f /components/client-table-v3/client-virtual-table.tsx | |
| parent | 298e166eebeba3276403f14f9b9b1e3f8f3d735e (diff) | |
(김준회) 테이블 커스텀 훅 버전 생성
Diffstat (limited to 'components/client-table-v3/client-virtual-table.tsx')
| -rw-r--r-- | components/client-table-v3/client-virtual-table.tsx | 309 |
1 files changed, 309 insertions, 0 deletions
diff --git a/components/client-table-v3/client-virtual-table.tsx b/components/client-table-v3/client-virtual-table.tsx new file mode 100644 index 00000000..7a092326 --- /dev/null +++ b/components/client-table-v3/client-virtual-table.tsx @@ -0,0 +1,309 @@ +"use client"; + +import * as React from "react"; +import { Table, flexRender } from "@tanstack/react-table"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + horizontalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { cn } from "@/lib/utils"; +import { Loader2, ChevronRight, ChevronDown } from "lucide-react"; + +import { ClientTableToolbar } from "../client-table/client-table-toolbar"; +import { exportToExcel } from "../client-table/export-utils"; +import { ClientDataTablePagination } from "@/components/client-data-table/data-table-pagination"; +import { ClientTableViewOptions } from "../client-table/client-table-view-options"; + +import { ClientTableColumnHeader } from "./client-table-column-header"; +import { ClientTablePreset } from "./client-table-preset"; +import { ClientVirtualTableProps } from "./types"; + +export function ClientVirtualTable<TData>({ + table, + isLoading = false, + height = "100%", + estimateRowHeight = 40, + className, + actions, + customToolbar, + enableExport = true, + onExport, + enableUserPreset = false, + tableKey, + getRowClassName, + onRowClick, + renderHeaderVisualFeedback, +}: ClientVirtualTableProps<TData>) { + // --- DnD Sensors --- + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor) + ); + + // --- Drag Handler --- + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (active && over && active.id !== over.id) { + const activeId = active.id as string; + const overId = over.id as string; + + const activeColumn = table.getColumn(activeId); + const overColumn = table.getColumn(overId); + + if (activeColumn && overColumn) { + const activePinState = activeColumn.getIsPinned(); + const overPinState = overColumn.getIsPinned(); + + // If dragging between different pin states, update the pin state + if (activePinState !== overPinState) { + activeColumn.pin(overPinState); + } + + // Reorder + const currentOrder = table.getState().columnOrder; + const oldIndex = currentOrder.indexOf(activeId); + const newIndex = currentOrder.indexOf(overId); + + if (oldIndex !== -1 && newIndex !== -1) { + table.setColumnOrder(arrayMove(currentOrder, oldIndex, newIndex)); + } + } + } + }; + + // --- Virtualization --- + const tableContainerRef = React.useRef<HTMLDivElement>(null); + const { rows } = table.getRowModel(); + + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => tableContainerRef.current, + estimateSize: () => estimateRowHeight, + overscan: 10, + }); + + const virtualRows = rowVirtualizer.getVirtualItems(); + const totalSize = rowVirtualizer.getTotalSize(); + + const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0; + const paddingBottom = + virtualRows.length > 0 + ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) + : 0; + + // --- Export --- + const handleExport = async () => { + if (onExport) { + onExport(table.getFilteredRowModel().rows.map((r) => r.original)); + return; + } + const currentData = table.getFilteredRowModel().rows.map((row) => row.original); + // Note: exportToExcel needs columns definition. table.getAllColumns() or visible columns? + // Using table.getAllLeafColumns() usually. + await exportToExcel(currentData, table.getAllLeafColumns(), `export-${new Date().toISOString().slice(0, 10)}.xlsx`); + }; + + const columns = table.getVisibleLeafColumns(); + const data = table.getFilteredRowModel().rows; // or just rows which is from getRowModel + + return ( + <div + className={cn("flex flex-col gap-4", className)} + style={{ height }} + > + <ClientTableToolbar + globalFilter={table.getState().globalFilter ?? ""} + setGlobalFilter={table.setGlobalFilter} + totalRows={table.getRowCount()} + visibleRows={table.getRowModel().rows.length} + onExport={enableExport ? handleExport : undefined} + viewOptions={ + <> + <ClientTableViewOptions table={table} /> + {enableUserPreset && tableKey && ( + <ClientTablePreset table={table} tableKey={tableKey} /> + )} + </> + } + customToolbar={customToolbar} + actions={actions} + /> + + <div + ref={tableContainerRef} + className="relative border rounded-md overflow-auto bg-background flex-1 min-h-0" + > + {isLoading && ( + <div className="absolute inset-0 z-50 flex items-center justify-center bg-background/50 backdrop-blur-sm"> + <Loader2 className="h-8 w-8 animate-spin text-primary" /> + </div> + )} + + <DndContext + sensors={sensors} + collisionDetection={closestCenter} + onDragEnd={handleDragEnd} + > + <table + className="table-fixed border-collapse w-full min-w-full" + style={{ width: table.getTotalSize() }} + > + <thead className="sticky top-0 z-40 bg-muted"> + {table.getHeaderGroups().map((headerGroup) => ( + <tr key={headerGroup.id}> + <SortableContext + items={headerGroup.headers.map((h) => h.id)} + strategy={horizontalListSortingStrategy} + > + {headerGroup.headers.map((header) => ( + <ClientTableColumnHeader + key={header.id} + header={header} + enableReordering={true} + renderHeaderVisualFeedback={renderHeaderVisualFeedback} + /> + ))} + </SortableContext> + </tr> + ))} + </thead> + <tbody> + {paddingTop > 0 && ( + <tr> + <td style={{ height: `${paddingTop}px` }} /> + </tr> + )} + {virtualRows.length === 0 && !isLoading ? ( + <tr> + <td colSpan={columns.length} className="h-24 text-center"> + No results. + </td> + </tr> + ) : ( + virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index]; + + // --- Group Header Rendering --- + if (row.getIsGrouped()) { + const groupingColumnId = row.groupingColumnId ?? ""; + const groupingValue = row.getGroupingValue(groupingColumnId); + + return ( + <tr + key={row.id} + className="hover:bg-muted/50 border-b bg-muted/30" + style={{ height: `${virtualRow.size}px` }} + > + <td + colSpan={columns.length} + className="px-4 py-2 text-left font-medium cursor-pointer" + onClick={row.getToggleExpandedHandler()} + > + <div className="flex items-center gap-2"> + {row.getIsExpanded() ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + )} + <span className="flex items-center gap-2"> + <span className="font-bold capitalize"> + {groupingColumnId}: + </span> + <span> + {String(groupingValue)} + </span> + <span className="text-muted-foreground text-sm font-normal"> + ({row.subRows.length}) + </span> + </span> + </div> + </td> + </tr> + ); + } + + // --- Normal Row Rendering --- + return ( + <tr + key={row.id} + className={cn( + "hover:bg-muted/50 border-b last:border-0", + getRowClassName ? getRowClassName(row.original, row.index) : "", + onRowClick ? "cursor-pointer" : "" + )} + style={{ height: `${virtualRow.size}px` }} + onClick={(e) => onRowClick?.(row, e)} + > + {row.getVisibleCells().map((cell) => { + const isPinned = cell.column.getIsPinned(); + const isGrouped = cell.column.getIsGrouped(); + + const style: React.CSSProperties = { + width: cell.column.getSize(), + }; + if (isPinned === "left") { + style.position = "sticky"; + style.left = `${cell.column.getStart("left")}px`; + style.zIndex = 20; + } else if (isPinned === "right") { + style.position = "sticky"; + style.right = `${cell.column.getAfter("right")}px`; + style.zIndex = 20; + } + + return ( + <td + key={cell.id} + className={cn( + "px-2 py-0 text-sm truncate border-b bg-background", + isGrouped ? "bg-muted/20" : "" + )} + style={style} + > + {cell.getIsGrouped() ? null : cell.getIsAggregated() ? ( + flexRender( + cell.column.columnDef.aggregatedCell ?? + cell.column.columnDef.cell, + cell.getContext() + ) + ) : cell.getIsPlaceholder() ? null : ( + flexRender(cell.column.columnDef.cell, cell.getContext()) + )} + </td> + ); + })} + </tr> + ); + }) + )} + {paddingBottom > 0 && ( + <tr> + <td style={{ height: `${paddingBottom}px` }} /> + </tr> + )} + </tbody> + </table> + </DndContext> + </div> + + <ClientDataTablePagination table={table} /> + </div> + ); +} + + |
