diff --git a/web-app/app/shared/components/section/Section.tsx b/web-app/app/shared/components/section/Section.tsx new file mode 100644 index 0000000..7236656 --- /dev/null +++ b/web-app/app/shared/components/section/Section.tsx @@ -0,0 +1,23 @@ +import { type ReactNode } from 'react'; + +type SectionVariant = 'base' | 'card' | 'list' | 'searchFilterGray'; + +const variantClassMap: Record = { + base: 'flex-center w-full bg-white shadow-card p-6', + card: 'flex-center bg-white shadow-card p-6', + list: 'flex flex-col h-full min-h-0 gap-4 bg-white shadow-card p-6', + searchFilterGray: 'flex items-center bg-gray-100 py-3 px-7 text-kc-black-2a text-sm font-medium gap-3', +}; + +export type SectionProps = { + variant: SectionVariant; + children: ReactNode; + className?: string; +}; + +export const Section = ({ variant, children, className }: SectionProps) => { + const baseClassName = variantClassMap[variant]; + const mergedClassName = className ? `${baseClassName} ${className}` : baseClassName; + + return
{children}
; +}; diff --git a/web-app/app/shared/components/table/Table.tsx b/web-app/app/shared/components/table/Table.tsx new file mode 100644 index 0000000..6b49158 --- /dev/null +++ b/web-app/app/shared/components/table/Table.tsx @@ -0,0 +1,357 @@ +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(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 ( + +
{children}
+
+ ); +} + +// ============================================================================ +// Caption +// ============================================================================ + +type CaptionProps = { + children?: ReactNode; + className?: string; +}; + +function Caption({ children, className }: CaptionProps) { + const styles = tableStyles(); + return
{children}
; +} + +type CaptionLeftProps = { + children?: ReactNode; + className?: string; +}; + +function CaptionLeft({ children, className }: CaptionLeftProps) { + const styles = tableStyles(); + return
{children}
; +} + +type CaptionRightProps = { + children?: ReactNode; + className?: string; +}; + +function CaptionRight({ children, className }: CaptionRightProps) { + return
{children}
; +} + +type TotalProps = { + count: number; + className?: string; +}; + +function Total({ count, className }: TotalProps) { + const styles = tableStyles(); + return ( +
+ Total {count.toLocaleString()} +
+ ); +} + +// ============================================================================ +// Container (scroll wrapper + table) +// ============================================================================ + +type ContainerProps = { + children: ReactNode; + className?: string; +}; + +function Container({ children, className }: ContainerProps) { + const { isLayoutFixed } = useTableContext(); + const styles = tableStyles({ isLayoutFixed }); + + return ( +
+ {children}
+
+ ); +} + +// ============================================================================ +// Colgroup +// ============================================================================ + +type ColgroupProps = { + children: ReactNode; +}; + +function Colgroup({ children }: ColgroupProps) { + return {children}; +} + +type ColProps = { + width?: number | string; +}; + +function Col({ width }: ColProps) { + const widthStyle = typeof width === 'number' ? `${width}px` : width || 'auto'; + return ; +} + +// ============================================================================ +// Header +// ============================================================================ + +type HeaderProps = { + children: ReactNode; + className?: string; +}; + +function Header({ children, className }: HeaderProps) { + const styles = tableStyles(); + return {children}; +} + +type HeaderRowProps = { + children: ReactNode; + className?: string; +}; + +function HeaderRow({ children, className }: HeaderRowProps) { + return {children}; +} + +type HeaderCellProps = ThHTMLAttributes & { + 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 ( + + {children} + + ); +} + +// ============================================================================ +// Body +// ============================================================================ + +type BodyProps = { + children: ReactNode; + className?: string; +}; + +function Body({ children, className }: BodyProps) { + return {children}; +} + +type RowProps = HTMLAttributes & { + 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 ( + + {children} + + ); +} + +type CellProps = TdHTMLAttributes & { + children?: ReactNode; + align?: 'left' | 'center' | 'right'; + className?: string; +}; + +function Cell({ children, align = 'left', className, style, ...props }: CellProps) { + const styles = tableStyles(); + + return ( + + {children} + + ); +} + +// ============================================================================ +// Empty & Loading +// ============================================================================ + +type EmptyProps = { + colSpan: number; + children?: ReactNode; + className?: string; +}; + +function Empty({ colSpan, children = '데이터가 없습니다.', className }: EmptyProps) { + const styles = tableStyles(); + return ( + + + {children} + + + ); +} + +type LoadingProps = { + colSpan: number; + className?: string; +}; + +function Loading({ colSpan, className }: LoadingProps) { + const styles = tableStyles(); + return ( + + +
+
+
+ + + ); +} + +// ============================================================================ +// 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, +}); diff --git a/web-app/app/shared/components/table/index.ts b/web-app/app/shared/components/table/index.ts new file mode 100644 index 0000000..c6f09c3 --- /dev/null +++ b/web-app/app/shared/components/table/index.ts @@ -0,0 +1 @@ +export { Table } from './Table';