diff options
Diffstat (limited to 'components/data-table')
| -rw-r--r-- | components/data-table/data-table-pagination.tsx | 219 | ||||
| -rw-r--r-- | components/data-table/data-table-pin-right.tsx | 2 | ||||
| -rw-r--r-- | components/data-table/data-table.tsx | 6 | ||||
| -rw-r--r-- | components/data-table/expandable-data-table.tsx | 707 | ||||
| -rw-r--r-- | components/data-table/infinite-data-table.tsx | 69 | ||||
| -rw-r--r-- | components/data-table/view-mode-toggle.tsx | 91 |
6 files changed, 711 insertions, 383 deletions
diff --git a/components/data-table/data-table-pagination.tsx b/components/data-table/data-table-pagination.tsx index 922dacf1..4ed63a1b 100644 --- a/components/data-table/data-table-pagination.tsx +++ b/components/data-table/data-table-pagination.tsx @@ -7,7 +7,6 @@ import { ChevronRight, ChevronsLeft, ChevronsRight, - Infinity, } from "lucide-react" import { Button } from "@/components/ui/button" @@ -22,99 +21,57 @@ import { interface DataTablePaginationProps<TData> { table: Table<TData> pageSizeOptions?: Array<number | "All"> - // 무한 스크롤 관련 props - infiniteScroll?: { - enabled: boolean - hasNextPage: boolean - isLoadingMore: boolean - totalCount?: number | null - onLoadMore?: () => void - } - // 페이지 크기 변경 콜백 (필수!) - onPageSizeChange?: (pageSize: number) => void } export function DataTablePagination<TData>({ table, pageSizeOptions = [10, 20, 30, 40, 50, "All"], - infiniteScroll, - onPageSizeChange, }: DataTablePaginationProps<TData>) { // 현재 테이블 pageSize const currentPageSize = table.getState().pagination.pageSize - const isInfiniteMode = infiniteScroll?.enabled || currentPageSize >= 1_000_000 - // "All"을 1,000,000으로 처리하고, 무한 스크롤 모드 표시 - const selectValue = isInfiniteMode ? "All" : String(currentPageSize) - - const handlePageSizeChange = (value: string) => { - if (!onPageSizeChange) { - console.warn('DataTablePagination: onPageSizeChange prop is required for page size changes to work') - return - } - - if (value === "All") { - // "All" 선택 시 무한 스크롤 모드로 전환 - onPageSizeChange(1_000_000) // URL 상태 업데이트만 수행 - } else { - const newSize = Number(value) - onPageSizeChange(newSize) // URL 상태 업데이트만 수행 - } - - // table.setPageSize()는 호출하지 않음! - // URL 상태 변경이 테이블 상태로 자동 반영됨 - } + // "All"을 1,000,000으로 처리할 것이므로, + // 만약 현재 pageSize가 1,000,000이면 화면상 "All"로 표시 + const selectValue = + currentPageSize === 1_000_000 + ? "All" + : String(currentPageSize) return ( <div className="flex w-full flex-col-reverse items-center justify-between gap-4 overflow-auto p-1 sm:flex-row sm:gap-8"> - {/* 선택된 행 및 총 개수 정보 */} <div className="flex-1 whitespace-nowrap text-sm text-muted-foreground"> {table.getFilteredSelectedRowModel().rows.length} of{" "} - {isInfiniteMode ? ( - // 무한 스크롤 모드일 때 - <> - {table.getRowModel().rows.length} row(s) selected. - {infiniteScroll?.totalCount !== null && ( - <span className="ml-4"> - Total: {infiniteScroll.totalCount?.toLocaleString()} records - <span className="ml-2 text-xs"> - ({table.getRowModel().rows.length.toLocaleString()} loaded) - </span> - </span> - )} - </> - ) : ( - // 페이지네이션 모드일 때 - <> - {table.getFilteredRowModel().rows.length} row(s) selected. - <span className="ml-4">Total: {table.getRowCount()} records</span> - </> - )} + {table.getFilteredRowModel().rows.length} row(s) selected. + <span className="ml-4">Total: {table.getRowCount()} records</span> </div> - <div className="flex flex-col-reverse items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8"> {/* Rows per page Select */} <div className="flex items-center space-x-2"> - <p className="whitespace-nowrap text-sm font-medium"> - {isInfiniteMode ? "View mode" : "Rows per page"} - </p> - <Select value={selectValue} onValueChange={handlePageSizeChange}> + <p className="whitespace-nowrap text-sm font-medium">Rows per page</p> + <Select + value={selectValue} + onValueChange={(value) => { + if (value === "All") { + // "All"을 1,000,000으로 치환 + table.setPageSize(1_000_000) + } else { + table.setPageSize(Number(value)) + } + }} + > <SelectTrigger className="h-8 w-[4.5rem]"> <SelectValue placeholder={selectValue} /> </SelectTrigger> <SelectContent side="top"> {pageSizeOptions.map((option) => { + // 화면에 표시할 라벨 const label = option === "All" ? "All" : String(option) + // value도 문자열화 const val = option === "All" ? "All" : String(option) return ( <SelectItem key={val} value={val}> - <div className="flex items-center space-x-2"> - {option === "All" && ( - <Infinity className="h-3 w-3 text-muted-foreground" /> - )} - <span>{label}</span> - </div> + {label} </SelectItem> ) })} @@ -122,90 +79,54 @@ export function DataTablePagination<TData>({ </Select> </div> - {/* 페이지네이션 모드일 때만 페이지 정보 표시 */} - {!isInfiniteMode && ( - <> - {/* 현재 페이지 / 전체 페이지 */} - <div className="flex items-center justify-center text-sm font-medium"> - Page {table.getState().pagination.pageIndex + 1} of{" "} - {table.getPageCount()} - </div> - - {/* 페이지 이동 버튼 */} - <div className="flex items-center space-x-2"> - <Button - aria-label="Go to first page" - variant="outline" - className="hidden size-8 p-0 lg:flex" - onClick={() => table.setPageIndex(0)} - disabled={!table.getCanPreviousPage()} - > - <ChevronsLeft className="size-4" aria-hidden="true" /> - </Button> - <Button - aria-label="Go to previous page" - variant="outline" - size="icon" - className="size-8" - onClick={() => table.previousPage()} - disabled={!table.getCanPreviousPage()} - > - <ChevronLeft className="size-4" aria-hidden="true" /> - </Button> - <Button - aria-label="Go to next page" - variant="outline" - size="icon" - className="size-8" - onClick={() => table.nextPage()} - disabled={!table.getCanNextPage()} - > - <ChevronRight className="size-4" aria-hidden="true" /> - </Button> - <Button - aria-label="Go to last page" - variant="outline" - size="icon" - className="hidden size-8 lg:flex" - onClick={() => table.setPageIndex(table.getPageCount() - 1)} - disabled={!table.getCanNextPage()} - > - <ChevronsRight className="size-4" aria-hidden="true" /> - </Button> - </div> - </> - )} + {/* 현재 페이지 / 전체 페이지 */} + <div className="flex items-center justify-center text-sm font-medium"> + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + </div> - {/* 무한 스크롤 모드일 때 로드 더 버튼 */} - {isInfiniteMode && infiniteScroll && ( - <div className="flex items-center space-x-2"> - {infiniteScroll.hasNextPage && ( - <Button - variant="outline" - size="sm" - onClick={infiniteScroll.onLoadMore} - disabled={infiniteScroll.isLoadingMore} - > - {infiniteScroll.isLoadingMore ? ( - <> - <div className="mr-2 h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" /> - Loading... - </> - ) : ( - <> - <ChevronRight className="mr-2 h-3 w-3" /> - Load More - </> - )} - </Button> - )} - {!infiniteScroll.hasNextPage && table.getRowModel().rows.length > 0 && ( - <span className="text-xs text-muted-foreground"> - All data loaded - </span> - )} - </div> - )} + {/* 페이지 이동 버튼 */} + <div className="flex items-center space-x-2"> + <Button + aria-label="Go to first page" + variant="outline" + className="hidden size-8 p-0 lg:flex" + onClick={() => table.setPageIndex(0)} + disabled={!table.getCanPreviousPage()} + > + <ChevronsLeft className="size-4" aria-hidden="true" /> + </Button> + <Button + aria-label="Go to previous page" + variant="outline" + size="icon" + className="size-8" + onClick={() => table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + <ChevronLeft className="size-4" aria-hidden="true" /> + </Button> + <Button + aria-label="Go to next page" + variant="outline" + size="icon" + className="size-8" + onClick={() => table.nextPage()} + disabled={!table.getCanNextPage()} + > + <ChevronRight className="size-4" aria-hidden="true" /> + </Button> + <Button + aria-label="Go to last page" + variant="outline" + size="icon" + className="hidden size-8 lg:flex" + onClick={() => table.setPageIndex(table.getPageCount() - 1)} + disabled={!table.getCanNextPage()} + > + <ChevronsRight className="size-4" aria-hidden="true" /> + </Button> + </div> </div> </div> ) diff --git a/components/data-table/data-table-pin-right.tsx b/components/data-table/data-table-pin-right.tsx index 2ba471cc..133740e1 100644 --- a/components/data-table/data-table-pin-right.tsx +++ b/components/data-table/data-table-pin-right.tsx @@ -84,7 +84,7 @@ function getColumnDisplayName<TData>(column: Column<TData>): string { /** * Array of column IDs that should be auto-pinned to the right when available */ -const AUTO_PIN_RIGHT_COLUMNS = ['actions'] +const AUTO_PIN_RIGHT_COLUMNS = ['actions', "action"] /** * "Pin Right" Popover. Supports pinning both individual columns and header groups. diff --git a/components/data-table/data-table.tsx b/components/data-table/data-table.tsx index c241c1ec..7d376b65 100644 --- a/components/data-table/data-table.tsx +++ b/components/data-table/data-table.tsx @@ -5,7 +5,7 @@ import { flexRender, type Table as TanstackTable } from "@tanstack/react-table" import { ChevronRight, ChevronUp } from "lucide-react" import { cn } from "@/lib/utils" -import { getCommonPinningStyles } from "@/lib/data-table" +import { getCommonPinningStylesWithBorder } from "@/lib/data-table" import { Table, TableBody, @@ -75,7 +75,7 @@ export function DataTable<TData>({ data-column-id={header.column.id} className={compact ? "py-1 px-2 text-sm" : ""} style={{ - ...getCommonPinningStyles({ column: header.column }), + ...getCommonPinningStylesWithBorder({ column: header.column }), width: header.getSize(), }} > @@ -170,7 +170,7 @@ export function DataTable<TData>({ data-column-id={cell.column.id} className={compactStyles.cell} style={{ - ...getCommonPinningStyles({ column: cell.column }), + ...getCommonPinningStylesWithBorder({ column: cell.column }), width: cell.column.getSize(), }} > diff --git a/components/data-table/expandable-data-table.tsx b/components/data-table/expandable-data-table.tsx index 9005e2fb..112e9448 100644 --- a/components/data-table/expandable-data-table.tsx +++ b/components/data-table/expandable-data-table.tsx @@ -5,7 +5,7 @@ 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 { getCommonPinningStylesWithBorder, debugPinningInfo } from "@/lib/data-table" import { Table, TableBody, @@ -26,18 +26,13 @@ interface ExpandableDataTableProps<TData> extends React.HTMLAttributes<HTMLDivEl expandedRows?: Set<string> setExpandedRows?: React.Dispatch<React.SetStateAction<Set<string>>> renderExpandedContent?: (row: TData) => React.ReactNode - expandable?: boolean // 확장 기능 활성화 여부 + expandable?: boolean maxHeight?: string | number - expandedRowClassName?: string // 확장된 행의 커스텀 클래스 + expandedRowClassName?: string + debug?: boolean // 디버깅 옵션 추가 + simpleExpansion?: boolean // 🎯 간단한 확장 방식 선택 옵션 추가 } -/** - * 확장 가능한 데이터 테이블 - 행 확장 시 바로 아래에 컨텐츠 표시 - * 개선사항: - * - 가로스크롤과 확장된 내용 독립성 보장 - * - 동적 높이 계산으로 세로스크롤 문제 해결 - * - 향상된 접근성 및 키보드 네비게이션 - */ export function ExpandableDataTable<TData>({ table, floatingBar = null, @@ -49,6 +44,8 @@ export function ExpandableDataTable<TData>({ expandable = false, maxHeight, expandedRowClassName, + debug = false, + simpleExpansion = false, // 🎯 기본값 false (전체 확장 방식) children, className, ...props @@ -56,11 +53,228 @@ export function ExpandableDataTable<TData>({ useAutoSizeColumns(table, autoSizeColumns) - // 스크롤 컨테이너 참조 - const scrollContainerRef = React.useRef<HTMLDivElement>(null) + const containerRef = React.useRef<HTMLDivElement>(null) + const scrollRef = React.useRef<HTMLDivElement>(null) // 🎯 스크롤 컨테이너 ref 추가 const [expandedHeights, setExpandedHeights] = React.useState<Map<string, number>>(new Map()) + const [isScrolled, setIsScrolled] = React.useState(false) + const [isScrolledToEnd, setIsScrolledToEnd] = React.useState(true) // 🎯 초기값을 true로 설정 (아직 계산 안됨) + const [isInitialized, setIsInitialized] = React.useState(false) // 🎯 초기화 상태 추가 + const [containerWidth, setContainerWidth] = React.useState<number>(0) - // 행 확장/축소 핸들러 (개선된 버전) + // 🎯 컨테이너 너비 감지 (패딩 제외된 실제 사용 가능한 너비) + React.useEffect(() => { + if (!containerRef.current) return + + const updateContainerWidth = () => { + if (containerRef.current) { + // clientWidth는 패딩을 제외한 실제 사용 가능한 너비 + setContainerWidth(containerRef.current.clientWidth) + } + } + + const resizeObserver = new ResizeObserver(updateContainerWidth) + resizeObserver.observe(containerRef.current) + + // 초기 너비 설정 + updateContainerWidth() + + return () => resizeObserver.disconnect() + }, []) + + // 🎯 초기 스크롤 상태 체크 (더 확실한 초기화) + React.useEffect(() => { + if (!scrollRef.current) return + + const checkScrollState = () => { + if (scrollRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current + const newIsScrolled = scrollLeft > 0 + const newIsScrolledToEnd = Math.abs(scrollWidth - clientWidth - scrollLeft) < 1 + + setIsScrolled(newIsScrolled) + setIsScrolledToEnd(newIsScrolledToEnd) + setIsInitialized(true) + + if (debug) { + console.log('Initial scroll check:', { + scrollLeft, + scrollWidth, + clientWidth, + newIsScrolled, + newIsScrolledToEnd, + canScroll: scrollWidth > clientWidth + }) + } + } + } + + // 즉시 체크 + checkScrollState() + + // 짧은 지연 후 재체크 (테이블 렌더링 완료 대기) + const timeouts = [50, 100, 200].map(delay => + setTimeout(checkScrollState, delay) + ) + + // ResizeObserver로 테이블 크기 변화 감지 + const resizeObserver = new ResizeObserver(() => { + setTimeout(checkScrollState, 10) + }) + resizeObserver.observe(scrollRef.current) + + return () => { + timeouts.forEach(clearTimeout) + resizeObserver.disconnect() + } + }, [table, debug]) + + // 🎯 데이터 변경 시 스크롤 상태 재설정 + React.useEffect(() => { + setIsInitialized(false) // 🎯 데이터 변경 시 초기화 상태 리셋 + setIsScrolledToEnd(true) // 🎯 안전한 기본값으로 리셋 + }, [table.getRowModel().rows.length]) + + const handleScroll = (e: React.UIEvent<HTMLDivElement>) => { + const scrollLeft = e.currentTarget.scrollLeft + const scrollWidth = e.currentTarget.scrollWidth + const clientWidth = e.currentTarget.clientWidth + + // 🎯 왼쪽으로부터 스크롤 상태 (왼쪽 핀용) + setIsScrolled(scrollLeft > 0) + + // 🎯 오른쪽 끝까지 스크롤 상태 (오른쪽 핀용) + const isAtEnd = Math.abs(scrollWidth - clientWidth - scrollLeft) < 1 // 1px 오차 허용 + setIsScrolledToEnd(isAtEnd) + setIsInitialized(true) // 🎯 스크롤 이벤트 발생 시 초기화 완료 + + if (debug) { + console.log('Scroll state:', { + scrollLeft, + scrollWidth, + clientWidth, + isScrolled: scrollLeft > 0, + isScrolledToEnd: isAtEnd, + remainingScroll: scrollWidth - clientWidth - scrollLeft + }) + } + } + + // 🔧 개선된 핀 스타일 함수 (좌/우 핀 구분 처리) + const getPinnedStyle = React.useCallback((column: any, isHeader: boolean = false) => { + if (debug) { + debugPinningInfo(column) + } + + try { + const baseStyle = getCommonPinningStylesWithBorder({ + column, + withBorder: true + }) + + const pinnedSide = column.getIsPinned() + + if (!pinnedSide) { + // width를 제외한 나머지 스타일만 반환 + const { width, ...restStyle } = baseStyle + // 헤더인 경우 핀되지 않았어도 배경 필요 (sticky 때문에) + return { + ...restStyle, + ...(isHeader && { + background: "hsl(var(--background))", + transition: "none", + }), + } + } + + // 확장 버튼이 있을 때 left pin된 컬럼들을 오른쪽으로 이동 + let leftPosition = baseStyle.left + if (expandable && pinnedSide === "left") { + const expandButtonWidth = 40 // w-10 = 40px + if (typeof baseStyle.left === 'string') { + const currentLeft = parseFloat(baseStyle.left.replace('px', '')) + leftPosition = `${currentLeft + expandButtonWidth}px` + } else if (typeof baseStyle.left === 'number') { + leftPosition = `${baseStyle.left + expandButtonWidth}px` + } else { + leftPosition = `${expandButtonWidth}px` + } + } + + // 🎯 핀 위치에 따른 배경 결정 + let shouldShowBackground = false + if (isHeader) { + // 헤더는 항상 배경 표시 + shouldShowBackground = true + } else { + // 바디 셀의 경우 핀 위치에 따라 다른 조건 적용 + if (pinnedSide === "left") { + // 왼쪽 핀: 오른쪽으로 스크롤했을 때 배경 표시 + shouldShowBackground = isScrolled + } else if (pinnedSide === "right") { + // 오른쪽 핀: 초기화 전이거나 오른쪽 끝까지 스크롤하지 않았을 때 배경 표시 + shouldShowBackground = !isInitialized || !isScrolledToEnd + } + } + + // width를 제외한 스타일 적용 + const { width, ...restBaseStyle } = baseStyle + + const finalStyle = { + ...restBaseStyle, + left: leftPosition, + background: shouldShowBackground + ? "hsl(var(--background))" + : "transparent", + transition: isHeader ? "none" : "background-color 0.15s ease-out", + } + + if (debug) { + console.log("Final pinned style:", { + columnId: column.id, + pinnedSide, + isHeader, + isScrolled, + isScrolledToEnd, + isInitialized, + shouldShowBackground, + finalStyle + }) + } + + return finalStyle + } catch (error) { + console.error("Error in getPinnedStyle:", error) + // fallback 스타일 + return { + position: 'relative' as const, + ...(isHeader && { + background: "hsl(var(--background))", + }), + } + } + }, [expandable, isScrolled, isScrolledToEnd, isInitialized, debug]) + + // 확장 버튼용 스타일 (안정성 개선) + const getExpandButtonStyle = React.useCallback(() => { + return { + position: 'sticky' as const, + left: 0, + zIndex: 1, + background: "hsl(var(--background))", + minWidth: '40px', + maxWidth: '40px', + width: '40px', + } + }, []) + + // 🎯 테이블 총 너비 계산 + const getTableWidth = React.useCallback(() => { + const expandButtonWidth = expandable ? 40 : 0 + const totalSize = table.getCenterTotalSize() + table.getLeftTotalSize() + table.getRightTotalSize() + return Math.max(totalSize + expandButtonWidth, 800) // 최소 800px 보장 + }, [table, expandable]) + + // 행 확장/축소 핸들러 const toggleRowExpansion = React.useCallback((rowId: string, event?: React.MouseEvent) => { if (!setExpandedRows) return @@ -71,7 +285,6 @@ export function ExpandableDataTable<TData>({ const newExpanded = new Set(expandedRows) if (newExpanded.has(rowId)) { newExpanded.delete(rowId) - // 높이 정보도 제거 setExpandedHeights(prev => { const newHeights = new Map(prev) newHeights.delete(rowId) @@ -91,7 +304,66 @@ export function ExpandableDataTable<TData>({ } }, [toggleRowExpansion]) - // 확장된 내용의 높이 측정 및 업데이트 + // 🎯 확장된 내용 스타일 계산 함수 (컨테이너 너비 기준) + const getExpandedContentStyle = React.useCallback(() => { + const expandButtonWidth = expandable ? 40 : 0 + const availableWidth = containerWidth || 800 // 🎯 컨테이너 너비 사용 (fallback 800px) + const contentWidth = availableWidth - expandButtonWidth - 32 // 버튼 + 여백(16px * 2) 제외 + + // 🎯 디버그 정보 + if (debug) { + console.log('Expanded content sizing:', { + containerWidth, + availableWidth, + expandButtonWidth, + contentWidth, + finalWidth: Math.max(contentWidth, 300) + }) + } + + return { + width: `${Math.max(contentWidth, 300)}px`, // 🎯 최소 300px 보장 + marginLeft: `${expandButtonWidth + 8}px`, // 🎯 확장 버튼 + 여백 + marginRight: '16px', + padding: '12px 16px', + backgroundColor: 'hsl(var(--background))', + borderRadius: '0 0 6px 6px', + boxShadow: '0 2px 4px -1px rgba(0, 0, 0, 0.1)', + border: '1px solid hsl(var(--border))', + borderTop: 'none', + marginBottom: '4px', + maxWidth: `${availableWidth - expandButtonWidth - 16}px`, // 🎯 컨테이너 너비 초과 방지 + overflow: 'auto', + } + }, [expandable, containerWidth, debug]) + + // 🎯 간단한 확장 스타일 (컨테이너 너비 기준) + const getSimpleExpandedStyle = React.useCallback(() => { + const expandButtonWidth = expandable ? 40 : 0 + const availableWidth = containerWidth || 800 + const contentWidth = availableWidth - expandButtonWidth - 48 // 버튼 + 여백 제외 + + return { + marginLeft: `${expandButtonWidth + 8}px`, + marginRight: '16px', + width: `${Math.max(contentWidth, 300)}px`, + maxWidth: `${availableWidth - expandButtonWidth - 24}px`, + padding: '12px 16px', + backgroundColor: 'hsl(var(--muted) / 0.5)', + borderRadius: '6px', + border: '1px solid hsl(var(--border))', + marginTop: '8px', + marginBottom: '8px', + overflow: 'auto', + } + }, [expandable, containerWidth]) + + // 🎯 사용할 확장 스타일 선택 (props로 제어) + const useSimpleExpansion = simpleExpansion + const getExpandedPlaceholderHeight = React.useCallback((contentHeight: number) => { + const padding = 24 + 4 // padding (12px * 2) + marginBottom (4px) + return Math.max(contentHeight + padding, 220) + }, []) const updateExpandedHeight = React.useCallback((rowId: string, height: number) => { setExpandedHeights(prev => { if (prev.get(rowId) !== height) { @@ -103,22 +375,26 @@ export function ExpandableDataTable<TData>({ }) }, []) - // 컴팩트 모드를 위한 클래스 정의 (개선된 버전) + // 컴팩트 모드 스타일 const compactStyles = compact ? { row: "h-7", cell: "py-1 px-2 text-sm", + header: "py-1 px-2 text-sm", + headerRow: "h-8", expandedCell: "py-2 px-4", groupRow: "py-1 bg-muted/20 text-sm", emptyRow: "h-16", } : { row: "", cell: "", - expandedCell: "py-0 px-0", // 패딩 제거하여 확장 컨텐츠가 전체 영역 사용 + header: "", + headerRow: "", + expandedCell: "py-0 px-0", groupRow: "bg-muted/20", emptyRow: "h-24", } - // 확장 버튼 렌더링 함수 (접근성 개선) + // 확장 버튼 렌더링 const renderExpandButton = (rowId: string) => { if (!expandable || !setExpandedRows) return null @@ -143,7 +419,7 @@ export function ExpandableDataTable<TData>({ ) } - // 확장된 내용 래퍼 컴포넌트 (높이 측정 기능 포함) + // 확장된 내용 래퍼 컴포넌트 const ExpandedContentWrapper = React.memo<{ rowId: string children: React.ReactNode @@ -176,244 +452,233 @@ export function ExpandableDataTable<TData>({ <div className={cn("w-full space-y-2.5", className)} {...props}> {children} - {/* 메인 테이블 컨테이너 - 가로스크롤 문제 해결 */} <div - ref={scrollContainerRef} + ref={containerRef} // 🎯 컨테이너 wrapper ref (패딩 제외 너비 계산용) className="relative rounded-md border" style={{ - // maxHeight: maxHeight || '35rem', - minHeight: '200px' // 최소 높이 보장 + 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() - )} + <div + ref={scrollRef} // 🎯 스크롤 컨테이너 ref 연결 + className="overflow-auto" + style={{ maxHeight: maxHeight || '34rem' }} + onScroll={handleScroll} // 🎯 스크롤 이벤트 핸들러 + > + <Table + className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10" + style={{ + width: getTableWidth(), // 🎯 동적 너비 계산 + minWidth: '100%' + }} + > + <TableHeader className="bg-background"> + {table.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id} className={compactStyles.headerRow}> + {expandable && ( + <TableHead + className={cn("w-10", compactStyles.header)} + style={getExpandButtonStyle()} + /> + )} + + {headerGroup.headers.map((header) => { + if (header.column.getIsGrouped()) { + return null + } - {header.column.getCanResize() && ( - <DataTableResizer header={header} /> + return ( + <TableHead + key={header.id} + colSpan={header.colSpan} + data-column-id={header.column.id} + className={cn( + compactStyles.header, + "bg-background" + )} + style={{ + ...getPinnedStyle(header.column, true), // 🎯 동적 스타일 + width: header.getSize() // 🎯 width 별도 설정 + }} + > + <div style={{ position: "relative" }}> + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() )} - </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> - )} + {header.column.getCanResize() && ( + <DataTableResizer header={header} /> + )} + </div> + </TableHead> + ) + })} + </TableRow> + ))} + </TableHeader> - <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> - ) + <TableBody> + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => { + const isExpanded = expandedRows.has(row.id) + const expandedHeight = expandedHeights.get(row.id) || 0 + + 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 + } } - // 일반 Row와 확장된 컨텐츠를 함께 렌더링 return ( - <React.Fragment key={row.id}> - {/* 메인 데이터 행 */} - <TableRow - className={cn( - compactStyles.row, - isExpanded && "bg-muted/30 border-b-0" - )} - data-state={row.getIsSelected() && "selected"} + <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" : ""} > - {/* 확장 버튼 셀 */} - {expandable && ( - <TableCell - className={cn("w-10", compactStyles.cell)} - style={{ - position: 'sticky', - left: 0, - zIndex: 1, - backgroundColor: isExpanded ? 'rgb(248 250 252)' : 'white' + {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() ? "그룹 축소" : "그룹 확장"} > - {renderExpandButton(row.id)} - </TableCell> + {row.getIsExpanded() ? ( + <ChevronUp size={compact ? 14 : 16} /> + ) : ( + <ChevronRight size={compact ? 14 : 16} /> + )} + </button> )} - {/* 데이터 셀들 */} - {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> + <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> + ) + } + + 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={getExpandButtonStyle()} + > + {renderExpandButton(row.id)} + </TableCell> + )} - {/* 확장된 컨텐츠 행 - 가로스크롤 독립성 보장 */} - {isExpanded && renderExpandedContent && ( - <TableRow className="hover:bg-transparent"> + {row.getVisibleCells().map((cell) => { + if (cell.column.getIsGrouped()) { + return null + } + + return ( <TableCell - colSpan={table.getVisibleFlatColumns().length + (expandable ? 1 : 0)} - className={cn( - compactStyles.expandedCell, - "border-t-0 relative", - expandedRowClassName - )} + key={cell.id} + data-column-id={cell.column.id} + className={compactStyles.cell} style={{ - minHeight: expandedHeight || 'auto', + ...getPinnedStyle(cell.column, false), // 🎯 동적 스타일 + width: cell.column.getSize() // 🎯 width 별도 설정 }} > - {/* 확장된 내용을 위한 고정 폭 컨테이너 */} + {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', + }} + > + {useSimpleExpansion ? ( + // 🎯 간단한 확장 방식: 테이블 내부에서만 확장 + <div style={getSimpleExpandedStyle()}> + <ExpandedContentWrapper rowId={row.id}> + {renderExpandedContent(row.original)} + </ExpandedContentWrapper> + </div> + ) : ( + // 🎯 전체 화면 확장 방식: 테이블 너비 기준으로 개선 <div className="relative w-full"> - {/* 가로스크롤과 독립적인 확장 영역 */} <div - className="absolute left-0 right-0 top-0 border-t" - style={{ - width: '80vw', - marginLeft: 'calc(-48vw + 50%)', - }} + className="absolute top-0" + style={getExpandedContentStyle()} > - <div className="max-w-none mx-auto"> - <ExpandedContentWrapper rowId={row.id}> - {renderExpandedContent(row.original)} - </ExpandedContentWrapper> - </div> + <ExpandedContentWrapper rowId={row.id}> + {renderExpandedContent(row.original)} + </ExpandedContentWrapper> </div> - {/* 높이 유지를 위한 스페이서 */} <div className="opacity-0 pointer-events-none" - style={{ height: Math.max(expandedHeight, 200) }} + style={{ height: getExpandedPlaceholderHeight(expandedHeight) }} /> </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> + )} + </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 className="flex flex-col gap-2.5"> - {/* Pagination */} <DataTablePagination table={table} /> - - {/* Floating Bar (선택된 행 있을 때) */} {table.getFilteredSelectedRowModel().rows.length > 0 && floatingBar} </div> </div> diff --git a/components/data-table/infinite-data-table.tsx b/components/data-table/infinite-data-table.tsx index fcac56ee..b8764d62 100644 --- a/components/data-table/infinite-data-table.tsx +++ b/components/data-table/infinite-data-table.tsx @@ -6,7 +6,7 @@ import { ChevronRight, ChevronUp, Loader2 } from "lucide-react" import { useIntersection } from "@mantine/hooks" import { cn } from "@/lib/utils" -import { getCommonPinningStyles } from "@/lib/data-table" +import { getCommonPinningStylesWithBorder } from "@/lib/data-table" import { Table, TableBody, @@ -53,6 +53,9 @@ export function InfiniteDataTable<TData>({ useAutoSizeColumns(table, autoSizeColumns) + // 🎯 스크롤 상태 감지 추가 + const [isScrolled, setIsScrolled] = React.useState(false) + // Intersection Observer for infinite scroll const { ref: loadMoreRef, entry } = useIntersection({ threshold: 0.1, @@ -65,21 +68,62 @@ export function InfiniteDataTable<TData>({ } }, [entry?.isIntersecting, hasNextPage, isLoadingMore, onLoadMore]) + // 🎯 스크롤 핸들러 추가 + const handleScroll = (e: React.UIEvent<HTMLDivElement>) => { + const scrollLeft = e.currentTarget.scrollLeft + setIsScrolled(scrollLeft > 0) + } + + // 🎯 동적 핀 스타일 함수 (width 중복 제거) + const getPinnedStyle = (column: any, isHeader: boolean = false) => { + const baseStyle = getCommonPinningStylesWithBorder({ column }) + const pinnedSide = column.getIsPinned() + + // width를 제외한 나머지 스타일만 반환 + const { width, ...restBaseStyle } = baseStyle + + return { + ...restBaseStyle, + // 헤더는 핀 여부와 관계없이 항상 배경 유지 (sticky로 고정되어 있기 때문) + ...(isHeader && { + background: "hsl(var(--background))", + transition: "none", + }), + // 바디 셀은 핀된 경우에만 스크롤 상태에 따라 동적 변경 + ...(!isHeader && pinnedSide && { + background: isScrolled + ? "hsl(var(--background))" + : "transparent", + transition: "background-color 0.15s ease-out", + }), + } + } + + // 🎯 테이블 총 너비 계산 + const getTableWidth = React.useCallback(() => { + const totalSize = table.getCenterTotalSize() + table.getLeftTotalSize() + table.getRightTotalSize() + return Math.max(totalSize, 800) // 최소 800px 보장 + }, [table]) + // 컴팩트 모드를 위한 클래스 정의 const compactStyles = compact ? { row: "h-7", cell: "py-1 px-2 text-sm", + header: "py-1 px-2 text-sm", // 헤더 스타일 추가 + headerRow: "h-8", // 헤더 행 높이 추가 groupRow: "py-1 bg-muted/20 text-sm", emptyRow: "h-16", } : { row: "", cell: "", + header: "", // 헤더 스타일 추가 + headerRow: "", // 헤더 행 높이 추가 groupRow: "bg-muted/20", emptyRow: "h-24", } return ( - <div className={cn("w-full space-y-2.5 overflow-auto", className)} {...props}> + <div className={cn("w-full space-y-2.5", className)} {...props}> {children} {/* 총 개수 표시 */} @@ -97,12 +141,19 @@ export function InfiniteDataTable<TData>({ <div className="max-w-[100vw] overflow-auto" style={{ maxHeight: maxHeight || '35rem' }} + onScroll={handleScroll} // 🎯 스크롤 이벤트 핸들러 추가 > - <Table className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10 table-fixed"> + <Table + className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10" + style={{ + width: getTableWidth(), // 🎯 동적 너비 계산 + minWidth: '100%' + }} + > {/* 테이블 헤더 */} <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( - <TableRow key={headerGroup.id} className={compact ? "h-8" : ""}> + <TableRow key={headerGroup.id} className={compactStyles.headerRow}> {headerGroup.headers.map((header) => { if (header.column.getIsGrouped()) { return null @@ -113,10 +164,10 @@ export function InfiniteDataTable<TData>({ key={header.id} colSpan={header.colSpan} data-column-id={header.column.id} - className={compact ? "py-1 px-2 text-sm" : ""} + className={compactStyles.header} style={{ - ...getCommonPinningStyles({ column: header.column }), - width: header.getSize(), + ...getPinnedStyle(header.column, true), // 🎯 헤더임을 명시 + width: header.getSize(), // 🎯 width 별도 설정 }} > <div style={{ position: "relative" }}> @@ -211,8 +262,8 @@ export function InfiniteDataTable<TData>({ data-column-id={cell.column.id} className={compactStyles.cell} style={{ - ...getCommonPinningStyles({ column: cell.column }), - width: cell.column.getSize(), + ...getPinnedStyle(cell.column, false), // 🎯 바디 셀임을 명시 + width: cell.column.getSize(), // 🎯 width 별도 설정 }} > {flexRender( diff --git a/components/data-table/view-mode-toggle.tsx b/components/data-table/view-mode-toggle.tsx new file mode 100644 index 00000000..fc07b4bf --- /dev/null +++ b/components/data-table/view-mode-toggle.tsx @@ -0,0 +1,91 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Infinity, Grid3X3, RotateCcw } from "lucide-react" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +interface ViewModeToggleProps { + isInfiniteMode: boolean + onSwitch: (pageSize: number) => void + className?: string +} + +export function ViewModeToggle({ isInfiniteMode, onSwitch, className }: ViewModeToggleProps) { + + const switchToPagination = React.useCallback(() => { + onSwitch(10) + }, [onSwitch]) + + + + // 새로고침 + const handleRefresh = React.useCallback(() => { + window.location.reload() + }, []) + + return ( + <div className={`flex items-center gap-2 ${className}`}> + {/* 현재 모드 표시 */} + <div className="flex items-center gap-2"> + {isInfiniteMode && ( + <Badge variant="default" className="bg-blue-100 text-blue-800 hover:bg-blue-200"> + <Infinity className="h-3 w-3 mr-1" /> + 무한 스크롤 모드 + </Badge> + ) } + </div> + + {/* 모드 전환 버튼 */} + <div className="flex items-center gap-1"> + {isInfiniteMode && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={switchToPagination} + className="h-8 px-2" + > + <Grid3X3 className="h-4 w-4 mr-1" /> + 페이지 모드 + </Button> + </TooltipTrigger> + <TooltipContent> + <p>페이지네이션 모드로 전환 (20개씩 표시)</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ) } + + {/* 새로고침 버튼 */} + {isInfiniteMode && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + onClick={handleRefresh} + className="h-8 w-8 p-0" + > + <RotateCcw className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>새로고침</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} + </div> + </div> + ) +}
\ No newline at end of file |
