summaryrefslogtreecommitdiff
path: root/components/data-table
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 00:18:16 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-05-28 00:18:16 +0000
commit748bb1720fd81e97a84c3e92f89d606e976b52e3 (patch)
treea7f7f377035cd04912fe0541368884f976f4ee6d /components/data-table
parent9e280704988fdeffa05c1d8cbb731722f666c6af (diff)
(대표님) 컴포넌트 파트 커밋
Diffstat (limited to 'components/data-table')
-rw-r--r--components/data-table/expandable-data-table.tsx421
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