diff options
Diffstat (limited to 'components/data-table/expandable-data-table.tsx')
| -rw-r--r-- | components/data-table/expandable-data-table.tsx | 421 |
1 files changed, 421 insertions, 0 deletions
diff --git a/components/data-table/expandable-data-table.tsx b/components/data-table/expandable-data-table.tsx new file mode 100644 index 00000000..9005e2fb --- /dev/null +++ b/components/data-table/expandable-data-table.tsx @@ -0,0 +1,421 @@ +"use client" + +import * as React from "react" +import { flexRender, type Table as TanstackTable } from "@tanstack/react-table" +import { ChevronRight, ChevronUp, ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" +import { getCommonPinningStyles } from "@/lib/data-table" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { DataTablePagination } from "@/components/data-table/data-table-pagination" +import { DataTableResizer } from "@/components/data-table/data-table-resizer" +import { useAutoSizeColumns } from "@/hooks/useAutoSizeColumns" + +interface ExpandableDataTableProps<TData> extends React.HTMLAttributes<HTMLDivElement> { + table: TanstackTable<TData> + floatingBar?: React.ReactNode | null + autoSizeColumns?: boolean + compact?: boolean + expandedRows?: Set<string> + setExpandedRows?: React.Dispatch<React.SetStateAction<Set<string>>> + renderExpandedContent?: (row: TData) => React.ReactNode + expandable?: boolean // 확장 기능 활성화 여부 + maxHeight?: string | number + expandedRowClassName?: string // 확장된 행의 커스텀 클래스 +} + +/** + * 확장 가능한 데이터 테이블 - 행 확장 시 바로 아래에 컨텐츠 표시 + * 개선사항: + * - 가로스크롤과 확장된 내용 독립성 보장 + * - 동적 높이 계산으로 세로스크롤 문제 해결 + * - 향상된 접근성 및 키보드 네비게이션 + */ +export function ExpandableDataTable<TData>({ + table, + floatingBar = null, + autoSizeColumns = true, + compact = false, + expandedRows = new Set(), + setExpandedRows, + renderExpandedContent, + expandable = false, + maxHeight, + expandedRowClassName, + children, + className, + ...props +}: ExpandableDataTableProps<TData>) { + + useAutoSizeColumns(table, autoSizeColumns) + + // 스크롤 컨테이너 참조 + const scrollContainerRef = React.useRef<HTMLDivElement>(null) + const [expandedHeights, setExpandedHeights] = React.useState<Map<string, number>>(new Map()) + + // 행 확장/축소 핸들러 (개선된 버전) + const toggleRowExpansion = React.useCallback((rowId: string, event?: React.MouseEvent) => { + if (!setExpandedRows) return + + if (event) { + event.stopPropagation() + } + + const newExpanded = new Set(expandedRows) + if (newExpanded.has(rowId)) { + newExpanded.delete(rowId) + // 높이 정보도 제거 + setExpandedHeights(prev => { + const newHeights = new Map(prev) + newHeights.delete(rowId) + return newHeights + }) + } else { + newExpanded.add(rowId) + } + setExpandedRows(newExpanded) + }, [expandedRows, setExpandedRows]) + + // 키보드 네비게이션 핸들러 + const handleKeyDown = React.useCallback((event: React.KeyboardEvent, rowId: string) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + toggleRowExpansion(rowId) + } + }, [toggleRowExpansion]) + + // 확장된 내용의 높이 측정 및 업데이트 + const updateExpandedHeight = React.useCallback((rowId: string, height: number) => { + setExpandedHeights(prev => { + if (prev.get(rowId) !== height) { + const newHeights = new Map(prev) + newHeights.set(rowId, height) + return newHeights + } + return prev + }) + }, []) + + // 컴팩트 모드를 위한 클래스 정의 (개선된 버전) + const compactStyles = compact ? { + row: "h-7", + cell: "py-1 px-2 text-sm", + expandedCell: "py-2 px-4", + groupRow: "py-1 bg-muted/20 text-sm", + emptyRow: "h-16", + } : { + row: "", + cell: "", + expandedCell: "py-0 px-0", // 패딩 제거하여 확장 컨텐츠가 전체 영역 사용 + groupRow: "bg-muted/20", + emptyRow: "h-24", + } + + // 확장 버튼 렌더링 함수 (접근성 개선) + const renderExpandButton = (rowId: string) => { + if (!expandable || !setExpandedRows) return null + + const isExpanded = expandedRows.has(rowId) + + return ( + <button + onClick={(e) => toggleRowExpansion(rowId, e)} + onKeyDown={(e) => handleKeyDown(e, rowId)} + className="inline-flex items-center justify-center w-6 h-6 hover:bg-gray-100 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" + aria-label={isExpanded ? "행 축소" : "행 확장"} + aria-expanded={isExpanded} + tabIndex={0} + type="button" + > + {isExpanded ? ( + <ChevronDown className="w-4 h-4" /> + ) : ( + <ChevronRight className="w-4 h-4" /> + )} + </button> + ) + } + + // 확장된 내용 래퍼 컴포넌트 (높이 측정 기능 포함) + const ExpandedContentWrapper = React.memo<{ + rowId: string + children: React.ReactNode + }>(({ rowId, children }) => { + const contentRef = React.useRef<HTMLDivElement>(null) + + React.useEffect(() => { + if (contentRef.current) { + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + updateExpandedHeight(rowId, entry.contentRect.height) + } + }) + + resizeObserver.observe(contentRef.current) + return () => resizeObserver.disconnect() + } + }, [rowId]) + + return ( + <div ref={contentRef} className="w-full"> + {children} + </div> + ) + }) + + ExpandedContentWrapper.displayName = "ExpandedContentWrapper" + + return ( + <div className={cn("w-full space-y-2.5", className)} {...props}> + {children} + + {/* 메인 테이블 컨테이너 - 가로스크롤 문제 해결 */} + <div + ref={scrollContainerRef} + className="relative rounded-md border" + style={{ + // maxHeight: maxHeight || '35rem', + minHeight: '200px' // 최소 높이 보장 + }} + > + {/* 가로스크롤 영역 */} + <div className="overflow-x-auto overflow-y-hidden h-full"> + {/* 세로스크롤 영역 (확장된 내용 포함) */} + <div className="overflow-y-auto h-full"> + <Table className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 table-fixed"> + {/* 테이블 헤더 */} + <TableHeader className="bg-background"> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id} className={compact ? "h-8" : ""}> + {/* 확장 버튼 컬럼 헤더 */} + {expandable && ( + <TableHead + className={cn("w-10 bg-background", compact ? "py-1 px-2" : "")} + style={{ position: 'sticky', left: 0, zIndex: 11 }} + /> + )} + + {headerGroup.headers.map((header) => { + if (header.column.getIsGrouped()) { + return null + } + + return ( + <TableHead + key={header.id} + colSpan={header.colSpan} + data-column-id={header.column.id} + className={cn( + compact ? "py-1 px-2 text-sm" : "", + "bg-background" + )} + style={{ + ...getCommonPinningStyles({ column: header.column }), + width: header.getSize(), + }} + > + <div style={{ position: "relative" }}> + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + {header.column.getCanResize() && ( + <DataTableResizer header={header} /> + )} + </div> + </TableHead> + ) + })} + </TableRow> + ))} + </TableHeader> + + {/* 테이블 바디 */} + <TableBody> + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => { + const isExpanded = expandedRows.has(row.id) + const expandedHeight = expandedHeights.get(row.id) || 0 + + // 그룹핑 헤더 Row + if (row.getIsGrouped()) { + const groupingColumnId = row.groupingColumnId ?? "" + const groupingColumn = table.getColumn(groupingColumnId) + + let columnLabel = groupingColumnId + if (groupingColumn) { + const headerDef = groupingColumn.columnDef.meta?.excelHeader + if (typeof headerDef === "string") { + columnLabel = headerDef + } + } + + return ( + <TableRow + key={row.id} + className={compactStyles.groupRow} + data-state={row.getIsExpanded() && "expanded"} + > + <TableCell + colSpan={table.getVisibleFlatColumns().length + (expandable ? 1 : 0)} + className={compact ? "py-1 px-2" : ""} + > + {row.getCanExpand() && ( + <button + onClick={row.getToggleExpandedHandler()} + className="inline-flex items-center justify-center mr-2 w-5 h-5 hover:bg-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + style={{ + marginLeft: `${row.depth * 1.5}rem`, + }} + aria-label={row.getIsExpanded() ? "그룹 축소" : "그룹 확장"} + > + {row.getIsExpanded() ? ( + <ChevronUp size={compact ? 14 : 16} /> + ) : ( + <ChevronRight size={compact ? 14 : 16} /> + )} + </button> + )} + + <span className="font-semibold"> + {columnLabel}: {row.getValue(groupingColumnId)} + </span> + <span className="ml-2 text-xs text-muted-foreground"> + ({row.subRows.length} rows) + </span> + </TableCell> + </TableRow> + ) + } + + // 일반 Row와 확장된 컨텐츠를 함께 렌더링 + return ( + <React.Fragment key={row.id}> + {/* 메인 데이터 행 */} + <TableRow + className={cn( + compactStyles.row, + isExpanded && "bg-muted/30 border-b-0" + )} + data-state={row.getIsSelected() && "selected"} + > + {/* 확장 버튼 셀 */} + {expandable && ( + <TableCell + className={cn("w-10", compactStyles.cell)} + style={{ + position: 'sticky', + left: 0, + zIndex: 1, + backgroundColor: isExpanded ? 'rgb(248 250 252)' : 'white' + }} + > + {renderExpandButton(row.id)} + </TableCell> + )} + + {/* 데이터 셀들 */} + {row.getVisibleCells().map((cell) => { + if (cell.column.getIsGrouped()) { + return null + } + + return ( + <TableCell + key={cell.id} + data-column-id={cell.column.id} + className={compactStyles.cell} + style={{ + ...getCommonPinningStyles({ column: cell.column }), + width: cell.column.getSize(), + }} + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + </TableCell> + ) + })} + </TableRow> + + {/* 확장된 컨텐츠 행 - 가로스크롤 독립성 보장 */} + {isExpanded && renderExpandedContent && ( + <TableRow className="hover:bg-transparent"> + <TableCell + colSpan={table.getVisibleFlatColumns().length + (expandable ? 1 : 0)} + className={cn( + compactStyles.expandedCell, + "border-t-0 relative", + expandedRowClassName + )} + style={{ + minHeight: expandedHeight || 'auto', + }} + > + {/* 확장된 내용을 위한 고정 폭 컨테이너 */} + <div className="relative w-full"> + {/* 가로스크롤과 독립적인 확장 영역 */} + <div + className="absolute left-0 right-0 top-0 border-t" + style={{ + width: '80vw', + marginLeft: 'calc(-48vw + 50%)', + }} + > + <div className="max-w-none mx-auto"> + <ExpandedContentWrapper rowId={row.id}> + {renderExpandedContent(row.original)} + </ExpandedContentWrapper> + </div> + </div> + + {/* 높이 유지를 위한 스페이서 */} + <div + className="opacity-0 pointer-events-none" + style={{ height: Math.max(expandedHeight, 200) }} + /> + </div> + </TableCell> + </TableRow> + )} + </React.Fragment> + ) + }) + ) : ( + // 데이터가 없을 때 + <TableRow> + <TableCell + colSpan={table.getAllColumns().length + (expandable ? 1 : 0)} + className={compactStyles.emptyRow + " text-center"} + > + No results. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + </div> + </div> + + <div className="flex flex-col gap-2.5"> + {/* Pagination */} + <DataTablePagination table={table} /> + + {/* Floating Bar (선택된 행 있을 때) */} + {table.getFilteredSelectedRowModel().rows.length > 0 && floatingBar} + </div> + </div> + ) +}
\ No newline at end of file |
