summaryrefslogtreecommitdiff
path: root/components/data-table/expandable-data-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/data-table/expandable-data-table.tsx')
-rw-r--r--components/data-table/expandable-data-table.tsx226
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"