Files

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&nbsp;<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,
});