diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3049cf1..c9a097b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: autoprefixer: specifier: ^10.4.24 version: 10.4.24(postcss@8.5.6) + baseline-browser-mapping: + specifier: ^2.10.0 + version: 2.10.0 eslint: specifier: ^9.39.2 version: 9.39.2(jiti@2.6.1) diff --git a/src/shared/ui/avatars/avatars.stories.tsx b/src/shared/ui/avatars/avatars.stories.tsx new file mode 100644 index 0000000..0059f94 --- /dev/null +++ b/src/shared/ui/avatars/avatars.stories.tsx @@ -0,0 +1,107 @@ +import React from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Avatar, AvatarGroup, type AvatarProps } from "./avatars"; + +const meta = { + title: "Shared/UI/Avatars", + parameters: { + layout: "padded", + componentSubtitle: "유저 프로필 및 다중 유저를 표시하는 아바타 컴포넌트", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; + +// ========================================== +// Avatar Stories +// ========================================== +export const AvatarSizes: StoryObj = { + render: () => ( +
+ + + + + + + +
+ ), + parameters: { + docs: { description: { story: "XS(24px) 부터 3XL(128px) 까지 지원합니다. (기본값: MD 40px)" } }, + }, +}; + +export const AvatarVariants: StoryObj = { + render: () => ( +
+ + + {/* src, name 둘 다 없으면 기본 아이콘 */} +
+ ), + parameters: { + docs: { + description: { + story: "이미지, 이니셜 텍스트, 기본 아이콘 순으로 렌더링 우선순위를 가집니다.", + }, + }, + }, +}; + +export const AvatarStatusDisplay: StoryObj = { + render: () => ( +
+ + + + +
+ ), +}; + +export const AvatarWithBadge: StoryObj = { + render: (args) => , + args: { + size: "lg", + src: "https://picsum.photos/102/102", + badge: ( + + 3 + + ), + }, +}; + +// ========================================== +// Avatar Group Stories +// ========================================== +const mockAvatars: AvatarProps[] = [ + { src: "https://picsum.photos/200/200?random=1", name: "User 1" }, + { src: "https://picsum.photos/200/200?random=2", name: "User 2" }, + { name: "Choi", status: "online" }, + { src: "https://picsum.photos/200/200?random=4", name: "User 4" }, + { src: "https://picsum.photos/200/200?random=5", name: "User 5" }, + { name: "Park" }, + { src: "https://picsum.photos/200/200?random=7", name: "User 7" }, +]; + +export const GroupDefault: StoryObj = { + render: (args) => , + args: { + avatars: mockAvatars, + maxVisible: 4, + size: "md", + }, +}; + +export const GroupSizes: StoryObj = { + render: () => ( +
+ + + +
+ ), +}; diff --git a/src/shared/ui/avatars/avatars.tsx b/src/shared/ui/avatars/avatars.tsx new file mode 100644 index 0000000..e726703 --- /dev/null +++ b/src/shared/ui/avatars/avatars.tsx @@ -0,0 +1,191 @@ +import React from "react"; +// 1. 공통 아이콘 Import 추가 +import { UserSolidIcon } from "../icons"; + +// ---------------------------------------------------------------------- +// 타입 정의 +// ---------------------------------------------------------------------- +export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl"; +export type AvatarStatus = "online" | "away" | "busy" | "offline"; + +export type AvatarProps = { + src?: string; // 이미지 URL + name?: string; // 이름 (이미지 없을 시 이니셜 추출용) + size?: AvatarSize; + status?: AvatarStatus; + badge?: React.ReactNode; // 코너 뱃지 (숫자, 닷 등) + hasBorder?: boolean; // 하얀색 테두리 여부 + isGrouped?: boolean; // 그룹 내 배치 여부 (테두리 두께 3px 및 z-index 조정을 위함) + className?: string; + onClick?: () => void; +}; + +export type AvatarGroupProps = { + avatars: AvatarProps[]; + maxVisible?: number; // 최대 표시 개수 + size?: AvatarSize; // 그룹 전체 일괄 사이즈 + className?: string; +}; + +// ---------------------------------------------------------------------- +// 스타일 토큰 (상수) +// ---------------------------------------------------------------------- +const avatarBaseClasses = + "relative inline-flex items-center justify-center rounded-full bg-[color:var(--color-primary-800,#004A9C)] text-white font-bold shadow-[0_2px_8px_rgba(0,0,0,0.08)] flex-shrink-0 select-none"; + +const avatarSizeClasses: Record = { + xs: "w-[24px] h-[24px]", + sm: "w-[32px] h-[32px]", + md: "w-[40px] h-[40px]", + lg: "w-[48px] h-[48px]", + xl: "w-[64px] h-[64px]", + "2xl": "w-[96px] h-[96px]", + "3xl": "w-[128px] h-[128px]", +}; + +// 비율에 맞춘 텍스트 사이즈 +const avatarTextSizeClasses: Record = { + xs: "text-[10px]", + sm: "text-[13px]", + md: "text-[16px]", + lg: "text-[19px]", + xl: "text-[26px]", + "2xl": "text-[38px]", + "3xl": "text-[51px]", +}; + +// 비율에 맞춘 상태 표시기 사이즈 +const statusSizeClasses: Record = { + xs: "w-[5px] h-[5px] border-[1px]", + sm: "w-[6px] h-[6px] border-[1.5px]", + md: "w-[8px] h-[8px] border-[2px]", + lg: "w-[10px] h-[10px] border-[2px]", + xl: "w-[13px] h-[13px] border-[2px]", + "2xl": "w-[19px] h-[19px] border-[3px]", + "3xl": "w-[26px] h-[26px] border-[4px]", +}; + +const statusColorClasses: Record = { + online: "bg-[color:var(--color-success-500,#10A259)]", + away: "bg-[color:var(--color-warning-500,#F59E0B)]", + busy: "bg-[color:var(--color-error-500,#EF4444)]", + offline: "bg-[color:var(--color-gray-400,#999999)]", +}; + +// ---------------------------------------------------------------------- +// 헬퍼 함수 +// ---------------------------------------------------------------------- +const getInitial = (name: string): string => { + return name.charAt(0).toUpperCase(); +}; + +const getAvatarClasses = ( + size: AvatarSize, + hasBorder: boolean, + isGrouped: boolean, + className: string, +): string => { + const base = avatarBaseClasses; + const sizeClass = avatarSizeClasses[size]; + const borderClass = isGrouped + ? "border-[3px] border-white box-content" + : hasBorder + ? "border-[2px] border-white box-content" + : ""; + + return [base, sizeClass, borderClass, className].filter(Boolean).join(" "); +}; + +// ---------------------------------------------------------------------- +// 컴포넌트 정의 +// ---------------------------------------------------------------------- + +// User Avatar Component +export const Avatar = ({ + src, + name, + size = "md", + status, + badge, + hasBorder = false, + isGrouped = false, + className = "", + onClick, +}: AvatarProps): React.ReactElement => { + const isClickable = !!onClick; + const avatarClasses = getAvatarClasses( + size, + hasBorder, + isGrouped, + [className, isClickable ? "cursor-pointer" : ""].join(" "), + ); + + return ( +
+ {/* 1. 이미지 우선 렌더링 */} + {src ? ( + {name + ) : /* 2. 이미지가 없으면 이름 이니셜 */ + name ? ( + {getInitial(name)} + ) : ( + /* 3. 이름도 없으면 기본 아이콘 */ +
+ +
+ )} + + {/* 상태 표시기 (우측 하단) */} + {status && ( + + )} + + {/* 뱃지 (우측 상단 등, badge prop으로 통째로 받음) */} + {badge && ( +
{badge}
+ )} +
+ ); +}; + +// Avatar Group Component +export const AvatarGroup = ({ + avatars, + maxVisible = 4, + size = "md", + className = "", +}: AvatarGroupProps): React.ReactElement => { + const safeMaxVisible = Math.max(0, maxVisible); + const visibleAvatars = avatars.slice(0, safeMaxVisible); + const hiddenCount = Math.max(0, avatars.length - safeMaxVisible); + + return ( + // Gap: -8px (Tailwind -space-x-2) +
+ {visibleAvatars.map((avatarProps, index) => ( +
+ +
+ ))} + + {/* +N 표시기 */} + {hiddenCount > 0 && ( +
+ +{hiddenCount} +
+ )} +
+ ); +}; diff --git a/src/shared/ui/empty_states/empty-states.stories.tsx b/src/shared/ui/empty_states/empty-states.stories.tsx new file mode 100644 index 0000000..44e6209 --- /dev/null +++ b/src/shared/ui/empty_states/empty-states.stories.tsx @@ -0,0 +1,136 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { EmptyState } from "./empty-states"; + +const meta = { + title: "Shared/UI/Empty States", + component: EmptyState, + parameters: { + layout: "padded", + componentSubtitle: "데이터가 없거나 에러가 발생했을 때 보여주는 화면", + }, + tags: ["autodocs"], + argTypes: { + variant: { + control: "select", + options: [ + "no-content", + "no-results", + "no-notifications", + "error", + "access-denied", + "coming-soon", + ], + description: "사전 정의된 빈 화면 상태 타입", + }, + title: { control: "text", description: "기본 타이틀을 덮어쓸 커스텀 타이틀" }, + description: { control: "text", description: "기본 설명을 덮어쓸 커스텀 설명" }, + hasBackground: { control: "boolean", description: "회색 배경 적용 여부" }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ========================================== +// Variants Stories +// ========================================== + +export const NoContent: Story = { + args: { + variant: "no-content", + primaryAction: { + label: "첫 게시물 작성하기", + onClick: () => alert("게시물 작성 모달 오픈!"), + }, + }, +}; + +export const NoResults: Story = { + args: { + variant: "no-results", + primaryAction: { + label: "검색어 초기화", + onClick: () => alert("검색어가 초기화되었습니다."), + }, + }, +}; + +export const NoNotifications: Story = { + args: { + variant: "no-notifications", + }, +}; + +export const ErrorState: Story = { + args: { + variant: "error", + primaryAction: { + label: "다시 시도", + onClick: () => window.location.reload(), + }, + }, +}; + +export const AccessDenied: Story = { + args: { + variant: "access-denied", + primaryAction: { + label: "이전 페이지로", + onClick: () => history.back(), + }, + secondaryAction: { + label: "홈으로 이동", + onClick: () => alert("홈으로 이동합니다."), + }, + }, +}; + +export const ComingSoon: Story = { + args: { + variant: "coming-soon", + }, +}; + +// ========================================== +// Customizing & States +// ========================================== + +export const WithBackground: Story = { + args: { + variant: "no-content", + hasBackground: true, + primaryAction: { + label: "작성하기", + onClick: () => {}, + }, + }, + parameters: { + docs: { + description: { + story: + "`hasBackground={true}`를 전달하면 컨테이너에 연한 회색 배경(#F9F9F9)과 둥근 모서리가 적용됩니다.", + }, + }, + }, +}; + +export const CustomTextOverrides: Story = { + args: { + variant: "no-results", + title: "원하시는 프로젝트를 찾지 못했어요 😢", + description: + "철자나 띄어쓰기를 확인한 후 다시 검색해 주세요.\n또는 필터를 조정해 볼 수 있습니다.", + primaryAction: { + label: "필터 초기화", + onClick: () => {}, + }, + }, + parameters: { + docs: { + description: { + story: + "Variant의 기본 아이콘을 유지하면서 `title`과 `description`만 커스텀하게 덮어쓸 수 있습니다.", + }, + }, + }, +}; diff --git a/src/shared/ui/empty_states/empty-states.tsx b/src/shared/ui/empty_states/empty-states.tsx new file mode 100644 index 0000000..31c80b0 --- /dev/null +++ b/src/shared/ui/empty_states/empty-states.tsx @@ -0,0 +1,163 @@ +import React from "react"; +// 1. 공통 아이콘 Import 추가 +import { FileTextIcon, SearchIcon, BellIcon, AlertCircleIcon, LockIcon, ClockIcon } from "../icons"; +import { Button } from "../button/button"; + +// ---------------------------------------------------------------------- +// 2. 타입 정의 (type 선호 컨벤션) +// ---------------------------------------------------------------------- +export type EmptyStateVariant = + | "no-content" + | "no-results" + | "no-notifications" + | "error" + | "access-denied" + | "coming-soon"; + +export type EmptyStateAction = { + label: string; + onClick: () => void; +}; + +export type EmptyStateProps = { + variant?: EmptyStateVariant; + title?: string; // variant 기본값을 덮어쓸 때 사용 + description?: string; // variant 기본값을 덮어쓸 때 사용 + icon?: React.ReactNode; // 커스텀 아이콘을 사용할 때 사용 + primaryAction?: EmptyStateAction; + secondaryAction?: EmptyStateAction; + hasBackground?: boolean; + className?: string; +}; + +// ---------------------------------------------------------------------- +// 3. Variant 매핑 설정 (상수) +// ---------------------------------------------------------------------- +type VariantConfig = { + title: string; + description?: string; + icon: React.ReactNode; + iconColorClass: string; +}; + +// 변경된 부분: 공통 아이콘 컴포넌트를 사용하고 w-full h-full로 부모 영역에 맞게 스케일링 +const VARIANT_MAP: Record = { + "no-content": { + title: "아직 게시물이 없습니다", + icon: , + iconColorClass: "text-[color:var(--color-primary-200,#B3D1F7)]", + }, + "no-results": { + title: "검색 결과가 없습니다", + description: "다른 키워드로 시도해보세요", + icon: , + iconColorClass: "text-[color:var(--color-gray-300,#CCCCCC)]", + }, + "no-notifications": { + title: "알림이 없습니다", + description: "새로운 알림이 오면 여기에 표시됩니다", + icon: , + iconColorClass: "text-[color:var(--color-primary-200,#B3D1F7)]", + }, + error: { + title: "데이터를 불러올 수 없습니다", + description: "잠시 후 다시 시도해주세요", + icon: , + iconColorClass: "text-[color:var(--color-gray-300,#CCCCCC)]", + }, + "access-denied": { + title: "접근 권한이 없습니다", + description: "관리자에게 문의하세요", + icon: , + iconColorClass: "text-[color:var(--color-gray-300,#CCCCCC)]", + }, + "coming-soon": { + title: "곧 만나요!", + description: "이 기능은 준비 중입니다", + icon: , + iconColorClass: "text-[color:var(--color-primary-200,#B3D1F7)]", + }, +}; + +// ---------------------------------------------------------------------- +// 4. 스타일 토큰 (상수) +// ---------------------------------------------------------------------- +const containerBaseClasses = + "flex flex-col items-center justify-center w-full min-h-[320px] px-[24px] py-[48px] gap-[24px] text-center"; +const titleClasses = + "font-bold text-[24px] leading-[32px] text-[color:var(--color-gray-800,#333333)] max-w-[400px] whitespace-pre-wrap"; +const descriptionClasses = + "font-normal text-[16px] leading-[24px] text-[color:var(--color-gray-600,#666666)] max-w-[480px] whitespace-pre-wrap"; + +// ---------------------------------------------------------------------- +// 5. 컴포넌트 정의 +// ---------------------------------------------------------------------- +export const EmptyState = ({ + variant = "no-content", + title, + description, + icon, + primaryAction, + secondaryAction, + hasBackground = false, + className = "", +}: EmptyStateProps): React.ReactElement => { + const config = VARIANT_MAP[variant]; + + // Props로 전달된 값이 있으면 우선 사용하고, 없으면 Variant 기본값 사용 + const displayTitle = title ?? config.title; + const displayDescription = description ?? config.description; + const displayIcon = icon ?? config.icon; + const iconColorClass = icon + ? "text-[color:var(--color-gray-400,#999999)]" + : config.iconColorClass; + + const containerClasses = [ + containerBaseClasses, + hasBackground ? "bg-[color:var(--color-gray-50,#F9F9F9)] rounded-lg" : "bg-transparent", + className, + ] + .filter(Boolean) + .join(" "); + + return ( +
+ {/* 1. Illustration */} +
+ {displayIcon} +
+ + {/* 2. 텍스트 영역 (Title & Description) */} +
+

{displayTitle}

+ {displayDescription &&

{displayDescription}

} +
+ + {/* 3. Action 버튼 영역 (공용 Button 컴포넌트 사용) */} + {(primaryAction || secondaryAction) && ( +
+ {primaryAction && ( + + )} + {secondaryAction && ( + + )} +
+ )} +
+ ); +}; diff --git a/src/shared/ui/icons/index.tsx b/src/shared/ui/icons/index.tsx index 648bd56..40930ba 100644 --- a/src/shared/ui/icons/index.tsx +++ b/src/shared/ui/icons/index.tsx @@ -374,3 +374,185 @@ export const LockIcon: React.FC = ({ size = 20, ...props }) => ( /> ); + +// Inbox 아이콘 (Lists 컴포넌트 사용) +export const InboxIcon: React.FC = ({ size = 24, ...props }) => ( + + + + +); + +// User Solid 아이콘 (Avatars 컴포넌트 사용) +export const UserSolidIcon: React.FC = ({ size = 24, ...props }) => ( + + + +); + +// File Text 아이콘 (Empty States 컴포넌트 사용) +export const FileTextIcon: React.FC = ({ size = 24, ...props }) => ( + + + + + + + +); + +// Bell 아이콘 (Empty States 컴포넌트 사용) +export const BellIcon: React.FC = ({ size = 24, ...props }) => ( + + + + +); + +// Alert Circle 아이콘 (Empty States 컴포넌트 사용) +export const AlertCircleIcon: React.FC = ({ size = 24, ...props }) => ( + + + + + +); + +// Clock 아이콘 (Empty States 컴포넌트 사용) +export const ClockIcon: React.FC = ({ size = 24, ...props }) => ( + + + + +); diff --git a/src/shared/ui/lists/lists.stories.tsx b/src/shared/ui/lists/lists.stories.tsx new file mode 100644 index 0000000..874296f --- /dev/null +++ b/src/shared/ui/lists/lists.stories.tsx @@ -0,0 +1,191 @@ +import React, { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ListItem, Table, type TableColumn, type TableRowData } from "./lists"; + +const meta = { + title: "Shared/UI/Lists & Tables", + // Storybook Meta는 하나의 메인 컴포넌트를 필요로 하지만, 두 개를 문서화하기 위해 Wrapper 활용 + parameters: { + layout: "padded", + componentSubtitle: "디자인 시스템에 정의된 List Item 및 Table 컴포넌트", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; + +// ========================================== +// 1. List Item Stories +// ========================================== +export const ListItemDefault: StoryObj = { + render: (args) => , + args: { + id: "item-1", + title: "아주대학교 산업협력 프로젝트 A", + description: "프론트엔드 파트 UI 시스템 구축 및 공통 컴포넌트 개발 진행 중", + metaPrimary: "진행중", + metaSecondary: "2023.10.25", + hasArrow: true, + }, +}; + +export const ListItemWithImage: StoryObj = { + render: (args) => , + args: { + id: "item-2", + title: "프로필 정보 업데이트", + description: "새로운 이미지와 프로필 상세 정보를 업데이트 하세요.", + imageUrl: "https://picsum.photos/100/100", // 더미 이미지 + hasArrow: true, + }, +}; + +// 인터랙션을 확인하기 위한 상태 Wrapper (리스트) +const InteractiveList = (): React.ReactElement => { + const [items, setItems] = useState([ + { + id: "1", + title: "첫 번째 항목", + description: "설명 텍스트입니다.", + isChecked: false, + isActive: false, + }, + { + id: "2", + title: "두 번째 항목 (Active)", + description: "선택된 상태입니다.", + isChecked: true, + isActive: true, + }, + { + id: "3", + title: "비활성화 항목", + description: "클릭할 수 없습니다.", + isChecked: false, + isActive: false, + isDisabled: true, + }, + ]); + + const toggleCheck = (id: string, isChecked: boolean) => { + setItems((prev) => prev.map((item) => (item.id === id ? { ...item, isChecked } : item))); + }; + + const selectItem = (id: string) => { + setItems((prev) => prev.map((item) => ({ ...item, isActive: item.id === id }))); + }; + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +}; + +export const ListStates: StoryObj = { + render: () => , +}; + +// ========================================== +// 2. Table Stories +// ========================================== + +const tableColumns: TableColumn[] = [ + { id: "status", label: "상태", width: "sm", align: "center" }, + { id: "title", label: "프로젝트 명", width: "fill" }, + { id: "manager", label: "담당자", width: "md", align: "center" }, + { id: "date", label: "등록일", width: "md", align: "right" }, +]; + +const initialTableData: TableRowData[] = [ + { + id: "row-1", + status: "진행중", + title: "웹 접근성 개선 프로젝트", + manager: "김아주", + date: "2023.10.24", + isSelected: false, + }, + { + id: "row-2", + status: "완료", + title: "어드민 대시보드 리팩토링", + manager: "이산업", + date: "2023.10.20", + isSelected: false, + }, + { + id: "row-3", + status: "보류", + title: "레거시 코드 제거 (비활성화)", + manager: "박프론트", + date: "2023.10.15", + isDisabled: true, + }, +]; + +export const TableDefault: StoryObj = { + render: (args) => , + args: { + columns: tableColumns, + data: initialTableData, + }, +}; + +export const TableEmptyState: StoryObj = { + render: (args) =>
, + args: { + columns: tableColumns, + data: [], + isEmpty: true, + }, +}; + +// 인터랙션을 확인하기 위한 상태 Wrapper (테이블) +const InteractiveTable = (): React.ReactElement => { + const [data, setData] = useState(initialTableData); + + const selectableRows = data.filter((r) => !r.isDisabled); + const isAllChecked = + selectableRows.length > 0 && selectableRows.every((r) => r.isSelected); + + const handleRowCheck = (id: string, isChecked: boolean) => { + setData((prev) => prev.map((row) => (row.id === id ? { ...row, isSelected: isChecked } : row))); + }; + + const handleCheckAll = (isChecked: boolean) => { + setData((prev) => + prev.map((row) => (row.isDisabled ? row : { ...row, isSelected: isChecked })), + ); + }; + + const handleRowClick = (id: string) => { + // 클릭 시 단일 선택되는 로직 예제 + setData((prev) => prev.map((row) => ({ ...row, isSelected: row.id === id }))); + }; + + return ( +
+ ); +}; + +export const TableStates: StoryObj = { + render: () => , +}; diff --git a/src/shared/ui/lists/lists.tsx b/src/shared/ui/lists/lists.tsx new file mode 100644 index 0000000..2573835 --- /dev/null +++ b/src/shared/ui/lists/lists.tsx @@ -0,0 +1,339 @@ +import React from "react"; +// 공통 아이콘 Import 추가 +import { ChevronRightIcon, InboxIcon } from "../icons"; + +// ---------------------------------------------------------------------- +// 타입 정의 +// ---------------------------------------------------------------------- + +// List Item 관련 타입 +export type ListItemProps = { + id: string; + title: string; + description?: string; + imageUrl?: string; + metaPrimary?: string; + metaSecondary?: string; + hasCheckbox?: boolean; + isChecked?: boolean; + hasArrow?: boolean; + isActive?: boolean; + isDisabled?: boolean; // 불린 컨벤션 + onClick?: (id: string) => void; + onCheck?: (id: string, isChecked: boolean) => void; +}; + +// Table 관련 타입 +export type TableColumnWidth = "checkbox" | "sm" | "md" | "lg" | "fill"; +export type TableColumnAlign = "left" | "center" | "right"; + +export type TableColumn = { + id: string; + label: string; + width?: TableColumnWidth; + align?: TableColumnAlign; +}; + +export type TableRowData = { + id: string; + [key: string]: React.ReactNode; + isSelected?: boolean; + isDisabled?: boolean; +}; + +export type TableProps = { + columns: TableColumn[]; + data: TableRowData[]; + isEmpty?: boolean; + hasCheckbox?: boolean; + isAllChecked?: boolean; + onRowClick?: (id: string) => void; + onRowCheck?: (id: string, isChecked: boolean) => void; + onCheckAll?: (isChecked: boolean) => void; +}; + +// ---------------------------------------------------------------------- +// 스타일 토큰 (상수) +// ---------------------------------------------------------------------- + +// --- List Item 스타일 --- +const listItemBaseClasses = + "flex flex-row items-center w-full min-h-[64px] px-[20px] py-[16px] gap-[16px] border-b border-[color:var(--color-gray-200,#E5E5E5)] transition-colors duration-200 outline-none"; + +// --- Table 스타일 --- +const tableContainerClasses = + "w-full flex flex-col border border-[color:var(--color-gray-200,#E5E5E5)] rounded-[8px] overflow-hidden bg-white"; +const tableHeaderRowClasses = + "flex flex-row items-center w-full h-[48px] px-[20px] py-[12px] bg-[color:var(--color-gray-50,#F9F9F9)] border-b-[2px] border-[color:var(--color-gray-300,#CCCCCC)]"; +const tableRowBaseClasses = + "flex flex-row items-center w-full min-h-[56px] px-[20px] py-[16px] border-b border-[color:var(--color-gray-200,#E5E5E5)] transition-colors duration-200"; + +// 너비 맵핑 클래스 +const columnWidthClasses: Record = { + checkbox: "w-[48px] flex-shrink-0", + sm: "w-[80px] flex-shrink-0", + md: "w-[120px] flex-shrink-0", + lg: "w-[200px] flex-shrink-0", + fill: "flex-1 min-w-0", +}; + +// 정렬 맵핑 클래스 +const columnAlignClasses: Record = { + left: "justify-start text-left", + center: "justify-center text-center", + right: "justify-end text-right", +}; + +// ---------------------------------------------------------------------- +// 함수 +// ---------------------------------------------------------------------- + +const getListItemClasses = (isActive: boolean, isDisabled: boolean): string => { + if (isDisabled) { + return [listItemBaseClasses, "bg-white opacity-50 cursor-not-allowed"].join(" "); + } + + const stateClasses = isActive + ? "bg-[color:var(--color-primary-50,#F0F6FD)] border-[color:var(--color-primary-200,#B3D1F7)]" + : "bg-white hover:bg-[color:var(--color-gray-50,#F9F9F9)] cursor-pointer group"; + + return [listItemBaseClasses, stateClasses].join(" "); +}; + +const getTableRowClasses = (isSelected: boolean, isDisabled: boolean): string => { + if (isDisabled) { + return [tableRowBaseClasses, "bg-white opacity-50 cursor-not-allowed"].join(" "); + } + + const stateClasses = isSelected + ? "bg-[color:var(--color-primary-50,#F0F6FD)] border-[color:var(--color-primary-200,#B3D1F7)]" + : "bg-white hover:bg-[color:var(--color-gray-50,#F9F9F9)] cursor-pointer"; + + return [tableRowBaseClasses, stateClasses].join(" "); +}; + +// ---------------------------------------------------------------------- +// 컴포넌트 정의 +// ---------------------------------------------------------------------- + +// List Item Component +export const ListItem = ({ + id, + title, + description, + imageUrl, + metaPrimary, + metaSecondary, + hasCheckbox = false, + isChecked = false, + hasArrow = false, + isActive = false, + isDisabled = false, + onClick, + onCheck, +}: ListItemProps): React.ReactElement => { + const handleItemClick = () => { + if (!isDisabled && onClick) onClick(id); + }; + + const handleCheckClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!isDisabled && onCheck) onCheck(id, !isChecked); + }; + + return ( +
+ {/* 체크박스 */} + {hasCheckbox && ( +
+ { + if (onCheck) { + onCheck(id, !isChecked); + } + }} + onClick={(e) => { + // 부모
  • 에 걸려있는 onClick 이벤트로 버블링되는 것 방지= + e.stopPropagation(); + }} + disabled={isDisabled} + className="w-4 h-4 cursor-pointer accent-[color:var(--color-primary-800,#004A9C)]" + /> +
    + )} + + {/* 썸네일 이미지 */} + {imageUrl && ( + {title} + )} + + {/* 콘텐츠 영역 */} +
    + + {title} + + {description && ( + + {description} + + )} +
    + + {/* 메타 정보 */} + {(metaPrimary || metaSecondary) && ( +
    + {metaPrimary && ( + + {metaPrimary} + + )} + {metaSecondary && ( + + {metaSecondary} + + )} +
    + )} + + {/* 화살표 아이콘 */} + {hasArrow && ( +
    + +
    + )} +
  • + ); +}; + +// Table Component +export const Table = ({ + columns, + data, + isEmpty = false, + hasCheckbox = false, + isAllChecked = false, + onRowClick, + onRowCheck, + onCheckAll, +}: TableProps): React.ReactElement => { + return ( +
    + {/* Table Header */} +
    + {hasCheckbox && ( +
    + onCheckAll && onCheckAll(e.target.checked)} + className="w-4 h-4 cursor-pointer accent-[color:var(--color-primary-800,#004A9C)]" + /> +
    + )} + {columns.map((col) => ( +
    + + {col.label} + +
    + ))} +
    + + {/* Table Body (Empty State) */} + {isEmpty || data.length === 0 ? ( +
    + + + 데이터가 없습니다 + +
    + ) : ( + /* Table Body (Rows) */ +
    + {data.map((row) => { + const isSelected = !!row.isSelected; + const isDisabled = !!row.isDisabled; + + const handleRowClick = () => { + if (!isDisabled && onRowClick) onRowClick(row.id); + }; + + const handleCheckClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!isDisabled && onRowCheck) onRowCheck(row.id, !isSelected); + }; + + return ( +
    + {hasCheckbox && ( +
    + { + if (onRowCheck) { + onRowCheck(row.id, !isSelected); + } + }} + onClick={(e) => { + // 부모 영역의 클릭 이벤트와 중복(버블링)되어 두 번 토글되는 현상 방지 + e.stopPropagation(); + }} + className="w-4 h-4 cursor-pointer accent-[color:var(--color-primary-800,#004A9C)]" + /> +
    + )} + {columns.map((col) => ( +
    + + {row[col.id]} + +
    + ))} +
    + ); + })} +
    + )} +
    + ); +};