DataTable.tsx 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. 'use client';
  2. // D6 §5.1 — 고밀도 "선/테이블 클래식" DataTable
  3. // 시맨틱 <table> 필수(div 그리드 금지). sticky <thead>, 행 구분 하단 1px 선만(세로선·zebra 없음),
  4. // 숫자열 우측 정렬 + tabular-nums, 밀도 3단(html[data-density] 변수 소비 — data-table.scss),
  5. // 우선순위 낮은 열은 mobile drop(data-priority), 정렬 열 aria-sort, <caption> 필수(sr-only 허용).
  6. import '@/app/styles/data-table.scss';
  7. import { cn } from '@/lib/utils/client';
  8. export type ColumnAlign = 'left'|'right'|'center';
  9. export type ColumnPriority = 'high'|'medium'|'low';
  10. export type SortDirection = 'asc'|'desc';
  11. export interface DataTableColumn<T> {
  12. // 열 식별자
  13. key: string;
  14. // 헤더 라벨
  15. header: React.ReactNode;
  16. // 셀 렌더러
  17. cell: (_row: T, _index: number) => React.ReactNode;
  18. // 정렬 (기본 left, 숫자열은 right)
  19. align?: ColumnAlign;
  20. // 우선순위 — low 는 mobile(<768px)에서 숨김 (data-priority)
  21. priority?: ColumnPriority;
  22. // 정렬 가능 열이면 방향(없으면 미정렬), 클릭 핸들러는 onSort
  23. sort?: SortDirection|null;
  24. // 헤더 셀 추가 className
  25. headerClassName?: string;
  26. // 바디 셀 추가 className
  27. cellClassName?: string;
  28. }
  29. interface DataTableProps<T> {
  30. // sr-only caption (접근성 — 표 요약). 화면 표시하려면 captionVisible
  31. caption: string;
  32. captionVisible?: boolean;
  33. columns: DataTableColumn<T>[];
  34. rows: T[];
  35. rowKey: (_row: T, _index: number) => string|number;
  36. // 행 클릭/링크 — 반환값(href)이 있으면 첫 셀에 링크 래핑은 컬럼 cell 이 담당. 여기선 tr onClick 만.
  37. onRowClick?: (_row: T, _index: number) => void;
  38. // 정렬 헤더 클릭
  39. onSort?: (_key: string) => void;
  40. // 빈 상태 메시지
  41. emptyMessage?: string;
  42. className?: string;
  43. }
  44. function ariaSort(sort: SortDirection|null|undefined): 'ascending'|'descending'|'none'|undefined {
  45. if (sort === 'asc') {
  46. return 'ascending';
  47. }
  48. if (sort === 'desc') {
  49. return 'descending';
  50. }
  51. // 정렬 가능 열이지만 현재 미적용(null)은 'none', 정렬 불가 열(undefined)은 aria-sort 미부여
  52. return sort === null ? 'none' : undefined;
  53. }
  54. export default function DataTable<T>({
  55. caption,
  56. captionVisible = false,
  57. columns,
  58. rows,
  59. rowKey,
  60. onRowClick,
  61. onSort,
  62. emptyMessage = '표시할 항목이 없습니다.',
  63. className,
  64. }: DataTableProps<T>)
  65. {
  66. return (
  67. <table className={cn('data-table', className)}>
  68. <caption className={captionVisible ? 'data-table__caption' : 'data-table__caption data-table__caption--sr'}>
  69. {caption}
  70. </caption>
  71. <thead>
  72. <tr>
  73. {columns.map(col => {
  74. const sortable = col.sort !== undefined;
  75. return (
  76. <th
  77. key={col.key}
  78. scope='col'
  79. data-align={col.align ?? 'left'}
  80. data-priority={col.priority ?? 'high'}
  81. aria-sort={ariaSort(col.sort)}
  82. className={col.headerClassName}
  83. >
  84. {sortable && onSort ? (
  85. <button
  86. type='button'
  87. className='data-table__sort'
  88. onClick={() => onSort(col.key)}
  89. >
  90. {col.header}
  91. <span className='data-table__sort-icon' aria-hidden='true'>
  92. {col.sort === 'asc' ? '▲' : col.sort === 'desc' ? '▼' : '⇅'}
  93. </span>
  94. </button>
  95. ) : (
  96. col.header
  97. )}
  98. </th>
  99. );
  100. })}
  101. </tr>
  102. </thead>
  103. <tbody>
  104. {rows.length === 0 ? (
  105. <tr className='data-table__empty-row'>
  106. <td colSpan={columns.length} className='data-table__empty'>{emptyMessage}</td>
  107. </tr>
  108. ) : (
  109. rows.map((row, index) => (
  110. <tr
  111. key={rowKey(row, index)}
  112. onClick={onRowClick ? () => onRowClick(row, index) : undefined}
  113. className={onRowClick ? 'data-table__row--clickable' : undefined}
  114. >
  115. {columns.map(col => (
  116. <td
  117. key={col.key}
  118. data-align={col.align ?? 'left'}
  119. data-priority={col.priority ?? 'high'}
  120. className={col.cellClassName}
  121. >
  122. {col.cell(row, index)}
  123. </td>
  124. ))}
  125. </tr>
  126. ))
  127. )}
  128. </tbody>
  129. </table>
  130. );
  131. }