디자인 시스템이란? 🎨
제품의 디자인과 개발을 안내하는 광범위한 지침, 표준, 그리고 실천사항을 모아놓은 것입니다. 쉽게 설명하자면, 우리 제품의 디자인에 대한 규칙이라고 할 수 있습니다.
디자인 시스템의 장점 ✨
대표적인 디자인 시스템 예시 📖
Storybook은 UI 컴포넌트를 독립적으로 개발하고 문서화할 수 있는 환경을 제공하는 도구입니다.
Story의 개념 💡
Storybook에서 가장 중요한 개념은 바로 story입니다. Story는 UI 컴포넌트의 특정 상태(state)를 의미합니다.
예를 들어 <button> 컴포넌트의 경우:
disabled 상태: 비활성화된 버튼enabled 상태: 활성화된 버튼primary 테마: 주요 액션 버튼secondary 테마: 보조 액션 버튼이렇게 하나의 컴포넌트가 가질 수 있는 다양한 상태들을 각각의 story로 작성하고, Storybook UI에서 시각적으로 확인할 수 있습니다.
Storybook이 디자인 시스템에서 하는 역할 🎯
먼저 Vite를 사용하여 프로젝트를 생성합니다:
npm create vite@latest my-design-system -- --template react-ts
cd my-design-system
npm install⚠️ Storybook 10의 주요 변경사항
Storybook 10부터는 ESM-only로 변경되었습니다. 이는 다음을 의미합니다:
필수 요구사항:
.storybook/main.js|ts, preview.js|ts 등)이 유효한 ESM 형식이어야 합니다require, module.exports)은 더 이상 사용할 수 없습니다ESM-only 전환의 장점:
Storybook 설치하기:
npx storybook@latest init
이 명령어를 실행하면:
1. Storybook이 프로젝트를 자동으로 감지합니다
2. 필요한 패키지들을 설치합니다
3. `.storybook` 디렉토리와 설정 파일들을 생성합니다
4. 예제 스토리 파일들(Button, Header, Page)을 생성합니다
5. 설치가 완료되면 자동으로 Storybook이 실행됩니다
**프로젝트 구조:**
my-design-system/
├── .storybook/
│ ├── main.ts
# Storybook 메인 설정
│ └── preview.ts
# 전역 스토리 설정
├── src/
│ └── stories/
# 예제 스토리들
└── package.json.storybook/main.ts - 메인 설정 파일:
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
// 스토리 파일 위치 지정
stories: [
'../src/**/*.mdx',
'../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'
],
// 사용할 애드온들
addons: [
'@storybook/addon-essentials',
// 필수 애드온 모음
'@storybook/addon-interactions',
// 인터랙션 테스트
],
// 프레임워크 설정 (Vite 빌더 자동 포함)
framework: {
name: '@storybook/react-vite',
options: {},
},
};
export default config;주요 설정 옵션:
stories: 스토리 파일들의 위치를 glob 패턴으로 지정합니다addons: 사용할 Storybook 애드온들을 나열합니다framework: 사용 중인 프레임워크와 빌더를 지정합니다.storybook/preview.ts - 전역 스토리 설정:
import type { Preview } from '@storybook/react';
const preview: Preview = {
parameters: {
// 모든 스토리에 적용될 설정
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;ℹ️ Storybook 7.0 이후 변경사항
Storybook 7.0부터는 @storybook/react-vite와 같은 프레임워크를 사용하면 Vite 빌더가 자동으로 설정됩니다.
이전 버전(~6.5)과의 차이:
.storybook/main.ts에 core.builder 설정을 명시적으로 추가해야 했습니다vite가 포함되어 있으면 자동으로 Vite 빌더를 사용합니다@storybook/builder-vite 패키지 설치가 필요 없습니다Vite 설정 파일 자동 인식:
Storybook은 프로젝트 루트의 vite.config.ts 파일을 자동으로 읽어서 적용합니다.
import type { StorybookConfig } from '@storybook/react-vite';
import { mergeConfig } from 'vite';
const config: StorybookConfig = {
framework: '@storybook/react-vite',
async viteFinal(config) {
// Vite 설정 커스터마이징
return mergeConfig(config, {
resolve: {
alias: {
'@components': '/src/components',
},
},
});
},
};
export default config;Tailwind CSS 4를 설치하고 설정합니다:
npm install -D tailwindcss@next @tailwindcss/vite@nextvite.config.ts 수정:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
});디자인 시스템 색상 설정 - src/index.css****:
Tailwind CSS 4부터는 tailwind.config.ts 파일이 더 이상 생성되지 않습니다. 대신 CSS 파일에서 @theme 디렉티브를 사용하여 디자인 토큰을 정의합니다:
@import "tailwindcss";
@theme {
/* 색상 정의 */
--color-primary: #1d2745;
--color-secondary: #1de5d4;
--color-tertiary: #f52c50;
--color-white: #ffffff;
--color-mono-100: #f1f1f1;
--color-mono-200: #bebebe;
--color-mono-300: #d6d7d9;
--color-error: #d01e1e;
/* 버튼 radius */
--radius-button-default: 8px;
}
body {
margin: 0;
font-family: "Noto Sans KR", sans-serif;
}사용 예시:
// Tailwind 클래스로 사용
<div className="bg-primary text-white">
<p className="text-error">에러 메시지</p>
</div>Tailwind CSS 4의 장점:
@fontsource를 사용하여 폰트를 설정합니다:
fontsource의 장점:
npm install @fontsource/noto-sans-kr**src/main.tsx**에서 import:
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './index.css';
// 필요한 폰트 웨이트만 import
import "@fontsource/noto-sans-kr/400.css";
import "@fontsource/noto-sans-kr/700.css";
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);Storybook에도 폰트 적용하기:
.storybook/preview.ts에도 동일하게 폰트를 import하면 Storybook UI에도 폰트가 적용됩니다:
import type { Preview } from '@storybook/react';
import '../src/index.css';
import "@fontsource/noto-sans-kr/400.css";
import "@fontsource/noto-sans-kr/700.css";
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;Storybook은 CSF(Component Story Format)라는 표준화된 형식을 사용합니다. CSF는 ES6 모듈 기반의 형식으로, 현재 CSF 3이 표준입니다.
CSF의 기본 구조:
import type { Meta, StoryObj } from '@storybook/react-vite';
import { ComponentName } from './ComponentName';
// 1. Meta 객체: 컴포넌트에 대한 메타데이터
const meta = {
title: 'Category/ComponentName',
// Storybook 사이드바 경로
component: ComponentName,
// 스토리의 대상 컴포넌트
parameters: {
/* ... */
},
// 스토리 표시 설정
tags: ['autodocs'],
// 자동 문서화 태그
argTypes: {
/* ... */
},
// Props 설정 및 설명
} satisfies Meta<typeof ComponentName>;
export default meta;
// 2. Story 타입 정의
type Story = StoryObj<typeof meta>;
// 3. 개별 스토리들
export const Default: Story = {
args: {
/* props */
},
};title - Storybook 사이드바 경로:
// 단일 경로
title: 'Button'
// → Button// 중첩 경로 (권장)
title: 'Components/Button'
// → Components > Button
title: 'Design System/Atoms/Button'
// → Design System > Atoms > Buttonparameters - 스토리 레이아웃 설정:
parameters: {
layout: 'centered',
// 'centered' | 'fullscreen' | 'padded'
}centered: 캔버스 중앙에 컴포넌트 배치 (기본값, 가장 많이 사용)fullscreen: 전체 화면으로 표시padded: 패딩을 추가하여 표시tags - 자동 문서화:
tags: ['autodocs']
// 자동으로 문서 페이지 생성argTypes - Props 설정 및 문서화 (⭐️ 중요 ⭐️)
argTypes는 컴포넌트의 props를 Controls 패널에서 조작할 수 있게 하고, 동시에 문서화하는 역할을 합니다.
argTypes: {
// 기본 구조
propName: {
control: 'control-type',
// Controls 패널의 입력 타입
description: '설명',
// Props 설명
defaultValue: 'default',
// 기본값
table: {
// 문서 테이블 설정
type: { summary: 'string' },
defaultValue: { summary: 'default' },
},
},
}텍스트 입력:
// 짧은 텍스트
control: 'text'
// 긴 텍스트 (textarea)
control: 'text'불리언:
control: 'boolean'숫자:
control: {
type: 'number',
min: 0,
max: 100,
step: 10,
}선택:
// 드롭다운
control: {
type: 'select',
options: ['small', 'medium', 'large'],
}
// 라디오 버튼
control: {
type: 'radio',
options: ['primary', 'secondary'],
}색상:
control: 'color'날짜:
control: 'date'객체/배열:
control: 'object'
---
### Label 컴포넌트 실습
**폴더 구조 정리:**
Storybook 스토리 파일과 실제 컴포넌트를 분리하여 관리하는 것이 좋습니다:
src/
├── components/ # 실제 컴포넌트
│ └── Label.tsx
└── stories/ # 스토리 파일
└── Label.stories.ts이렇게 분리하면:
src/components/Label.tsx****:
interface ILabelProps {
htmlFor: string;
children: string;
}
export default function Label({ htmlFor, children }: ILabelProps) {
return (
<label className="text-sm text-primary font-bold" htmlFor={htmlFor}>
{children}
</label>
);
}src/stories/Label.stories.ts****:
import type { Meta, StoryObj } from "@storybook/react-vite";
import Label from "../components/Label";
const meta = {
title: "Text/Label",
component: Label,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
htmlFor: {
control: "text",
description: "연결할 input 요소의 id"
},
children: {
control: "text",
description: "레이블에 표시될 텍스트"
},
},
} satisfies Meta<typeof Label>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
htmlFor: "username",
children: "이메일",
},
};src/components/ErrorMessage.tsx****:
interface IErrorMessageProps {
children: string;
}
export default function ErrorMessage({ children }: IErrorMessageProps) {
return <p className="text-sm text-error font-bold">{children}</p>;
}src/stories/ErrorMessage.stories.ts****:
import type { Meta, StoryObj } from "@storybook/react-vite";
import ErrorMessage from "../components/ErrorMessage";
const meta = {
title: "Text/ErrorMessage",
component: ErrorMessage,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
children: {
control: "text",
description: "표시할 에러 메시지"
},
},
} satisfies Meta<typeof ErrorMessage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
children: "이메일 형식이 올바르지 않습니다",
},
};⚠️ Storybook 10 이벤트 핸들러 필수화
Storybook 10부터는 이벤트 핸들러가 필수 옵션이 되었습니다.
onClick,onChange등의 이벤트 핸들러가 없으면 에러가 발생할 수 있습니다.
해결 방법: fn() 함수 사용
import { fn } from "@storybook/test";
const meta = {
// ...
args: {
onClick: fn(),
// mock 이벤트 핸들러
},
} satisfies Meta<typeof Component>;IconButton 설계 고려사항:
작은 아이콘은 주로 SVG 형식을 사용합니다. IconButton에 아이콘을 전달하는 방법은 여러 가지가 있지만, iconPath를 props로 받는 방식이 관리하기 편합니다:
src/components/IconButton.tsx****:
interface IIconButtonProps {
iconPath: string;
alt: string;
onClick: () => void;
}
export default function IconButton({ iconPath, alt, onClick }: IIconButtonProps) {
return (
<button onClick={onClick} className="p-2">
<img src={iconPath} alt={alt} />
</button>
);
}아이콘 파일 위치 전략:
아이콘 파일은 다음 3가지 위치에 둘 수 있습니다:
public 디렉토리 (추천 ⭐)public/
└── icons/
├── ic-delete.svg
├── ic-edit.svg
└── ic-close.svg/icons/ic-delete.svgsrc/assets 디렉토리src/
└── assets/
└── icons/
└── ic-delete.svgtypescript
iconPath: "https://cdn.example.com/icons/ic-delete.svg"
src/stories/IconButton.stories.ts****:
import type { Meta, StoryObj } from "@storybook/react-vite";
import IconButton from "../components/IconButton";
import { fn } from "@storybook/test";
const meta = {
title: "Buttons/IconButton",
component: IconButton,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
alt: {
control: "text",
description: "아이콘의 대체 텍스트 (접근성)",
},
iconPath: {
control: "text",
description: "아이콘 이미지 경로"
},
onClick: {
action: "clicked",
description: "클릭 이벤트 핸들러"
},
},
args: {
onClick: fn(),
// mock 함수
},
} satisfies Meta<typeof IconButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
alt: "삭제",
iconPath: "/icons/ic-delete.svg",
},
};TextField는 여러 작은 컴포넌트들이 조합된 복합 컴포넌트입니다:
<input> 요소<IconButton> (지우기 버튼)<ErrorMessage> (에러 표시)src/components/DefaultTextField.tsx****:
import { useState } from "react";
import ErrorMessage from "./ErrorMessage";
import IconButton from "./IconButton";
interface IDefaultTextFieldProps {
id: string;
placeholder: string;
value: string;
onChange: React.ChangeEventHandler<HTMLInputElement>;
errorMessage: string;
isError: boolean;
iconPath: string;
iconAlt: string;
onIconClick: React.MouseEventHandler<HTMLButtonElement>;
}
export default function DefaultTextField({
id,
placeholder,
value,
onChange,
errorMessage,
isError,
iconPath,
iconAlt,
onIconClick,
}: IDefaultTextFieldProps) {
const [isFocused, setIsFocused] = useState(false);
// 상태에 따른 border 색상
const borderColor = isFocused
? "border-secondary"
// 포커스 시
: !value
? "border-mono-300"
// 빈 상태
: "border-primary";
// 입력된 상태
return (
<div>
<div
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
className={`
flex items-center
text-primary
border-b-2
${borderColor}
transition-colors
`}
>
<input
id={id}
className="flex-1 outline-none py-2"
placeholder={placeholder}
value={value}
type="text"
onChange={onChange}
/>
{
/* 값이 있을 때만 삭제 버튼 표시 */
}
{!!value && (
<IconButton
onClick={onIconClick}
alt={iconAlt}
iconPath={iconPath}
/>
)}
</div>
{
/* 에러가 있을 때만 에러 메시지 표시 */
}
{isError && <ErrorMessage>{errorMessage}</ErrorMessage>}
</div>
);
}src/stories/DefaultTextField.stories.ts****:
import type { Meta, StoryObj } from "@storybook/react";
import DefaultTextField from "../components/DefaultTextField";
import { fn } from "@storybook/test";
const meta = {
title: "TextFields/DefaultTextField",
component: DefaultTextField,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
id: {
control: "text",
description: "input 요소의 id",
},
placeholder: {
control: "text",
description: "플레이스홀더 텍스트",
},
value: {
control: "text",
description: "현재 입력값",
},
errorMessage: {
control: "text",
description: "에러 메시지",
},
isError: {
control: "boolean",
description: "에러 상태 여부",
},
iconPath: {
control: "text",
description: "삭제 버튼 아이콘 경로",
},
iconAlt: {
control: "text",
description: "아이콘 대체 텍스트",
},
onChange: {
action: "changed",
description: "입력값 변경 이벤트"
},
onIconClick: {
action: "clicked",
description: "삭제 버튼 클릭 이벤트"
},
},
args: {
onChange: fn(),
onIconClick: fn(),
},
} satisfies Meta<typeof DefaultTextField>;
export default meta;
type Story = StoryObj<typeof meta>;
// 기본 상태
export const Default: Story = {
args: {
id: "email",
placeholder: "이메일을 입력해주세요",
value: "",
errorMessage: "",
isError: false,
iconPath: "/icons/ic-delete.svg",
iconAlt: "지우기",
},
};
// 입력된 상태
export const Filled: Story = {
args: {
...Default.args,
value: "example@email.com",
},
};
// 에러 상태
export const Error: Story = {
args: {
...Default.args,
value: "invalid-email",
isError: true,
errorMessage: "올바른 이메일 형식이 아닙니다",
},
};CLS란? 📊
CLS(Cumulative Layout Shift)는 Web Vitals의 하나로, 페이지 로딩 중 예기치 않은 레이아웃 변경을 측정하는 지표입니다.
문제 상황:
ErrorMessage가 나타나면서 아래 콘텐츠가 밀려나는 경우:
// ❌ 나쁜 예: CLS 발생
{isError && <ErrorMessage>{errorMessage}</ErrorMessage>}해결 방법 1: 최소 높이 확보
// ✅ 좋은 예: 최소 높이 설정
<div className="min-h-[20px]">
{isError && <ErrorMessage>{errorMessage}</ErrorMessage>}
</div>해결 방법 2: 항상 공간 차지
// ✅ 좋은 예: 항상 공간 차지 (visibility로 제어)
<div className={isError ? 'visible' : 'invisible'}>
<ErrorMessage>{errorMessage}</ErrorMessage>
</div>CLS가 중요한 이유:
권장 사항:
min-height나 aspect-ratio를 활용하세요width와 height 속성을 지정하세요Decorator가 필요한 상황:
width: 100%를 가진 컴포넌트(NavigationBar, Footer 등)를 Storybook에서 표시할 때:
문제점:
해결: Decorator 사용
Decorator는 스토리를 감싸는 래퍼 컴포넌트입니다. 마치 테스트의 wrapper처럼 동작합니다.
⚠️ 주의사항
Decorator를 사용하는 스토리 파일은 반드시
.tsx확장자를 사용해야 합니다.
src/stories/NavigationBar.stories.tsx****:
import type { Meta, StoryObj } from "@storybook/react-vite";
import NavigationBar from "../components/NavigationBar";
const meta = {
title: "Navigation/NavigationBar",
component: NavigationBar,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
// Decorator: 360px 컨테이너로 감싸기
decorators: [
(Story) => (
<div style={{ width: "360px" }}>
<Story />
</div>
),
],
argTypes: {
title: {
control: "text",
description: "네비게이션 바 제목",
},
showBackButton: {
control: "boolean",
description: "뒤로가기 버튼 표시 여부",
},
showCloseButton: {
control: "boolean",
description: "닫기 버튼 표시 여부",
},
isDark: {
control: "boolean",
description: "다크 모드 여부",
},
onBackButtonClick: {
action: "back-clicked",
description: "뒤로가기 버튼 클릭 이벤트",
},
onCloseButtonClick: {
action: "close-clicked",
description: "닫기 버튼 클릭 이벤트",
},
},
} satisfies Meta<typeof NavigationBar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: "페이지 제목",
showBackButton: true,
showCloseButton: true,
isDark: false,
},
};
// 다크 모드
export const Dark: Story = {
args: {
...Default.args,
isDark: true,
},
};
// 뒤로가기만
export const BackOnly: Story = {
args: {
...Default.args,
showCloseButton: false,
},
};Decorator의 다른 활용 방법:
// 1. 다양한 너비 테스트
decorators: [
(Story) => (
<div>
<div style={{ width: "360px", marginBottom: "20px" }}>
<Story />
</div>
<div style={{ width: "768px" }}>
<Story />
</div>
</div>
),
]
// 2. 테마 제공
decorators: [
(Story) => (
<ThemeProvider theme={darkTheme}>
<Story />
</ThemeProvider>
),
]
// 3. 라우터 제공
decorators: [
(Story) => (
<BrowserRouter>
<Story />
</BrowserRouter>
),
]다양한 테마를 가진 컴포넌트:
PrimaryButton은 여러 테마(dark, light, social, text)를 가집니다. 이럴 때 TypeScript의 Record 타입을 활용하면 타입 안전성을 확보하면서 관리할 수 있습니다.
src/components/PrimaryButton.tsx****:
// 1. 테마 타입 정의
type PrimaryButtonTheme = "dark" | "light" | "social" | "text";
interface IPrimaryButtonProps {
theme: PrimaryButtonTheme;
disabled: boolean;
children: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
}
// 2. 테마별 스타일 정의
const themeStyles: Record<PrimaryButtonTheme, string> = {
dark: "bg-primary text-white",
light: "bg-white text-primary",
social: "bg-secondary text-white",
text: "bg-transparent text-primary",
};
const disabledStyle = "disabled:bg-mono-100 disabled:text-mono-200";
// 3. 컴포넌트
export default function PrimaryButton({
theme,
children,
onClick,
disabled,
}: IPrimaryButtonProps) {
return (
<button
className={`
w-full
h-[59px]
rounded-[8px]
font-bold
transition-colors
${themeStyles[theme]}
${disabledStyle}
`}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}Record 타입의 장점:
src/stories/PrimaryButton.stories.tsx****:
import type { Meta, StoryObj } from "@storybook/react";
import PrimaryButton from "../components/PrimaryButton";
import { fn } from "@storybook/test";
const meta = {
title: "Buttons/PrimaryButton",
component: PrimaryButton,
parameters: {
layout: "centered",
},
decorators: [
(Story) => (
<div style={{ width: "360px" }}>
<Story />
</div>
),
],
tags: ["autodocs"],
argTypes: {
theme: {
control: {
type: "select",
options: ["dark", "light", "social", "text"],
},
description: "버튼 테마",
},
children: {
control: "text",
description: "버튼 텍스트",
},
disabled: {
control: "boolean",
description: "버튼 비활성화 여부",
},
onClick: {
action: "clicked",
description: "클릭 이벤트"
},
},
args: {
onClick: fn(),
},
} satisfies Meta<typeof PrimaryButton>;
export default meta;
type Story = StoryObj<typeof meta>;
// 각 테마별로 스토리 생성
export const Dark: Story = {
args: {
children: "계속하기",
theme: "dark",
disabled: false,
},
};
export const Light: Story = {
args: {
children: "계속하기",
theme: "light",
disabled: false,
},
};
export const Social: Story = {
args: {
children: "Google로 시작하기",
theme: "social",
disabled: false,
},
};
export const Text: Story = {
args: {
children: "더보기",
theme: "text",
disabled: false,
},
};
export const Disabled: Story = {
args: {
children: "계속하기",
theme: "dark",
disabled: true,
},
};여러 스토리 작성의 장점:
TagList 컴포넌트 구현:
TagList는 여러 TagButton으로 구성되며, 선택된 태그를 부모에게 전달해야 합니다. 이때 Generic Type을 사용하면 태그 값의 타입을 안전하게 관리할 수 있습니다.
src/components/TagList.tsx****:
// Generic Type으로 태그 타입 지정
interface ITagListProps<T extends string> {
tags: readonly T[];
// 태그 목록 (readonly로 불변성 보장)
selectedTag: T;
// 현재 선택된 태그
onTagClick: (tag: T) => void;
// 태그 클릭 핸들러
}
export default function TagList<T extends string>({
tags,
selectedTag,
onTagClick,
}: ITagListProps<T>) {
return (
<div
className="flex gap-4"
// Event Bubbling으로 클릭 이벤트 처리
onClick={(event) => {
const target = event.target as HTMLButtonElement;
// textContent를 타입 단언으로 처리
const clickedTag = target.textContent as T;
onTagClick(clickedTag);
}}
>
{tags.map((tag) => (
<button
key={tag}
className={`
px-4 py-2 rounded-full
${tag === selectedTag
? 'bg-primary text-white'
: 'bg-mono-100 text-primary'}
`}
>
{tag}
</button>
))}
</div>
);
}Generic Type의 장점:
// ✅ 타입 안전: 올바른 태그만 전달 가능
const categories = ['개발', '디자인', '기획'] as const;
type Category = typeof categories[number];
// '개발' | '디자인' | '기획'
function handleTagClick(tag: Category) {
console.log(tag);
// '개발' | '디자인' | '기획'만 가능
}
// ❌ 컴파일 에러: '마케팅'은 허용되지 않음
handleTagClick('마케팅');Event Bubbling 이해하기:
Event Bubbling은 자식 요소에서 발생한 이벤트가 부모 요소로 전파되는 것을 말합니다.
<div onClick={handleParentClick}> {
/* 3. 마지막으로 여기 도달 */
}
<ul onClick={handleListClick}> {
/* 2. 여기로 전파 */
}
<li>
<button onClick={handleButtonClick}> {
/* 1. 클릭 발생 */
}
클릭
</button>
</li>
</ul>
</div>TagList에서의 활용:
장점:
src/stories/TagList.stories.tsx****:
import type { Meta, StoryObj } from "@storybook/react";
import TagList from "../components/TagList";
import { fn } from "@storybook/test";
const categories = ['전체', '개발', '디자인', '기획', '마케팅'] as const;
const meta = {
title: "Components/TagList",
component: TagList,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
tags: {
control: "object",
description: "표시할 태그 목록",
},
selectedTag: {
control: "text",
description: "현재 선택된 태그",
},
onTagClick: {
action: "tag-clicked",
description: "태그 클릭 이벤트",
},
},
args: {
onTagClick: fn(),
},
} satisfies Meta<typeof TagList<typeof categories[number]>>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
tags: categories,
selectedTag: '전체',
},
};
export const DesignSelected: Story = {
args: {
tags: categories,
selectedTag: '디자인',
},
};