/** * @title Dynamic Table * @description 정렬·페이지 나누기·로딩 상태를 내장한 데이터 표예요. */import * as React from 'react';import { Tooltip as TooltipPrimitive } from 'radix-ui';import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight } from 'lucide-react';import { cn } from '@/lib/utils';import { Button, IconButton } from '@/components/ui/button';/* ------------------------------------------------------------------------------------------------- * Types — @atlaskit/dynamic-table 의 types.d.ts 와 같은 이름 * -----------------------------------------------------------------------------------------------*/export type SortOrderType = 'ASC' | 'DESC';export type LoadingSpinnerSizeType = 'small' | 'large';export interface RowCellType { /** 이 열을 정렬할 때 쓰는 값. 문자열·숫자 모두 가능해요. */ key?: string | number; /** 셀이 차지할 열 개수 */ colSpan?: number; /** 셀 내용 */ content?: React.ReactNode; testId?: string;}export interface HeadCellType extends RowCellType { /** 이 열을 정렬할 수 있는지 */ isSortable?: boolean; /** 열 너비 (백분율) */ width?: number; /** 공간이 모자랄 때 말줄임할지 (isFixedSize 와 함께) */ shouldTruncate?: boolean; /** 오름차순 정렬 버튼 툴팁. 기본 "오름차순 정렬" */ ascendingSortTooltip?: string; /** 내림차순 정렬 버튼 툴팁. 기본 "내림차순 정렬" */ descendingSortTooltip?: string; /** 정렬 버튼의 aria-roledescription. 기본 "정렬 버튼" */ buttonAriaRoleDescription?: string; /** 머리글이 아이콘뿐일 때, 정렬 아이콘이 보이면 머리글 아이콘을 대신해요. */ isIconOnlyHeader?: boolean;}export interface HeadType { cells: HeadCellType[];}export interface RowType extends Omit<React.ComponentPropsWithoutRef<'tr'>, 'key'> { cells: RowCellType[]; key?: string; /** 행 강조. 선택·포커스를 나타내는 용도로 쓰지 않아요. */ isHighlighted?: boolean; testId?: string;}export interface I18nShape { prev: string; next: string; label: string; pageLabel?: string;}export interface SortData { key: string | null; sortOrder: SortOrderType | null; item: HeadCellType;}export interface StatelessProps { caption?: React.ReactNode; head?: HeadType; rows?: RowType[]; emptyView?: React.ReactElement; loadingSpinnerSize?: LoadingSpinnerSizeType; isLoading?: boolean; loadingLabel?: string; isFixedSize?: boolean; rowsPerPage?: number; totalRows?: number; onSetPage?: (page: number) => void; onSort?: (data: SortData) => void; onPageRowsUpdate?: (pageRows: RowType[]) => void; page?: number; sortKey?: string; sortOrder?: SortOrderType; paginationi18n?: I18nShape; highlightedRowIndex?: number | number[]; testId?: string; label?: string;}export interface StatefulProps extends Omit<StatelessProps, 'totalRows'> { defaultPage?: number; defaultSortKey?: string; defaultSortOrder?: SortOrderType;}/* ------------------------------------------------------------------------------------------------- * Spinner — @atlaskit/spinner (circle r=7, stroke 1.5, icon.subtle, 0.86s) * -----------------------------------------------------------------------------------------------*/const spinnerSizes = { small: 16, medium: 24, large: 48 } as const;function TableSpinner({ size = 'large', label }: { size?: keyof typeof spinnerSizes; label?: string }) { const px = spinnerSizes[size]; return ( <span data-slot="dynamic-table-spinner" className="inline-flex animate-spin align-middle [animation-duration:0.86s] [animation-timing-function:cubic-bezier(0.4,0.15,0.6,0.85)]" style={{ width: px, height: px }} > <svg width={px} height={px} viewBox="0 0 16 16" role={label ? 'img' : 'none'} aria-label={label || undefined} className="text-ds-icon-subtle" > <circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" strokeWidth="1.5" strokeDasharray="33 60" /> </svg> </span> );}/* ------------------------------------------------------------------------------------------------- * Pagination — @atlaskit/pagination (subtle 버튼, max 7, 말줄임) * -----------------------------------------------------------------------------------------------*/function collapseRange(total: number, current: number, max: number): Array<number | 'ellipsis-1' | 'ellipsis-2'> { const all = Array.from({ length: total }, (_, i) => i); if (total <= max) return all; const hasStart = max - 4 < current; const hasEnd = current < total - max + 3; if (hasStart && !hasEnd) { const count = max - 2; return [0, 'ellipsis-1', ...all.slice(total - count)]; } if (!hasStart && hasEnd) { const count = max - 2; return [...all.slice(0, count), 'ellipsis-1', total - 1]; } const count = max - 4; const start = current - Math.floor(count / 2); return [0, 'ellipsis-1', ...all.slice(start, current + count - 1), 'ellipsis-2', total - 1];}function TablePagination({ total, value, onChange, i18n, isDisabled, testId,}: { total: number; value: number; onChange: (page: number) => void; i18n: I18nShape; isDisabled?: boolean; testId?: string;}) { const selectedIndex = value - 1; return ( <nav data-slot="dynamic-table-pagination" aria-label={i18n.label} data-testid={testId && `${testId}--pagination`}> <div className="flex items-center"> <IconButton appearance="subtle" label={i18n.prev} icon={<ChevronLeft />} isDisabled={isDisabled || selectedIndex === 0} onClick={() => onChange(value - 1)} /> <ul className="m-ds-0 flex list-none items-baseline p-ds-0"> {collapseRange(total, selectedIndex, 7).map((item) => typeof item === 'number' ? ( <li key={`page-${item}`} className="mt-ds-0 flex font-ds-body"> <Button appearance="subtle" isSelected={item === selectedIndex} isDisabled={isDisabled} aria-current={item === selectedIndex ? 'page' : undefined} aria-label={`${i18n.pageLabel ?? '페이지'} ${item + 1}`} onClick={() => onChange(item + 1)} > {item + 1} </Button> </li> ) : ( <li key={item} className="relative flex px-ds-100 ds-font-body text-ds-text"> <span aria-hidden>…</span> <span className="sr-only">페이지 건너뜀</span> </li> ), )} </ul> <IconButton appearance="subtle" label={i18n.next} icon={<ChevronRight />} isDisabled={isDisabled || selectedIndex === total - 1} onClick={() => onChange(value + 1)} /> </div> </nav> );}/* ------------------------------------------------------------------------------------------------- * Sorting — with-sorted-page-rows 와 같은 규칙 (숫자 → 문자열 순, Intl.Collator numeric) * -----------------------------------------------------------------------------------------------*/function getSortingCellValue(cells: RowCellType[], head: HeadType, sortKey: string) { for (let i = 0; i < cells.length; i++) { if (head.cells[i]?.key === sortKey) return cells[i]?.key; } return undefined;}function getSortedRows(head: HeadType | undefined, rows: RowType[], sortKey?: string, sortOrder?: SortOrderType) { if (!sortKey || !head) return rows; const modifier = sortOrder === 'ASC' ? 1 : -1; const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'accent' }); return Array.from(rows).sort((a, b) => { const valA = getSortingCellValue(a.cells, head, sortKey); const valB = getSortingCellValue(b.cells, head, sortKey); if (valA === undefined || valB === undefined) return modifier; if (typeof valA !== typeof valB) { if (typeof valA === 'number') return -1; if (typeof valB === 'number') return 1; } if (typeof valA === 'string' && typeof valB === 'string') return modifier * collator.compare(valA, valB); if ((!valA && valA !== 0) || valA < valB) return -modifier; if ((!valB && valB !== 0) || valA > valB) return modifier; return 0; });}function toggleSortOrder(order?: SortOrderType): SortOrderType { return order === 'ASC' ? 'DESC' : order === 'DESC' ? 'ASC' : 'ASC';}/* ------------------------------------------------------------------------------------------------- * Styled parts — styled/*.tsx 해독 값 * -----------------------------------------------------------------------------------------------*/// styled/table-cell + head-cell 공통: 4px/8px 패딩, 첫 열 왼쪽·마지막 열 오른쪽 패딩 0const cellBase = 'border-none px-ds-100 py-ds-050 text-left first-of-type:ps-ds-0 last:pe-ds-0';function widthStyle(width?: number): React.CSSProperties | undefined { return typeof width !== 'undefined' ? { width: `${width}%` } : undefined;}interface TableHeadCellProps extends Omit<React.ComponentProps<'th'>, 'content'> { content?: React.ReactNode; isSortable?: boolean; sortOrder?: SortOrderType; isFixedSize?: boolean; shouldTruncate?: boolean; width?: number; isActive?: boolean; ascendingSortTooltip?: string; descendingSortTooltip?: string; buttonAriaRoleDescription?: string; isIconOnlyHeader?: boolean; onSortClick?: () => void; testId?: string;}function DynamicTableHeadCell({ content, isSortable, sortOrder, isFixedSize, shouldTruncate, width, isActive: isActiveProp, ascendingSortTooltip = '오름차순 정렬', descendingSortTooltip = '내림차순 정렬', buttonAriaRoleDescription = '정렬 버튼', isIconOnlyHeader, onSortClick, testId, className, style, ...props}: TableHeadCellProps) { const [isHovered, setIsHovered] = React.useState(false); const [isFocused, setIsFocused] = React.useState(false); const isActive = isActiveProp || sortOrder !== undefined; const isSortIconVisible = isHovered || isActive || isFocused; const isVisibleIconOnlyHeader = isSortIconVisible && isIconOnlyHeader; const truncate = isFixedSize && shouldTruncate; // 내용이 없으면 th 대신 빈 td (axe empty-table-header) const Comp = content ? 'th' : 'td'; return ( <Comp data-slot="dynamic-table-head-cell" aria-sort={sortOrder === 'ASC' ? 'ascending' : sortOrder === 'DESC' ? 'descending' : undefined} data-testid={testId && `${testId}--head--cell`} style={{ ...style, ...widthStyle(width) }} className={cn( cellBase, 'relative border-b-(length:--ds-border-width-selected) border-solid border-ds-border align-middle ds-font-body-small font-ds-bold text-ds-text-subtle', 'focus-visible:outline-(length:--ds-border-width-focused) focus-visible:outline-solid focus-visible:outline-ds-border-focused', truncate && 'truncate', isFixedSize && 'overflow-hidden', className, )} {...props} > {isSortable ? ( <div className="flex items-center" onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} > <TooltipPrimitive.Provider delayDuration={300}> <TooltipPrimitive.Root> <TooltipPrimitive.Trigger asChild> <button type="button" aria-roledescription={buttonAriaRoleDescription} onClick={onSortClick} className={cn( 'flex cursor-pointer items-center rounded-ds-small bg-transparent p-ds-0 text-left', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', truncate && 'min-w-0 max-w-full', )} > <span className={cn( 'flex', isVisibleIconOnlyHeader ? '-ms-ds-300 opacity-0' : 'pe-ds-050 opacity-100', truncate && 'min-w-0 flex-1', )} > <span className={cn( 'ds-font-body-small font-ds-bold text-ds-text-subtle', truncate && 'block min-w-0 flex-1 truncate', )} > {content} </span> </span> <span className={cn( 'flex bg-transparent p-ds-050 text-ds-text-subtle', isSortIconVisible ? 'opacity-100' : 'opacity-0', )} > {sortOrder === 'ASC' ? ( <ArrowUp className="size-4" aria-hidden data-testid={testId && `${testId}--up--icon`} /> ) : ( <ArrowDown className="size-4" aria-hidden data-testid={testId && `${testId}--down--icon`} /> )} </span> </button> </TooltipPrimitive.Trigger> <TooltipPrimitive.Portal> <TooltipPrimitive.Content side="top" sideOffset={8} className="z-50 max-w-60 rounded-ds-small bg-ds-background-neutral-bold px-ds-075 py-ds-025 ds-font-body-small text-ds-text-inverse animate-in fade-in-0" > {sortOrder === 'ASC' ? ascendingSortTooltip : descendingSortTooltip} </TooltipPrimitive.Content> </TooltipPrimitive.Portal> </TooltipPrimitive.Root> </TooltipPrimitive.Provider> </div> ) : ( content )} </Comp> );}/* ------------------------------------------------------------------------------------------------- * DynamicTableStateless * -----------------------------------------------------------------------------------------------*/const defaultI18n: I18nShape = { prev: '이전 페이지', next: '다음 페이지', label: '페이지 나누기', pageLabel: '페이지',};function DynamicTableStateless({ caption, head, highlightedRowIndex, rows, sortKey, sortOrder, loadingLabel = '표를 불러오는 중', onPageRowsUpdate, testId, totalRows, label, isLoading = false, isFixedSize = false, rowsPerPage = Infinity, onSetPage, onSort, page = 1, emptyView, loadingSpinnerSize, paginationi18n = defaultI18n,}: StatelessProps) { const [activeSortCell, setActiveSortCell] = React.useState<number | null>(null); const rowsLength = rows?.length ?? 0; let totalPages: number; let isTotalPagesControlledExternally = false; if (totalRows && Number.isInteger(totalRows) && rowsPerPage && rowsLength && rowsLength <= totalRows) { totalPages = Math.ceil(totalRows / rowsPerPage); isTotalPagesControlledExternally = true; } else { totalPages = rowsLength && rowsPerPage ? Math.ceil(rowsLength / rowsPerPage) : 0; } totalPages = totalPages < 1 ? 1 : totalPages; const pageNumber = page > totalPages ? totalPages : page; const pageRows = React.useMemo(() => { if (!rows || !rows.length) return []; if (isTotalPagesControlledExternally) return rows; const sorted = getSortedRows(head, rows, sortKey, sortOrder); if (rowsPerPage === Infinity) return sorted; return sorted.slice((pageNumber - 1) * rowsPerPage, pageNumber * rowsPerPage); }, [rows, head, sortKey, sortOrder, pageNumber, rowsPerPage, isTotalPagesControlledExternally]); React.useEffect(() => { onPageRowsUpdate?.(pageRows); // eslint-disable-next-line react-hooks/exhaustive-deps }, [pageRows]); const rowsExist = rowsLength > 0; const spinnerSize: LoadingSpinnerSizeType = loadingSpinnerSize ?? (pageRows.length > 2 ? 'large' : 'small'); const handleSort = (cell: HeadCellType, index: number) => { setActiveSortCell(index); if (!cell.isSortable || cell.key === undefined) return; const key = String(cell.key); const next = key !== sortKey ? 'ASC' : toggleSortOrder(sortOrder); onSort?.({ key, sortOrder: next, item: cell }); }; const isHighlighted = (row: RowType, index: number) => row.isHighlighted || (highlightedRowIndex !== undefined && (typeof highlightedRowIndex === 'number' ? highlightedRowIndex === index : highlightedRowIndex.includes(index))); return ( <div data-slot="dynamic-table"> {/* loading-container-advanced: relative + 아래 24px 여백 */} <div className="relative mb-ds-300" data-testid={testId && `${testId}--loading--container--advanced`}> <table aria-label={label} inert={isLoading || undefined} data-testid={testId && `${testId}--table`} className={cn( 'w-full border-separate border-spacing-0 ds-font-body text-ds-text', isFixedSize && 'table-fixed', rowsExist && 'border-b-(length:--ds-border-width-selected) border-solid border-ds-border', )} > {!!caption && ( <caption className="mt-ds-300 mb-ds-100 text-left ds-font-heading-medium text-ds-text">{caption}</caption> )} {head && ( <thead className="border-b-0" data-testid={testId && `${testId}--head`}> <tr> {head.cells.map((cell, index) => { const { key, content, isSortable, width, shouldTruncate, ascendingSortTooltip, descendingSortTooltip, buttonAriaRoleDescription, isIconOnlyHeader, colSpan, testId: cellTestId, } = cell; return ( <DynamicTableHeadCell key={key ?? index} content={content} colSpan={colSpan} isSortable={!!isSortable} width={width} shouldTruncate={shouldTruncate} isFixedSize={isFixedSize} isActive={activeSortCell === index} sortOrder={key !== undefined && String(key) === sortKey ? sortOrder : undefined} ascendingSortTooltip={ascendingSortTooltip} descendingSortTooltip={descendingSortTooltip} buttonAriaRoleDescription={buttonAriaRoleDescription} isIconOnlyHeader={isIconOnlyHeader} onSortClick={() => handleSort(cell, index)} testId={cellTestId || (testId && `${testId}--head`)} /> ); })} </tr> </thead> )} {rowsExist && ( <tbody data-testid={testId && `${testId}--body`} className={cn(isLoading && 'pointer-events-none opacity-(--ds-opacity-loading)')} > {pageRows.map((row, rowIndex) => { const { cells, key: rowKey, isHighlighted: _h, testId: rowTestId, className: rowClassName, ...rowProps } = row; const highlighted = isHighlighted(row, rowIndex); return ( <tr key={rowKey ?? rowIndex} data-slot="dynamic-table-row" data-highlighted={highlighted || undefined} data-testid={rowTestId || (testId && `${testId}--row-${rowKey}`)} className={cn( 'bg-ds-background-neutral-subtle focus-visible:outline-(length:--ds-border-width-focused) focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-ds-border-focused', highlighted ? 'bg-ds-background-selected hover:bg-ds-background-selected-hovered' : 'hover:bg-ds-background-neutral-subtle-hovered', rowClassName, )} {...rowProps} > {cells.map((cell, cellIndex) => { const { content, testId: cellTestId, key: _k, colSpan } = cell; const headCell = head?.cells[cellIndex]; return ( <td key={cellIndex} colSpan={colSpan} data-testid={cellTestId || (testId && `${testId}--cell-${cellIndex}`)} style={widthStyle(headCell?.width)} className={cn( cellBase, isFixedSize && 'overflow-hidden', isFixedSize && headCell?.shouldTruncate && 'truncate', )} > {content} </td> ); })} </tr> ); })} </tbody> )} </table> {isLoading && rowsExist && ( <div className="pointer-events-none absolute inset-0 flex items-center justify-center" data-testid={testId && `${testId}--spinner-backdrop`} > <div className="relative top-0"> <TableSpinner size={spinnerSize} label={loadingLabel} /> </div> </div> )} </div> {totalPages > 1 && ( <div className="flex justify-center" data-testid={testId && `${testId}--pagination--container`}> <TablePagination total={totalPages} value={pageNumber} onChange={(p) => onSetPage?.(p)} i18n={{ ...defaultI18n, ...paginationi18n }} isDisabled={isLoading} testId={testId} /> </div> )} {!rowsExist && (isLoading || emptyView) && ( <div className="relative" data-testid={testId && `${testId}--container`}> <div className={cn(isLoading && 'pointer-events-none opacity-[0.22]')}> {isLoading ? ( <div className="h-[9rem]" /> ) : ( <div className="m-auto w-1/2 p-[10px] text-center" data-testid={testId && `${testId}--empty-view-container`}> {emptyView} </div> )} </div> {isLoading && ( <div className="absolute inset-0 flex items-center justify-center"> <TableSpinner size="large" label={loadingLabel} /> </div> )} </div> )} </div> );}/* ------------------------------------------------------------------------------------------------- * DynamicTable (stateful) — 정렬·페이지 상태를 스스로 관리해요. * -----------------------------------------------------------------------------------------------*/function DynamicTable({ defaultPage = 1, defaultSortKey, defaultSortOrder, page: pageProp, sortKey: sortKeyProp, sortOrder: sortOrderProp, onSetPage, onSort, ...props}: StatefulProps) { const [page, setPage] = React.useState(defaultPage); const [sortKey, setSortKey] = React.useState<string | undefined>(defaultSortKey); const [sortOrder, setSortOrder] = React.useState<SortOrderType | undefined>(defaultSortOrder); return ( <DynamicTableStateless {...props} page={pageProp ?? page} sortKey={sortKeyProp ?? sortKey} sortOrder={sortOrderProp ?? sortOrder} onSetPage={(p) => { onSetPage?.(p); setPage(p); }} onSort={(data) => { onSort?.(data); setSortKey(data.key ?? undefined); setSortOrder(data.sortOrder ?? undefined); }} /> );}export { DynamicTable, DynamicTableStateless };