358 lines
9.5 KiB
TypeScript
358 lines
9.5 KiB
TypeScript
import { createContext, useContext, type ReactNode, type HTMLAttributes, type TdHTMLAttributes, type ThHTMLAttributes } from 'react';
|
|
import { tv } from 'tailwind-variants';
|
|
|
|
// ============================================================================
|
|
// Styles
|
|
// ============================================================================
|
|
|
|
const tableStyles = tv({
|
|
slots: {
|
|
wrapper: 'flex flex-col gap-2 w-full',
|
|
scrollContainer: 'relative flex-1 overflow-y-auto overflow-x-auto min-h-0',
|
|
table: 'w-full border-collapse',
|
|
thead: 'sticky top-0 z-1 bg-primary-tertiary02',
|
|
th: 'px-2 h-[42px] align-middle whitespace-pre-line text-sm font-bold text-primary-secondary',
|
|
thGrouped: 'h-[38px] border-l border-r border-white text-sm font-bold text-primary-secondary',
|
|
thGroupedBottom: 'h-[38px] border-l border-r border-b border-white text-sm font-bold text-primary-secondary',
|
|
tr: 'cursor-default transition-colors duration-200',
|
|
trEmpty: 'text-center align-middle text-sm text-dabeeo-gray-44',
|
|
td: 'border-b border-dabeeo-gray-eb py-2.5 px-2 text-sm text-dabeeo-gray-44 align-middle whitespace-nowrap overflow-hidden text-ellipsis font-medium',
|
|
caption: 'flex justify-between text-sm',
|
|
captionLeft: 'flex items-end gap-6',
|
|
total: 'font-medium text-dabeeo-gray-44',
|
|
totalCount: 'font-bold text-primary',
|
|
loading: 'flex py-2 gap-2 w-full items-center justify-center h-16',
|
|
spinner: 'w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin',
|
|
},
|
|
variants: {
|
|
isLayoutFixed: {
|
|
true: { table: 'table-fixed' },
|
|
},
|
|
isFullHeight: {
|
|
true: { wrapper: 'h-full' },
|
|
},
|
|
isHoverable: {
|
|
true: { tr: 'hover:bg-dabeeo-gray-eb' },
|
|
},
|
|
isClickable: {
|
|
true: { tr: 'cursor-pointer' },
|
|
},
|
|
isSelected: {
|
|
true: { tr: 'bg-primary-tertiary01' },
|
|
},
|
|
},
|
|
});
|
|
|
|
// ============================================================================
|
|
// Context
|
|
// ============================================================================
|
|
|
|
type TableContextValue = {
|
|
isLayoutFixed: boolean;
|
|
isFullHeight: boolean;
|
|
isHoverable: boolean;
|
|
isClickable: boolean;
|
|
columnCount: number;
|
|
setColumnCount: (count: number) => void;
|
|
};
|
|
|
|
const TableContext = createContext<TableContextValue | null>(null);
|
|
|
|
function useTableContext() {
|
|
const context = useContext(TableContext);
|
|
if (!context) {
|
|
throw new Error('Table compound components must be used within a Table component');
|
|
}
|
|
return context;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Table (Root)
|
|
// ============================================================================
|
|
|
|
type TableRootProps = {
|
|
children: ReactNode;
|
|
isLayoutFixed?: boolean;
|
|
isFullHeight?: boolean;
|
|
isHoverable?: boolean;
|
|
isClickable?: boolean;
|
|
className?: string;
|
|
};
|
|
|
|
function TableRoot({
|
|
children,
|
|
isLayoutFixed = true,
|
|
isFullHeight = true,
|
|
isHoverable = true,
|
|
isClickable = true,
|
|
className,
|
|
}: TableRootProps) {
|
|
const [columnCount, setColumnCount] = useState(0);
|
|
const styles = tableStyles({ isLayoutFixed, isFullHeight });
|
|
|
|
return (
|
|
<TableContext.Provider
|
|
value={{ isLayoutFixed, isFullHeight, isHoverable, isClickable, columnCount, setColumnCount }}
|
|
>
|
|
<div className={styles.wrapper({ className })}>{children}</div>
|
|
</TableContext.Provider>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Caption
|
|
// ============================================================================
|
|
|
|
type CaptionProps = {
|
|
children?: ReactNode;
|
|
className?: string;
|
|
};
|
|
|
|
function Caption({ children, className }: CaptionProps) {
|
|
const styles = tableStyles();
|
|
return <div className={styles.caption({ className })}>{children}</div>;
|
|
}
|
|
|
|
type CaptionLeftProps = {
|
|
children?: ReactNode;
|
|
className?: string;
|
|
};
|
|
|
|
function CaptionLeft({ children, className }: CaptionLeftProps) {
|
|
const styles = tableStyles();
|
|
return <div className={styles.captionLeft({ className })}>{children}</div>;
|
|
}
|
|
|
|
type CaptionRightProps = {
|
|
children?: ReactNode;
|
|
className?: string;
|
|
};
|
|
|
|
function CaptionRight({ children, className }: CaptionRightProps) {
|
|
return <div className={className}>{children}</div>;
|
|
}
|
|
|
|
type TotalProps = {
|
|
count: number;
|
|
className?: string;
|
|
};
|
|
|
|
function Total({ count, className }: TotalProps) {
|
|
const styles = tableStyles();
|
|
return (
|
|
<div className={styles.total({ className })}>
|
|
Total <span className={styles.totalCount()}>{count.toLocaleString()}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Container (scroll wrapper + table)
|
|
// ============================================================================
|
|
|
|
type ContainerProps = {
|
|
children: ReactNode;
|
|
className?: string;
|
|
};
|
|
|
|
function Container({ children, className }: ContainerProps) {
|
|
const { isLayoutFixed } = useTableContext();
|
|
const styles = tableStyles({ isLayoutFixed });
|
|
|
|
return (
|
|
<div className={styles.scrollContainer({ className })}>
|
|
<table className={styles.table()}>{children}</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Colgroup
|
|
// ============================================================================
|
|
|
|
type ColgroupProps = {
|
|
children: ReactNode;
|
|
};
|
|
|
|
function Colgroup({ children }: ColgroupProps) {
|
|
return <colgroup>{children}</colgroup>;
|
|
}
|
|
|
|
type ColProps = {
|
|
width?: number | string;
|
|
};
|
|
|
|
function Col({ width }: ColProps) {
|
|
const widthStyle = typeof width === 'number' ? `${width}px` : width || 'auto';
|
|
return <col style={{ width: widthStyle }} />;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Header
|
|
// ============================================================================
|
|
|
|
type HeaderProps = {
|
|
children: ReactNode;
|
|
className?: string;
|
|
};
|
|
|
|
function Header({ children, className }: HeaderProps) {
|
|
const styles = tableStyles();
|
|
return <thead className={styles.thead({ className })}>{children}</thead>;
|
|
}
|
|
|
|
type HeaderRowProps = {
|
|
children: ReactNode;
|
|
className?: string;
|
|
};
|
|
|
|
function HeaderRow({ children, className }: HeaderRowProps) {
|
|
return <tr className={className}>{children}</tr>;
|
|
}
|
|
|
|
type HeaderCellProps = ThHTMLAttributes<HTMLTableCellElement> & {
|
|
children?: ReactNode;
|
|
align?: 'left' | 'center' | 'right';
|
|
isGroupTitle?: boolean;
|
|
isGroupChild?: boolean;
|
|
className?: string;
|
|
};
|
|
|
|
function HeaderCell({
|
|
children,
|
|
align = 'left',
|
|
isGroupTitle = false,
|
|
isGroupChild = false,
|
|
className,
|
|
style,
|
|
...props
|
|
}: HeaderCellProps) {
|
|
const styles = tableStyles();
|
|
|
|
let cellClass = styles.th();
|
|
if (isGroupTitle) {
|
|
cellClass = styles.thGroupedBottom();
|
|
} else if (isGroupChild) {
|
|
cellClass = styles.thGrouped();
|
|
}
|
|
|
|
return (
|
|
<th align={align} className={`${cellClass} ${className || ''}`} style={style} {...props}>
|
|
{children}
|
|
</th>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Body
|
|
// ============================================================================
|
|
|
|
type BodyProps = {
|
|
children: ReactNode;
|
|
className?: string;
|
|
};
|
|
|
|
function Body({ children, className }: BodyProps) {
|
|
return <tbody className={className}>{children}</tbody>;
|
|
}
|
|
|
|
type RowProps = HTMLAttributes<HTMLTableRowElement> & {
|
|
children: ReactNode;
|
|
isSelected?: boolean;
|
|
className?: string;
|
|
};
|
|
|
|
function Row({ children, isSelected = false, className, ...props }: RowProps) {
|
|
const { isHoverable, isClickable } = useTableContext();
|
|
const styles = tableStyles({ isHoverable, isClickable, isSelected });
|
|
|
|
return (
|
|
<tr className={styles.tr({ className })} {...props}>
|
|
{children}
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
type CellProps = TdHTMLAttributes<HTMLTableCellElement> & {
|
|
children?: ReactNode;
|
|
align?: 'left' | 'center' | 'right';
|
|
className?: string;
|
|
};
|
|
|
|
function Cell({ children, align = 'left', className, style, ...props }: CellProps) {
|
|
const styles = tableStyles();
|
|
|
|
return (
|
|
<td className={styles.td({ className })} style={{ textAlign: align, ...style }} {...props}>
|
|
{children}
|
|
</td>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Empty & Loading
|
|
// ============================================================================
|
|
|
|
type EmptyProps = {
|
|
colSpan: number;
|
|
children?: ReactNode;
|
|
className?: string;
|
|
};
|
|
|
|
function Empty({ colSpan, children = '데이터가 없습니다.', className }: EmptyProps) {
|
|
const styles = tableStyles();
|
|
return (
|
|
<tr className={styles.trEmpty({ className })}>
|
|
<td colSpan={colSpan} className="py-2.5">
|
|
{children}
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
type LoadingProps = {
|
|
colSpan: number;
|
|
className?: string;
|
|
};
|
|
|
|
function Loading({ colSpan, className }: LoadingProps) {
|
|
const styles = tableStyles();
|
|
return (
|
|
<tr className={styles.trEmpty({ className })}>
|
|
<td colSpan={colSpan} className="py-2.5">
|
|
<div className={styles.loading()}>
|
|
<div className={styles.spinner()} />
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Import useState
|
|
// ============================================================================
|
|
|
|
import { useState } from 'react';
|
|
|
|
// ============================================================================
|
|
// Export
|
|
// ============================================================================
|
|
|
|
export const Table = Object.assign(TableRoot, {
|
|
Caption,
|
|
CaptionLeft,
|
|
CaptionRight,
|
|
Total,
|
|
Container,
|
|
Colgroup,
|
|
Col,
|
|
Header,
|
|
HeaderRow,
|
|
HeaderCell,
|
|
Body,
|
|
Row,
|
|
Cell,
|
|
Empty,
|
|
Loading,
|
|
});
|