| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140 |
- 'use client';
- // D6 §5.1 — 고밀도 "선/테이블 클래식" DataTable
- // 시맨틱 <table> 필수(div 그리드 금지). sticky <thead>, 행 구분 하단 1px 선만(세로선·zebra 없음),
- // 숫자열 우측 정렬 + tabular-nums, 밀도 3단(html[data-density] 변수 소비 — data-table.scss),
- // 우선순위 낮은 열은 mobile drop(data-priority), 정렬 열 aria-sort, <caption> 필수(sr-only 허용).
- import '@/app/styles/data-table.scss';
- import { cn } from '@/lib/utils/client';
- export type ColumnAlign = 'left'|'right'|'center';
- export type ColumnPriority = 'high'|'medium'|'low';
- export type SortDirection = 'asc'|'desc';
- export interface DataTableColumn<T> {
- // 열 식별자
- key: string;
- // 헤더 라벨
- header: React.ReactNode;
- // 셀 렌더러
- cell: (_row: T, _index: number) => React.ReactNode;
- // 정렬 (기본 left, 숫자열은 right)
- align?: ColumnAlign;
- // 우선순위 — low 는 mobile(<768px)에서 숨김 (data-priority)
- priority?: ColumnPriority;
- // 정렬 가능 열이면 방향(없으면 미정렬), 클릭 핸들러는 onSort
- sort?: SortDirection|null;
- // 헤더 셀 추가 className
- headerClassName?: string;
- // 바디 셀 추가 className
- cellClassName?: string;
- }
- interface DataTableProps<T> {
- // sr-only caption (접근성 — 표 요약). 화면 표시하려면 captionVisible
- caption: string;
- captionVisible?: boolean;
- columns: DataTableColumn<T>[];
- rows: T[];
- rowKey: (_row: T, _index: number) => string|number;
- // 행 클릭/링크 — 반환값(href)이 있으면 첫 셀에 링크 래핑은 컬럼 cell 이 담당. 여기선 tr onClick 만.
- onRowClick?: (_row: T, _index: number) => void;
- // 정렬 헤더 클릭
- onSort?: (_key: string) => void;
- // 빈 상태 메시지
- emptyMessage?: string;
- className?: string;
- }
- function ariaSort(sort: SortDirection|null|undefined): 'ascending'|'descending'|'none'|undefined {
- if (sort === 'asc') {
- return 'ascending';
- }
- if (sort === 'desc') {
- return 'descending';
- }
- // 정렬 가능 열이지만 현재 미적용(null)은 'none', 정렬 불가 열(undefined)은 aria-sort 미부여
- return sort === null ? 'none' : undefined;
- }
- export default function DataTable<T>({
- caption,
- captionVisible = false,
- columns,
- rows,
- rowKey,
- onRowClick,
- onSort,
- emptyMessage = '표시할 항목이 없습니다.',
- className,
- }: DataTableProps<T>)
- {
- return (
- <table className={cn('data-table', className)}>
- <caption className={captionVisible ? 'data-table__caption' : 'data-table__caption data-table__caption--sr'}>
- {caption}
- </caption>
- <thead>
- <tr>
- {columns.map(col => {
- const sortable = col.sort !== undefined;
- return (
- <th
- key={col.key}
- scope='col'
- data-align={col.align ?? 'left'}
- data-priority={col.priority ?? 'high'}
- aria-sort={ariaSort(col.sort)}
- className={col.headerClassName}
- >
- {sortable && onSort ? (
- <button
- type='button'
- className='data-table__sort'
- onClick={() => onSort(col.key)}
- >
- {col.header}
- <span className='data-table__sort-icon' aria-hidden='true'>
- {col.sort === 'asc' ? '▲' : col.sort === 'desc' ? '▼' : '⇅'}
- </span>
- </button>
- ) : (
- col.header
- )}
- </th>
- );
- })}
- </tr>
- </thead>
- <tbody>
- {rows.length === 0 ? (
- <tr className='data-table__empty-row'>
- <td colSpan={columns.length} className='data-table__empty'>{emptyMessage}</td>
- </tr>
- ) : (
- rows.map((row, index) => (
- <tr
- key={rowKey(row, index)}
- onClick={onRowClick ? () => onRowClick(row, index) : undefined}
- className={onRowClick ? 'data-table__row--clickable' : undefined}
- >
- {columns.map(col => (
- <td
- key={col.key}
- data-align={col.align ?? 'left'}
- data-priority={col.priority ?? 'high'}
- className={col.cellClassName}
- >
- {col.cell(row, index)}
- </td>
- ))}
- </tr>
- ))
- )}
- </tbody>
- </table>
- );
- }
|