diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md deleted file mode 100644 index babf9b2..0000000 --- a/.github/ISSUE_TEMPLATE/custom.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: Custom issue template -about: Describe this issue template's purpose here. -title: "" -labels: "" -assignees: "" ---- diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11d2646..75a86f9 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) @@ -1312,12 +1315,9 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - baseline-browser-mapping@2.8.29: - resolution: {integrity: sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==} - hasBin: true - - baseline-browser-mapping@2.9.15: - resolution: {integrity: sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==} + baseline-browser-mapping@2.10.0: + resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} + engines: {node: '>=6.0.0'} hasBin: true brace-expansion@1.1.12: @@ -3906,9 +3906,7 @@ snapshots: balanced-match@1.0.2: {} - baseline-browser-mapping@2.8.29: {} - - baseline-browser-mapping@2.9.15: {} + baseline-browser-mapping@2.10.0: {} brace-expansion@1.1.12: dependencies: @@ -3925,7 +3923,7 @@ snapshots: browserslist@4.28.0: dependencies: - baseline-browser-mapping: 2.8.29 + baseline-browser-mapping: 2.10.0 caniuse-lite: 1.0.30001755 electron-to-chromium: 1.5.256 node-releases: 2.0.27 @@ -3933,7 +3931,7 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.9.15 + baseline-browser-mapping: 2.10.0 caniuse-lite: 1.0.30001772 electron-to-chromium: 1.5.267 node-releases: 2.0.27 diff --git a/src/shared/ui/lists/lists.stories.tsx b/src/shared/ui/lists/lists.stories.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/shared/ui/lists/lists.tsx b/src/shared/ui/lists/lists.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/shared/ui/tabs/tabs.stories.tsx b/src/shared/ui/tabs/tabs.stories.tsx new file mode 100644 index 0000000..92525f4 --- /dev/null +++ b/src/shared/ui/tabs/tabs.stories.tsx @@ -0,0 +1,131 @@ +import React, { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Tabs, type TabItem } from "./tabs"; + +// 데모용 뱃지 컴포넌트 (실제 프로젝트에 Badge 컴포넌트가 있다면 그것으로 교체하세요) +const BadgeMock = ({ count }: { count: number }): React.ReactElement => ( + + {count} + +); + +const meta = { + title: "Shared/UI/Tabs", + component: Tabs, + parameters: { + layout: "padded", + componentSubtitle: "다양한 형태(Horizontal, Vertical, Pill)를 지원하는 탭 컴포넌트", + }, + tags: ["autodocs"], + argTypes: { + variant: { + control: "radio", + options: ["horizontal", "vertical", "pill"], + description: "탭의 스타일 변형", + table: { defaultValue: { summary: "horizontal" } }, + }, + items: { + description: "탭 아이템 배열 ({ id, label, badge?, isDisabled? })", // 설명 업데이트 + }, + value: { + control: "text", + description: "현재 선택된 탭 ID", + }, + onChange: { action: "changed", description: "탭 변경 핸들러" }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ---------------------------------------------------------------------- +// 1. 기본 목업 데이터 +// ---------------------------------------------------------------------- +const defaultItems: TabItem[] = [ + { id: "tab1", label: "전체" }, + { id: "tab2", label: "진행중" }, + { id: "tab3", label: "완료" }, + { id: "tab4", label: "보류" }, +]; + +// ---------------------------------------------------------------------- +// 2. 상태 관리용 Wrapper 컴포넌트 (ESLint 훅 규칙 준수) +// ---------------------------------------------------------------------- +const TabsWithState = (args: React.ComponentProps): React.ReactElement => { + const [selected, setSelected] = useState(args.value); + return ; +}; + +// ---------------------------------------------------------------------- +// 3. 스토리 정의 +// ---------------------------------------------------------------------- + +// --- 기본 (수평) 탭 --- +export const Horizontal: Story = { + render: (args) => , + args: { + items: defaultItems, + variant: "horizontal", + value: "tab1", + onChange: () => {}, + }, +}; + +// --- 수직 탭 --- +export const Vertical: Story = { + render: (args) => , + args: { + items: defaultItems, + variant: "vertical", + value: "tab1", + onChange: () => {}, + }, + parameters: { + docs: { + description: { + story: "Vertical Tabs는 기본적으로 200px 너비를 가집니다.", + }, + }, + }, +}; + +// --- 필(알약) 형태 탭 --- +export const Pill: Story = { + render: (args) => , + args: { + items: defaultItems, + variant: "pill", + value: "tab1", + onChange: () => {}, + }, +}; + +// --- 뱃지가 포함된 탭 --- +export const WithBadges: Story = { + render: (args) => , + args: { + items: [ + { id: "mail", label: "메일", badge: }, + { id: "alarm", label: "알림", badge: }, + { id: "settings", label: "설정" }, // 뱃지 없음 + ], + variant: "horizontal", + value: "mail", // 초기 선택값 + onChange: () => {}, + }, +}; + +// --- 비활성화된 아이템이 포함된 탭 --- +export const WithDisabledItem: Story = { + render: (args) => , + args: { + items: [ + { id: "tab1", label: "사용 가능" }, + { id: "tab2", label: "사용 불가", isDisabled: true }, // 불린 컨벤션(isDisabled) 적용 + { id: "tab3", label: "관리자 전용", isDisabled: true }, + ], + variant: "horizontal", + value: "tab1", + onChange: () => {}, + }, +}; diff --git a/src/shared/ui/tabs/tabs.tsx b/src/shared/ui/tabs/tabs.tsx new file mode 100644 index 0000000..bed4b19 --- /dev/null +++ b/src/shared/ui/tabs/tabs.tsx @@ -0,0 +1,122 @@ +import React from "react"; + +// ---------------------------------------------------------------------- +// 1. 타입 및 인터페이스 +// ---------------------------------------------------------------------- +// 💡 배열 생략: 사용하지 않는 배열로 인한 ESLint 에러 방지 및 가이드라인(Literal Union) 준수 +export type TabVariant = "horizontal" | "vertical" | "pill"; + +export type TabItem = { + id: string; + label: string; + badge?: React.ReactNode; + isDisabled?: boolean; +}; + +export type TabsProps = { + items: TabItem[]; + value: string; + onChange: (id: string) => void; + variant?: TabVariant; + className?: string; +}; + +// ---------------------------------------------------------------------- +// 2. 스타일 토큰 (상수) +// ---------------------------------------------------------------------- +const containerBaseClasses = "flex"; + +const containerVariantClasses: Record = { + horizontal: + "w-full h-[48px] flex-row gap-0 border-b-[2px] border-[color:var(--color-gray-200,#E5E5E5)] p-0", + vertical: "w-[200px] flex-col gap-1 p-2 h-auto", + pill: "w-fit flex-row gap-2 p-1 bg-[color:var(--color-gray-100,#F2F2F2)] rounded-lg", +}; + +const itemBaseClasses = + "relative flex items-center justify-center cursor-pointer transition-all duration-200 outline-none select-none whitespace-nowrap"; + +const itemVariantClasses: Record = { + horizontal: "h-[48px] px-6 gap-2 text-[14px] leading-[20px]", + vertical: + "h-[40px] px-4 gap-2 text-[14px] leading-[20px] rounded-[6px] border-l-[3px] justify-start", + pill: "h-[32px] px-4 gap-2 text-[14px] font-medium rounded-[6px]", +}; + +// ---------------------------------------------------------------------- +// 3. 헬퍼 함수 (클래스 조립) +// ---------------------------------------------------------------------- +const getTabItemClasses = (variant: TabVariant, isActive: boolean, isDisabled: boolean): string => { + const base = itemBaseClasses; + const variantClasses = itemVariantClasses[variant]; + let stateClasses = ""; + + if (isDisabled) { + stateClasses = "text-[color:var(--color-gray-300,#CCCCCC)] cursor-not-allowed"; + } else { + switch (variant) { + case "horizontal": + stateClasses = isActive + ? "text-[color:var(--color-primary-800,#004A9C)] font-[600]" + : "text-[color:var(--color-gray-600,#666666)] font-[500] hover:bg-[color:var(--color-gray-50,#F9F9F9)] hover:text-[#333333]"; + break; + case "vertical": + stateClasses = isActive + ? "bg-[color:var(--color-primary-50,#F0F6FD)] border-[color:var(--color-primary-800,#004A9C)] text-[color:var(--color-primary-800,#004A9C)] font-medium" + : "border-transparent text-[color:var(--color-gray-900,#1A1A1A)] hover:bg-[color:var(--color-gray-50,#F9F9F9)]"; + break; + case "pill": + stateClasses = isActive + ? "bg-white text-[color:var(--color-primary-800,#004A9C)] shadow-[0_2px_4px_rgba(0,0,0,0.08)]" + : "text-[color:var(--color-gray-600,#666666)] hover:text-[color:var(--color-gray-900,#1A1A1A)]"; + break; + } + } + + return [base, variantClasses, stateClasses].filter(Boolean).join(" "); +}; + +// ---------------------------------------------------------------------- +// 4. 컴포넌트 정의 +// ---------------------------------------------------------------------- +export const Tabs = ({ + items, + value, + onChange, + variant = "horizontal", + className = "", +}: TabsProps): React.ReactElement => { + const containerClasses = [containerBaseClasses, containerVariantClasses[variant], className].join( + " ", + ); + + return ( +
+ {items.map((item) => { + const isActive = value === item.id; + const isDisabled = !!item.isDisabled; + + return ( + + ); + })} +
+ ); +}; + +Tabs.displayName = "Tabs";