diff options
Diffstat (limited to 'components/data-table/expandable-data-table.tsx')
| -rw-r--r-- | components/data-table/expandable-data-table.tsx | 226 |
1 files changed, 138 insertions, 88 deletions
diff --git a/components/data-table/expandable-data-table.tsx b/components/data-table/expandable-data-table.tsx index 112e9448..15c59540 100644 --- a/components/data-table/expandable-data-table.tsx +++ b/components/data-table/expandable-data-table.tsx @@ -29,8 +29,11 @@ interface ExpandableDataTableProps<TData> extends React.HTMLAttributes<HTMLDivEl expandable?: boolean maxHeight?: string | number expandedRowClassName?: string - debug?: boolean // 디버깅 옵션 추가 - simpleExpansion?: boolean // 🎯 간단한 확장 방식 선택 옵션 추가 + debug?: boolean + simpleExpansion?: boolean + // ✅ 새로운 props 추가 + clickableColumns?: string[] // 클릭 가능한 컬럼 ID들 + excludeFromClick?: string[] // 클릭에서 제외할 컬럼 ID들 } export function ExpandableDataTable<TData>({ @@ -45,7 +48,10 @@ export function ExpandableDataTable<TData>({ maxHeight, expandedRowClassName, debug = false, - simpleExpansion = false, // 🎯 기본값 false (전체 확장 방식) + simpleExpansion = false, + // ✅ 새로운 props + clickableColumns, + excludeFromClick = ['actions'], children, className, ...props @@ -54,20 +60,19 @@ export function ExpandableDataTable<TData>({ useAutoSizeColumns(table, autoSizeColumns) const containerRef = React.useRef<HTMLDivElement>(null) - const scrollRef = React.useRef<HTMLDivElement>(null) // 🎯 스크롤 컨테이너 ref 추가 + const scrollRef = React.useRef<HTMLDivElement>(null) 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 [isScrolledToEnd, setIsScrolledToEnd] = React.useState(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) } } @@ -75,13 +80,12 @@ export function ExpandableDataTable<TData>({ const resizeObserver = new ResizeObserver(updateContainerWidth) resizeObserver.observe(containerRef.current) - // 초기 너비 설정 updateContainerWidth() return () => resizeObserver.disconnect() }, []) - // 🎯 초기 스크롤 상태 체크 (더 확실한 초기화) + // 초기 스크롤 상태 체크 React.useEffect(() => { if (!scrollRef.current) return @@ -108,15 +112,12 @@ export function ExpandableDataTable<TData>({ } } - // 즉시 체크 checkScrollState() - // 짧은 지연 후 재체크 (테이블 렌더링 완료 대기) const timeouts = [50, 100, 200].map(delay => setTimeout(checkScrollState, delay) ) - // ResizeObserver로 테이블 크기 변화 감지 const resizeObserver = new ResizeObserver(() => { setTimeout(checkScrollState, 10) }) @@ -128,24 +129,28 @@ export function ExpandableDataTable<TData>({ } }, [table, debug]) - // 🎯 데이터 변경 시 스크롤 상태 재설정 + // 데이터 변경 시 스크롤 상태 재설정 React.useEffect(() => { - setIsInitialized(false) // 🎯 데이터 변경 시 초기화 상태 리셋 - setIsScrolledToEnd(true) // 🎯 안전한 기본값으로 리셋 - }, [table.getRowModel().rows.length]) + setIsInitialized(false) + setIsScrolledToEnd(true) + + if (debug) { + const allColumns = table.getAllColumns().map(col => col.id) + const visibleColumns = table.getVisibleFlatColumns().map(col => col.id) + console.log('🔍 Available columns:', { allColumns, visibleColumns, clickableColumns, excludeFromClick }) + } + }, [table.getRowModel().rows.length, clickableColumns, excludeFromClick, debug]) 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 오차 허용 + const isAtEnd = Math.abs(scrollWidth - clientWidth - scrollLeft) < 1 setIsScrolledToEnd(isAtEnd) - setIsInitialized(true) // 🎯 스크롤 이벤트 발생 시 초기화 완료 + setIsInitialized(true) if (debug) { console.log('Scroll state:', { @@ -159,7 +164,77 @@ export function ExpandableDataTable<TData>({ } } - // 🔧 개선된 핀 스타일 함수 (좌/우 핀 구분 처리) + // 행 확장/축소 핸들러 + 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 isCellClickable = React.useCallback((columnId: string) => { + if (!expandable || !setExpandedRows) return false + + if (excludeFromClick.includes(columnId)) { + if (debug) console.log('🚫 Column excluded:', columnId) + return false + } + + if (clickableColumns && clickableColumns.length > 0) { + const isClickable = clickableColumns.includes(columnId) + if (debug) console.log('📋 Column clickable check:', { columnId, clickableColumns, isClickable }) + return isClickable + } + + if (debug) console.log('✅ Column clickable by default:', columnId) + return true + }, [expandable, setExpandedRows, clickableColumns, excludeFromClick, debug]) + + // ✅ 셀 클릭 핸들러 + const handleCellClick = React.useCallback((rowId: string, columnId: string, event: React.MouseEvent) => { + if (debug) console.log('🔍 Cell clicked:', { rowId, columnId, clickable: isCellClickable(columnId) }) + + if (!isCellClickable(columnId)) { + if (debug) console.log('❌ Cell not clickable:', columnId) + return + } + + const target = event.target as HTMLElement + + // 실제 BUTTON과 A 태그만 제외 + if (target.tagName === 'BUTTON' || target.tagName === 'A') { + if (debug) console.log('❌ Button or link clicked, ignoring') + return + } + + if (debug) console.log('✅ Toggling row expansion for:', rowId) + toggleRowExpansion(rowId, event) + }, [isCellClickable, toggleRowExpansion, debug]) + + // 키보드 네비게이션 핸들러 + const handleKeyDown = React.useCallback((event: React.KeyboardEvent, rowId: string) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + toggleRowExpansion(rowId) + } + }, [toggleRowExpansion]) + + // 핀 스타일 함수 const getPinnedStyle = React.useCallback((column: any, isHeader: boolean = false) => { if (debug) { debugPinningInfo(column) @@ -174,9 +249,7 @@ export function ExpandableDataTable<TData>({ const pinnedSide = column.getIsPinned() if (!pinnedSide) { - // width를 제외한 나머지 스타일만 반환 const { width, ...restStyle } = baseStyle - // 헤더인 경우 핀되지 않았어도 배경 필요 (sticky 때문에) return { ...restStyle, ...(isHeader && { @@ -186,10 +259,9 @@ export function ExpandableDataTable<TData>({ } } - // 확장 버튼이 있을 때 left pin된 컬럼들을 오른쪽으로 이동 let leftPosition = baseStyle.left if (expandable && pinnedSide === "left") { - const expandButtonWidth = 40 // w-10 = 40px + const expandButtonWidth = 40 if (typeof baseStyle.left === 'string') { const currentLeft = parseFloat(baseStyle.left.replace('px', '')) leftPosition = `${currentLeft + expandButtonWidth}px` @@ -200,23 +272,17 @@ export function ExpandableDataTable<TData>({ } } - // 🎯 핀 위치에 따른 배경 결정 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 = { @@ -244,7 +310,6 @@ export function ExpandableDataTable<TData>({ return finalStyle } catch (error) { console.error("Error in getPinnedStyle:", error) - // fallback 스타일 return { position: 'relative' as const, ...(isHeader && { @@ -254,7 +319,7 @@ export function ExpandableDataTable<TData>({ } }, [expandable, isScrolled, isScrolledToEnd, isInitialized, debug]) - // 확장 버튼용 스타일 (안정성 개선) + // 확장 버튼용 스타일 const getExpandButtonStyle = React.useCallback(() => { return { position: 'sticky' as const, @@ -267,50 +332,19 @@ export function ExpandableDataTable<TData>({ } }, []) - // 🎯 테이블 총 너비 계산 + // 테이블 총 너비 계산 const getTableWidth = React.useCallback(() => { const expandButtonWidth = expandable ? 40 : 0 const totalSize = table.getCenterTotalSize() + table.getLeftTotalSize() + table.getRightTotalSize() - return Math.max(totalSize + expandButtonWidth, 800) // 최소 800px 보장 + return Math.max(totalSize + expandButtonWidth, 800) }, [table, expandable]) - // 행 확장/축소 핸들러 - 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 getExpandedContentStyle = React.useCallback(() => { const expandButtonWidth = expandable ? 40 : 0 - const availableWidth = containerWidth || 800 // 🎯 컨테이너 너비 사용 (fallback 800px) - const contentWidth = availableWidth - expandButtonWidth - 32 // 버튼 + 여백(16px * 2) 제외 + const availableWidth = containerWidth || 800 + const contentWidth = availableWidth - expandButtonWidth - 32 - // 🎯 디버그 정보 if (debug) { console.log('Expanded content sizing:', { containerWidth, @@ -322,8 +356,8 @@ export function ExpandableDataTable<TData>({ } return { - width: `${Math.max(contentWidth, 300)}px`, // 🎯 최소 300px 보장 - marginLeft: `${expandButtonWidth + 8}px`, // 🎯 확장 버튼 + 여백 + width: `${Math.max(contentWidth, 300)}px`, + marginLeft: `${expandButtonWidth + 8}px`, marginRight: '16px', padding: '12px 16px', backgroundColor: 'hsl(var(--background))', @@ -332,16 +366,16 @@ export function ExpandableDataTable<TData>({ border: '1px solid hsl(var(--border))', borderTop: 'none', marginBottom: '4px', - maxWidth: `${availableWidth - expandButtonWidth - 16}px`, // 🎯 컨테이너 너비 초과 방지 + 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 // 버튼 + 여백 제외 + const contentWidth = availableWidth - expandButtonWidth - 48 return { marginLeft: `${expandButtonWidth + 8}px`, @@ -358,12 +392,12 @@ export function ExpandableDataTable<TData>({ } }, [expandable, containerWidth]) - // 🎯 사용할 확장 스타일 선택 (props로 제어) const useSimpleExpansion = simpleExpansion const getExpandedPlaceholderHeight = React.useCallback((contentHeight: number) => { - const padding = 24 + 4 // padding (12px * 2) + marginBottom (4px) + const padding = 24 + 4 return Math.max(contentHeight + padding, 220) }, []) + const updateExpandedHeight = React.useCallback((rowId: string, height: number) => { setExpandedHeights(prev => { if (prev.get(rowId) !== height) { @@ -453,22 +487,22 @@ export function ExpandableDataTable<TData>({ {children} <div - ref={containerRef} // 🎯 컨테이너 wrapper ref (패딩 제외 너비 계산용) + ref={containerRef} className="relative rounded-md border" style={{ minHeight: '200px' }} > <div - ref={scrollRef} // 🎯 스크롤 컨테이너 ref 연결 + ref={scrollRef} className="overflow-auto" style={{ maxHeight: maxHeight || '34rem' }} - onScroll={handleScroll} // 🎯 스크롤 이벤트 핸들러 + onScroll={handleScroll} > <Table className="[&>thead]:sticky [&>thead]:top-0 [&>thead]:z-10" style={{ - width: getTableWidth(), // 🎯 동적 너비 계산 + width: getTableWidth(), minWidth: '100%' }} > @@ -497,8 +531,8 @@ export function ExpandableDataTable<TData>({ "bg-background" )} style={{ - ...getPinnedStyle(header.column, true), // 🎯 동적 스타일 - width: header.getSize() // 🎯 width 별도 설정 + ...getPinnedStyle(header.column, true), + width: header.getSize() }} > <div style={{ position: "relative" }}> @@ -599,15 +633,33 @@ export function ExpandableDataTable<TData>({ return null } + const isClickable = isCellClickable(cell.column.id) + return ( <TableCell key={cell.id} data-column-id={cell.column.id} - className={compactStyles.cell} + className={cn( + compactStyles.cell, + // ✅ 클릭 가능한 셀에 시각적 피드백 추가 + isClickable && "cursor-pointer hover:bg-muted/50 transition-colors" + )} style={{ - ...getPinnedStyle(cell.column, false), // 🎯 동적 스타일 - width: cell.column.getSize() // 🎯 width 별도 설정 + ...getPinnedStyle(cell.column, false), + width: cell.column.getSize() }} + // ✅ 클릭 이벤트 추가 + onClick={isClickable ? (e) => handleCellClick(row.id, cell.column.id, e) : undefined} + // ✅ 접근성 개선 + role={isClickable ? "button" : undefined} + tabIndex={isClickable ? 0 : undefined} + onKeyDown={isClickable ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleCellClick(row.id, cell.column.id, e as any) + } + } : undefined} + aria-label={isClickable ? `${isExpanded ? '행 축소' : '행 확장'} - ${cell.column.id}` : undefined} > {flexRender( cell.column.columnDef.cell, @@ -632,14 +684,12 @@ export function ExpandableDataTable<TData>({ }} > {useSimpleExpansion ? ( - // 🎯 간단한 확장 방식: 테이블 내부에서만 확장 <div style={getSimpleExpandedStyle()}> <ExpandedContentWrapper rowId={row.id}> {renderExpandedContent(row.original)} </ExpandedContentWrapper> </div> ) : ( - // 🎯 전체 화면 확장 방식: 테이블 너비 기준으로 개선 <div className="relative w-full"> <div className="absolute top-0" |
