/** * @title Table Tree * @description 계층 구조 데이터를 펼치고 접을 수 있는 행으로 보여주는 표예요. */import * as React from 'react';import { ChevronDown, ChevronRight } from 'lucide-react';import { cn } from '@/lib/utils';import { IconButton } from '@/components/ui/button';/* ------------------------------------------------------------------------------------------------- * Column width context — internal/context + with-column-width * -----------------------------------------------------------------------------------------------*/export type ColumnWidth = string | number;const TableTreeContext = React.createContext<{ setColumnWidth: (columnIndex: number, width: ColumnWidth) => void; getColumnWidth: (columnIndex: number) => ColumnWidth | null;}>({ setColumnWidth: () => {}, getColumnWidth: () => null,});function useColumnWidth(width: ColumnWidth | undefined, columnIndex: number | undefined) { const { setColumnWidth, getColumnWidth } = React.useContext(TableTreeContext); React.useEffect(() => { if (width !== undefined && columnIndex !== undefined) setColumnWidth(columnIndex, width); }, [width, columnIndex, setColumnWidth]); if (width !== null && width !== undefined) return width; if (columnIndex !== undefined) return getColumnWidth(columnIndex) ?? undefined; return undefined;}function toItemId(id: string) { return `tabletreeitem-${id}`.replace(/[^-_a-zA-Z0-9]/g, '');}/* ------------------------------------------------------------------------------------------------- * CommonCell — padding 8px 24px 8px var(--indent, 24px), min-height 40px, font.body, color.text * -----------------------------------------------------------------------------------------------*/interface CommonCellProps extends React.ComponentProps<'div'> { indent?: string; width?: ColumnWidth;}function CommonCell({ indent, width, className, style, ...props }: CommonCellProps) { return ( <div role="gridcell" className={cn( 'relative box-border flex min-h-10 items-center py-ds-100 pe-ds-300 ps-[var(--indent,var(--ds-space-300,24px))]', 'ds-font-body text-ds-text hyphens-auto [word-break:break-word]', className, )} style={{ ...style, ['--indent' as string]: indent, width: width ?? undefined }} {...props} /> );}/* ------------------------------------------------------------------------------------------------- * Headers / Header * -----------------------------------------------------------------------------------------------*/export interface HeadersProps { children?: React.ReactNode;}/** 머리글 행. 자식 Header 에 columnIndex 를 넘겨줘요. */function Headers({ children }: HeadersProps) { return ( <div role="row" data-slot="table-tree-headers" className="flex border-b-(length:--ds-border-width-selected) border-solid border-ds-border" > {React.Children.map(children, (header, index) => React.isValidElement<HeaderProps>(header) ? React.cloneElement(header, { columnIndex: index }) : header, )} </div> );}export interface HeaderProps extends Omit<React.ComponentProps<'div'>, 'role'> { /** 열 너비. 지정하면 같은 열의 Cell 도 이 너비를 따라요. */ width?: ColumnWidth; columnIndex?: number; role?: string;}function Header({ width, columnIndex, children, role = 'columnheader', className, ...props }: HeaderProps) { const columnWidth = useColumnWidth(width, columnIndex); return ( <CommonCell role={role} data-slot="table-tree-header" width={columnWidth} className={cn('ds-font-body-small font-ds-bold text-ds-text-subtle', className)} {...props} > {children} </CommonCell> );}/* ------------------------------------------------------------------------------------------------- * Cell * -----------------------------------------------------------------------------------------------*/export interface CellProps extends React.ComponentProps<'div'> { /** 한 줄로 말줄임해요. 접근성을 위해 가능하면 줄바꿈을 권장해요. */ singleLine?: boolean; /** 들여쓰기 단계. Row 가 첫 셀에 자동으로 넣어요. */ indentLevel?: number; width?: ColumnWidth; columnIndex?: number;}function Cell({ children, singleLine, indentLevel, width, columnIndex, className, ...props }: CellProps) { const columnWidth = useColumnWidth(width, columnIndex); return ( <CommonCell data-slot="table-tree-cell" indent={indentLevel ? `calc(var(--ds-space-300, 24px) * ${indentLevel})` : undefined} width={columnWidth} className={className} {...props} > <span className={cn(singleLine && 'truncate')}>{children}</span> </CommonCell> );}/* ------------------------------------------------------------------------------------------------- * Spinner (small 16px) — 하위 행 로딩 * -----------------------------------------------------------------------------------------------*/function TreeSpinner({ label }: { label?: string }) { return ( <span className="inline-flex size-4 animate-spin align-middle [animation-duration:0.86s] [animation-timing-function:cubic-bezier(0.4,0.15,0.6,0.85)]"> <svg viewBox="0 0 16 16" className="size-4 text-ds-icon-subtle" role={label ? 'img' : 'none'} aria-label={label}> <circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" strokeWidth="1.5" strokeDasharray="33 60" /> </svg> </span> );}function LoaderItem({ depth = 1, loadingLabel }: { depth?: number; loadingLabel?: string }) { return ( <div role="row" className="flex border-b-(length:--ds-border-width) border-solid border-ds-border"> <CommonCell indent={`calc(var(--ds-space-300, 24px) * ${depth})`} width="100%"> <span className={cn( 'absolute flex w-full items-center -ms-ds-300 -mt-[3px]', depth === 1 && 'ps-[50%]', )} > <TreeSpinner label={loadingLabel} /> </span> </CommonCell> </div> );}/* ------------------------------------------------------------------------------------------------- * Rows / Items * -----------------------------------------------------------------------------------------------*/type RowElement<Item> = React.ReactElement<RowProps<Item>>;export interface RowsProps<Item> { /** 표시할 데이터. undefined 면 로딩 스피너를 보여줘요. */ items?: Item[] | null; /** 항목 하나를 Row 로 그리는 함수 */ render: (item: Item) => RowElement<Item> | null; /** 로딩 스피너의 접근성 이름. 기본 "불러오는 중" */ loadingLabel?: string;}function Items<Item extends { id: string }>({ depth = 0, items, loadingLabel, render,}: RowsProps<Item> & { depth?: number }) { if (!items) return <LoaderItem depth={depth + 1} loadingLabel={loadingLabel} />; return ( <> {items.map((data) => { const row = render(data); if (!row) return null; const itemId = row.props.itemId; const childItems = row.props.items; return React.cloneElement(row, { key: data.id, depth: depth + 1, data, renderChildren: () => ( <div id={itemId ? toItemId(itemId) : undefined}> <Items depth={depth + 1} items={childItems} render={render} loadingLabel={loadingLabel} /> </div> ), }); })} </> );}function Rows<Item extends { id: string }>({ items, render, loadingLabel = '불러오는 중' }: RowsProps<Item>) { return ( <div role="rowgroup" data-slot="table-tree-rows"> <Items items={items} render={render} loadingLabel={loadingLabel} /> </div> );}/* ------------------------------------------------------------------------------------------------- * Row * -----------------------------------------------------------------------------------------------*/export interface RowProps<Item> { /** 하위 행이 있는지 */ hasChildren?: boolean; /** Cell 들 */ children?: React.ReactNode; /** 행 ID. 펼침 버튼의 aria-controls 와 라벨에 쓰여요. */ itemId?: string; /** 하위 행 데이터. undefined 면 펼쳤을 때 로딩 스피너가 보여요. */ items?: Item[] | null; /** 펼침 상태 (제어) */ isExpanded?: boolean; /** 처음 펼침 상태 (비제어) */ isDefaultExpanded?: boolean; /** 펼치기 버튼 접근성 이름. 기본 "펼치기" */ expandLabel?: string; /** 접기 버튼 접근성 이름. 기본 "접기" */ collapseLabel?: string; onCollapse?: (data: Item) => void; onExpand?: (data: Item) => void; renderChildren?: () => React.ReactNode; /** 행 아무 곳이나 눌러도 펼치고 접어요. 셀에 버튼 같은 상호작용 요소가 있으면 쓰지 않아요. */ shouldExpandOnClick?: boolean; data?: Item; depth?: number; /** 펼침 버튼 라벨에 덧붙일 열 (items 방식이면 열 이름, Rows 방식이면 열 번호) */ mainColumnForExpandCollapseLabel?: string | number;}function getExtendedLabel(cellContent: unknown, cellIndex: number, main?: string | number): string | undefined { if (main === undefined) return undefined; if (React.isValidElement<Record<string, unknown>>(cellContent)) { const value = cellContent.props[String(main).toLowerCase()]; return typeof value === 'string' || typeof value === 'number' ? String(value) : undefined; } if (cellIndex === main && (typeof cellContent === 'string' || typeof cellContent === 'number')) { return String(cellContent); } return undefined;}function Chevron({ isExpanded, ariaControls, collapseLabel = '접기', expandLabel = '펼치기', rowId, extendedLabel, onExpandToggle,}: { isExpanded: boolean; ariaControls?: string; collapseLabel?: string; expandLabel?: string; rowId: string; extendedLabel?: string; onExpandToggle: (e: React.MouseEvent) => void;}) { const getLabel = (base: string) => (extendedLabel ? `${extendedLabel} 행 ${base}` : `${rowId} 행 ${base}`); return ( <span data-slot="table-tree-chevron" className="absolute -ms-ds-300 -mt-[3px] flex items-center"> <IconButton appearance="subtle" spacing="compact" className="[&_svg]:size-3" icon={isExpanded ? <ChevronDown /> : <ChevronRight />} label={isExpanded ? getLabel(collapseLabel) : getLabel(expandLabel)} aria-expanded={isExpanded} aria-controls={isExpanded ? ariaControls : undefined} onClick={onExpandToggle} /> </span> );}function Row<Item extends { id: string }>({ shouldExpandOnClick, hasChildren, depth, renderChildren, isDefaultExpanded, data, onExpand, onCollapse, mainColumnForExpandCollapseLabel, expandLabel, collapseLabel, itemId, children, isExpanded: isExpandedProp,}: RowProps<Item>) { const [isExpandedState, setIsExpandedState] = React.useState(isDefaultExpanded || false); React.useEffect(() => { if (isExpandedProp === undefined && isDefaultExpanded !== undefined) setIsExpandedState(isDefaultExpanded); }, [isDefaultExpanded, isExpandedProp]); const isExpanded = isExpandedProp !== undefined ? isExpandedProp : isExpandedState; const onExpandStateChange = (next: boolean) => { if (!data) return; if (next) onExpand?.(data); else onCollapse?.(data); }; const onExpandToggle = (e: React.MouseEvent) => { e.stopPropagation(); if (isExpandedProp !== undefined) { onExpandStateChange(!isExpandedProp); } else { onExpandStateChange(!isExpandedState); setIsExpandedState((prev) => !prev); } }; // 텍스트를 드래그해 선택하는 중에는 펼치지 않아요. const onRowClick = (e: React.MouseEvent) => { const selection = window.getSelection()?.toString() || ''; if (selection.length === 0) onExpandToggle(e); }; let cellIndex = -1; const cells = React.Children.map(children, (cell) => { if (!React.isValidElement<CellProps>(cell)) return cell; cellIndex += 1; const isFirstCell = cellIndex === 0; const content = cell.props.children; let cellContent: React.ReactNode = content; if (isFirstCell && hasChildren) { cellContent = ( <> <Chevron isExpanded={isExpanded} expandLabel={expandLabel} collapseLabel={collapseLabel} extendedLabel={getExtendedLabel(content, cellIndex, mainColumnForExpandCollapseLabel)} onExpandToggle={onExpandToggle} ariaControls={itemId ? toItemId(itemId) : undefined} rowId={itemId ?? ''} /> {content} </> ); } return React.cloneElement(cell, { columnIndex: cellIndex, indentLevel: isFirstCell ? depth : 0 }, cellContent); }); return ( <> <div role="row" data-slot="table-tree-row" aria-expanded={hasChildren ? isExpanded : undefined} aria-level={depth || undefined} onClick={hasChildren && shouldExpandOnClick ? onRowClick : undefined} className={cn( 'flex border-b-(length:--ds-border-width) border-solid border-ds-border', hasChildren && shouldExpandOnClick && 'cursor-pointer', )} > {cells} </div> {hasChildren && isExpanded && renderChildren?.()} </> );}/* ------------------------------------------------------------------------------------------------- * TableTree * -----------------------------------------------------------------------------------------------*/export interface TableTreeItem { id: string; content?: Record<string, unknown>; children?: TableTreeItem[];}export interface TableTreeProps<Item> { /** Headers/Rows 를 직접 조합할 때 */ children?: React.ReactNode; /** 열마다 셀을 그리는 컴포넌트. 항목의 content 가 props 로 들어가요. */ columns?: React.ElementType[]; /** 열 너비 */ columnWidths?: ColumnWidth[]; /** 열 머리글 텍스트 */ headers?: string[]; /** 행 아무 곳이나 눌러도 펼치고 접어요. */ shouldExpandOnClick?: boolean; /** 기본 사용 시 표 데이터 */ items?: Item[] | null; /** 펼침 버튼 라벨에 덧붙일 열 이름(또는 번호) */ mainColumnForExpandCollapseLabel?: string | number; /** 표의 접근성 이름 (aria-label) */ label?: string; /** 표의 이름이 되는 요소 ID (aria-labelledby) */ referencedLabel?: string;}const emptyColumnWidths: ColumnWidth[] = [];function TableTree<Item extends { id: string; content?: object; children?: Item[] }>({ children, columns, columnWidths: defaultColumnWidths = emptyColumnWidths, headers, shouldExpandOnClick, items, mainColumnForExpandCollapseLabel, label, referencedLabel,}: TableTreeProps<Item>) { const [columnWidths, setColumnWidths] = React.useState<ColumnWidth[]>(defaultColumnWidths); const setColumnWidth = React.useCallback((columnIndex: number, width: ColumnWidth) => { setColumnWidths((prev) => { if (prev[columnIndex] === width) return prev; const next = [...prev]; next[columnIndex] = width; return next; }); }, []); const getColumnWidth = React.useCallback((columnIndex: number) => columnWidths[columnIndex] ?? null, [columnWidths]); const contextValue = React.useMemo(() => ({ setColumnWidth, getColumnWidth }), [setColumnWidth, getColumnWidth]); return ( <TableTreeContext.Provider value={contextValue}> <div role="treegrid" aria-readonly aria-label={label} aria-labelledby={referencedLabel} data-slot="table-tree"> {headers && ( <Headers> {headers.map((header, index) => ( <Header key={index} columnIndex={index} width={columnWidths[index]}> {header} </Header> ))} </Headers> )} {columns && items && ( <Rows<Item> items={items} render={({ id, children: childItems, content }) => ( <Row<Item> itemId={id} items={childItems} hasChildren={!!childItems && childItems.length > 0} shouldExpandOnClick={shouldExpandOnClick} mainColumnForExpandCollapseLabel={mainColumnForExpandCollapseLabel} > {columns.map((CellContent, index) => ( <Cell key={`cell-${index}`} columnIndex={index} width={columnWidths[index]}> <CellContent {...(content ?? {})} /> </Cell> ))} </Row> )} /> )} {children} </div> </TableTreeContext.Provider> );}export { TableTree, Headers, Header, Rows, Row, Cell, TableTreeContext };