🎉 berenickt 블로그에 온 걸 환영합니다. 🎉
Front
Storybook
Storybook 개요

1. Storybook

  • 효과적인 디자인 시스템 구성을 위해서는 표준화된 패턴 및 재사용 가능한 컴포넌트의 명세를 명확하게 확인할 수 있는 문서 필요
  • 애플리케이션과 구분된 샌드박스 개발 공간 필요
  • 프론트엔드 기술 스택 - UI 컴포넌트 테스트
  • Storybook
    • 격리된 UI 컴포넌트 개발환경을 구성할 수 있게끔 도와주는 도구
    • 프로젝트 내 디자인 시스템을 효율적으로 개발하고 테스트하기 위해 사용

1.1 Storybook를 사용해야하는 이유

  • 컴포넌트 기반 개발 및 테스팅 용이성: 각 컴포넌트를 독립적으로 개발하고 테스트할 수 있기 때문에 코드의 재사용성이 향상되고 개발자는 컴포넌트의 상태 및 다양한 사용 사례를 효과적으로 관리
  • 문서화 및 디자인 시스템 구축: 컴포넌트의 사용법과 속성을 문서화하고 레퍼런스로 제공
  • 시각적 테스팅 및 디자인 시스템 검증: 시각적 테스팅을 지원하여 UI 컴포넌트의 변경 사항을 빠르게 확인하고 디자인 시스템의 일관성을 유지
  • 빠른 개발 환경 제공: 빠른 프로토타이핑 및 개발 환경을 제공
  • 컴포넌트 간의 상호작용 및 스토리 작성: 컴포넌트 간의 상호작용을 보여주기 위해 스토리를 작성하는 기능을 제공

1.2 Storybook 주요 개념

  • 스토리 (Story): 하나 이상의 컴포넌트 상태를 보여주는 단위
  • 컴포넌트: UI를 구성하는 독립적인 요소
  • 애드온(addon): Storybook에 추가 기 능을 제공하는 확장 기능
  • Manager Area: (좌측) 스토리 목록 및 다양한 작업을 수행할 수 있는 공간
  • Preview Area: (우측) 스토리의 시각 적인 결과물이 표시되는 영역

2. 설치 및 설정

1
npx storybook@latest init
  1. npx storybook@next init 명령어로 Next.js 프로젝트를 위한 스토리북 세팅
    • Next.js를 위한 여러가지 스토리북 파일들이 생성됨 (.storybook/main.ts, .storybook/preview.ts, stories 폴더 등)
  2. storybook/main.ts 파일에서 StorybookConfig에 stories: [‘../app/**/*.stories.tsx'] 코드 추가
    • 혹시나 package.jsonsideEffects: true로 설정되어있으면 제거
  3. tailwind.config.js 파일에 tailwind 적용을 위해서 ‘./stories//*.{js,ts,jsx,tsx,mdx}’ 코드 추가
  4. 원하는 컴포넌트의 스토리 작성 (e.g. Button.stories.ts)
  5. 스토리북을 다시 실행하고 싶다면 yarn storybook 명령어로 실행

2.1 tailwind 추가 설정

.storybook/preview.ts
1
import type { Preview } from '@storybook/react'
2
import '../src/styles/globals.css' // Storybook에 tailwindcss 설정을 적용
3
4
// React 컴포넌트의 미리보기
5
const preview: Preview = {
6
parameters: {
7
// on으로 시작하고 대문자로 시작하는 이름을 가진 속성들을 찾는다.
8
actions: { argTypesRegex: '^on[A-Z].*' },
9
controls: {
10
// 컨트롤의 타입에 따라 일치하는 정규식을 정의
11
matchers: {
12
// color 타입의 컨트롤은 background 또는 color로 끝나는 속성 이름을 가질 수 있다.
13
color: /(background|color)$/i,
14
// date 타입의 컨트롤은 Date로 끝나는 속성 이름을 가질 수 있다.
15
date: /Date$/i,
16
},
17
},
18
},
19
}
20
21
export default preview

2.2 storybook에 폰트 적용 예시

preview-head.html
1
<!-- 스토리북에 Google Material Symbols 적용 -->
2
<!-- @see https://developers.google.com/fonts/docs/material_symbols?hl=ko#use_in_web -->
3
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet" />
4
5
<!-- google font 적용 -->
6
<link href="https://fonts.googleapis.com/css2?family=Black+Han+Sans&display=swap" rel="stylesheet" />
7
8
<link
9
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@100;200;300;400;500;600;700;800;900&display=swap"
10
rel="stylesheet"
11
/>

2.3 Storybook 배포

Chromatic: Storybook에 통합되어 시각적 테스팅과 디자인 시스템 관리를 위한 온라인 플랫폼

  1. Chromatic 설치: yarn add —dev chromatic
  2. Chromatic 계정 생성 및 프로젝트 추가 (https://www.chromatic.com/)
  3. Chromatic 사이트에서 프로젝트 생성 후, Publish 명령어 입력
    • npx chromatic —project-token=TOKEN_RANDOM
  4. 스토리북 publishing이 완료되면, 터미널에 배포된 chromatic url에 들어가서 스토리 확인

3. 버튼 컴포넌트 스토리 예시

3.1 index.tsx

버튼 props

위 그림과 같이 주석 자체가 그대로 문서화된다. 이게 가능한 이유는 stories.ts에 tags: ['autodocs']를 추가해줬기 때문.

1
import classNames from 'classnames'
2
3
import Spinner from '../Spinner'
4
5
/**
6
* React.ComponentPropsWithoutRef<'button'>은 button 요소의 ref을 제외한 모든 속성을 상속받는다.
7
*/
8
interface Props extends React.ComponentPropsWithoutRef<'button'> {
9
/**
10
* 버튼 크기를 지정합니다 (기본값: 'md')
11
*/
12
size?: 'xs' | 'sm' | 'md'
13
/**
14
* 버튼 색상을 지정합니다 (기본값: 'black')
15
*/
16
color?: 'black' | 'grey' | 'orange' | 'red'
17
/**
18
* 버튼 내부 색상이 칠해져 있는지 여부를 지정합니다
19
*/
20
outline?: boolean
21
/**
22
* 사용자 인터렉션이 진행되고 있는지 여부를 지정합니다
23
*/
24
isLoading?: boolean
25
/**
26
* 버튼이 width: 100%여야 하는 경우 사용합니다
27
*/
28
fullWidth?: boolean
29
}
30
31
/**
32
* 버튼을 표시하기 위한 컴포넌트
33
*/
34
export default function Button({
35
color = 'black',
36
size = 'md',
37
outline,
38
fullWidth,
39
isLoading,
40
children,
41
...props // 나머지 모든 속성을 props로 전달
42
}: Props) {
43
return (
44
<button
45
{...props}
46
disabled={isLoading || props.disabled} // 로딩 중이면, disabled 속성을 추가
47
className={classNames(
48
props.className, // 다른 클래스 이름이 있다면 그것도 포함
49
'disabled:opacity-50 relative', // disabled 속성이 있을 때, 투명도를 50%로 변경
50
size === 'xs' && 'text-xs px-2',
51
size === 'sm' && 'text-sm px-3 py-1',
52
size === 'md' && 'text-base px-5 py-2',
53
fullWidth && 'w-full',
54
outline === true
55
? {
56
'text-black': true,
57
border: true,
58
'border-black': color === 'black',
59
'border-slate-300': color === 'grey',
60
'border-orange-500': color === 'orange',
61
'border-red-700': color === 'red',
62
}
63
: {
64
'text-white': true,
65
'bg-black': color === 'black',
66
'bg-slate-300': color === 'grey',
67
'bg-orange-500': color === 'orange',
68
'bg-red-500': color === 'red',
69
},
70
)}
71
>
72
{isLoading ? (
73
<>
74
{/* absolute : 요소를 절대위치로 띄움 */}
75
<div className="absolute top-0 left-0 h-full w-full flex justify-center items-center">
76
<Spinner size={size} />
77
</div>
78
{/* opacity-0 : 요소를 화면에만 숨겨서, 너비를 그대로 함 */}
79
<div className="opacity-0">{children}</div>
80
</>
81
) : (
82
children
83
)}
84
</button>
85
)
86
}

3.2 index.stories.ts

1
import type { Meta, StoryObj } from '@storybook/react'
2
3
import Button from '.'
4
5
const meta = {
6
title: 'Button',
7
component: Button,
8
tags: ['autodocs'], // "autodocs" tags를 추가하면, 컴포넌트 문서 작성 가능
9
args: {
10
children: 'Hello World!', // 컴포넌트의 기본 텍스트
11
},
12
} satisfies Meta<typeof Button>
13
14
/**
15
* StoryObj 타입 : Storybook의 스토리 객체를 나타냄
16
*/
17
export default meta
18
type Story = StoryObj<typeof meta>
19
20
export const DefaultButton: Story = {
21
args: {},
22
}

4. input 컴포넌트 스토리 예시

4.1 index.tsx

1
import classNames from 'classnames'
2
import { ForwardedRef, forwardRef } from 'react'
3
4
interface Props extends React.ComponentPropsWithoutRef<'input'> {}
5
6
/**
7
* Input 요소를 처리하기 위한 컴포넌트
8
* @description forwardRef : ref를 전달하기 위한 함수
9
* @see https://ko.react.dev/reference/react/forwardRef
10
*/
11
const Input = forwardRef(function Input(
12
{ ...props }: Props, // 나머지 모든 속성을 props로 전달
13
ref: ForwardedRef<HTMLInputElement>,
14
) {
15
return (
16
<input
17
{...props}
18
ref={ref} // ref를 전달
19
className={classNames(props.className, 'border text-base p-2 outline-none text-black disabled:opacity-50')}
20
/>
21
)
22
})
23
24
export default Input

4.2 index.stories.ts

1
import type { Meta, StoryObj } from '@storybook/react'
2
3
import Input from '.'
4
5
const meta = {
6
title: 'Input',
7
component: Input,
8
tags: ['autodocs'], // "autodocs" tags를 추가하면, 컴포넌트 문서 작성 가능
9
args: {
10
value: 'Hello World!', // 컴포넌트의 기본 텍스트
11
disabled: false,
12
},
13
} satisfies Meta<typeof Input>
14
15
/**
16
* StoryObj 타입 : Storybook의 스토리 객체를 나타냄
17
*/
18
export default meta
19
type Story = StoryObj<typeof meta>
20
21
export const DefaultInput: Story = {
22
args: {},
23
}

5. text 컴포넌트 스토리 예시

5.1 index.tsx

1
import classNames from 'classnames'
2
3
/***
4
* React.ComponentPropsWithoutRef<'span'>은 span 요소의 ref을 제외한 모든 속성을 상속받는다.
5
*/
6
interface Props extends React.ComponentPropsWithoutRef<'span'> {
7
/**
8
* 텍스트의 크기를 설정합니다. (기본값: md)
9
*/
10
size?: '4xl' | '3xl' | '2xl' | 'xl' | 'lg' | 'md' | 'sm' | 'xs'
11
/**
12
* 텍스트의 색상을 설정합니다. (기본값: black)
13
*/
14
color?: 'black' | 'grey' | 'red' | 'white'
15
/**
16
* 텍스트의 굵기를 설정합니다. (기본값: normal)
17
*/
18
weight?: 'light' | 'normal' | 'bold'
19
}
20
21
/**
22
* 일반적인 텍스트를 표시하기 위한 컴포넌트
23
*/
24
export default function Text({
25
size = 'md',
26
color = 'black',
27
weight = 'normal',
28
...props // 나머지 모든 속성을 props로 전달
29
}: Props) {
30
return (
31
<span
32
{...props}
33
className={classNames(
34
props.className, // 다른 클래스 이름이 있다면 그것도 포함
35
{
36
'text-4xl': size === '4xl',
37
'text-3xl': size === '3xl',
38
'text-2xl': size === '2xl',
39
'text-xl': size === 'xl',
40
'text-lg': size === 'lg',
41
'text-base': size === 'md',
42
'text-sm': size === 'sm',
43
'text-xs': size === 'xs',
44
},
45
{
46
'text-black': color === 'black',
47
'text-zinc-400': color === 'grey',
48
'text-red-500': color === 'red',
49
'text-white': color === 'white',
50
},
51
{
52
'font-light': weight === 'light',
53
'font-normal': weight === 'normal',
54
'font-bold': weight === 'bold',
55
},
56
)}
57
/>
58
)
59
}

5.2 index.stories.ts

1
import type { Meta, StoryObj } from '@storybook/react'
2
3
import Text from '.'
4
5
const meta = {
6
title: 'Text',
7
component: Text,
8
tags: ['autodocs'],
9
args: {
10
children: 'Hello World!', // 컴포넌트의 기본 텍스트
11
},
12
} satisfies Meta<typeof Text>
13
14
/**
15
* StoryObj 타입 : Storybook의 스토리 객체를 나타냄
16
*/
17
export default meta
18
type Story = StoryObj<typeof meta>
19
20
export const DefaultText: Story = {
21
args: {},
22
}
23
24
export const SizedText: Story = {
25
args: { size: '4xl' },
26
}
27
28
export const ColoredText: Story = {
29
args: { color: 'red' },
30
}
31
32
export const WeightedText: Story = {
33
args: { weight: 'bold' },
34
}

6. Spiner 컴포넌트 스토리 예시

6.1 index.tsx

1
interface Props {
2
/**
3
* 스피너의 크기를 지정합니다 (기본값: 'md')
4
*/
5
size?: 'xs' | 'sm' | 'md'
6
}
7
8
/**
9
* 스피너를 표시하기 위한 컴포넌트
10
* @see https://tailwindcss.com/docs/animation#spin
11
*/
12
export default function Spinner({ size = 'md' }: Props) {
13
return (
14
<span
15
className="material-symbols-outlined animate-spin"
16
style={{
17
fontSize: size === 'md' ? '1rem' : size === 'sm' ? '0.875rem' : '0.75rem',
18
}}
19
>
20
progress_activity
21
</span>
22
)
23
}

6.2 index.stories.ts

1
import type { Meta, StoryObj } from '@storybook/react'
2
3
import Spinner from '.'
4
5
const meta = {
6
title: 'Spinner',
7
component: Spinner,
8
tags: ['autodocs'],
9
} satisfies Meta<typeof Spinner>
10
11
/**
12
* StoryObj 타입 : Storybook의 스토리 객체를 나타냄
13
*/
14
export default meta
15
type Story = StoryObj<typeof meta>
16
17
export const DefaultSpinner: Story = {
18
args: {},
19
}

7. Product 컴포넌트 스토리 예시

7.1 index.tsx

1
import dayjs from 'dayjs'
2
import relativeTime from 'dayjs/plugin/relativeTime'
3
import 'dayjs/locale/ko'
4
5
import Text from '../Text'
6
7
interface Props {
8
/** 상품 이름 */
9
title: string
10
/** 상품 가격 */
11
price: number
12
/** 상품 등록 시간 */
13
createdAt: string
14
/** 상품 대표 이미지 주소 */
15
imageUrl: string
16
/** 상품 판매 여부 */
17
isSoldOut?: boolean
18
}
19
20
/***
21
* @see https://day.js.org/docs/en/plugin/relative-time#docsNav
22
*/
23
dayjs.extend(relativeTime).locale('ko')
24
25
/**
26
* 상품 미리보기 컴포넌트
27
* @returns
28
*/
29
export default function Product({ title, price, createdAt, imageUrl, isSoldOut }: Props) {
30
return (
31
<div className="flex flex-col border border-slate-300 relative">
32
{/* 판매 완료 시 투명도 및 판매 완료 문구 표시 */}
33
{isSoldOut && (
34
<div className="absolute top-0 left-0 w-full h-full bg-slate-900 opacity-70 flex justify-center items-center">
35
<Text color="white">판매 완료</Text>
36
</div>
37
)}
38
{/* 이미지 */}
39
<div className="h-36 bg-cover bg-center" style={{ backgroundImage: `url(${imageUrl})` }} />
40
{/* 설명 */}
41
<div className="h-20 flex flex-col px-3 justify-center">
42
{/* 제품명 */}
43
{/* 텍스트가 너무 길어서 한 줄로 표시되지 않을 때, 넘치는 부분을 ...으로 표시 */}
44
<Text className="text-ellipsis overflow-hidden whitespace-nowrap block">{title}</Text>
45
<div className="flex justify-between items-center flex-wrap">
46
{/* 가격 */}
47
{/* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString#using_tolocalestring */}
48
<div>
49
<Text weight="bold"> {price.toLocaleString()} </Text>
50
<Text weight="bold" size="sm">
51
52
</Text>
53
</div>
54
{/* 날짜 */}
55
{/* @see https://day.js.org/docs/en/display/from-now#docsNav */}
56
<Text weight="light" color="grey" size="sm">
57
{dayjs(createdAt).fromNow()}
58
</Text>
59
</div>
60
</div>
61
</div>
62
)
63
}

7.2 index.stories.ts

1
import { faker } from '@faker-js/faker'
2
import type { Meta, StoryObj } from '@storybook/react'
3
4
import Product from '.'
5
6
const meta = {
7
title: 'Product',
8
component: Product,
9
tags: ['autodocs'], // "autodocs" tags를 추가하면, 컴포넌트 문서 작성 가능
10
args: {},
11
// decorators : 추가적인 값을 부여할 수 있는 옵션
12
decorators: [
13
// Story 하나 하나를 그릴 떄, 데코레이터로 감싸져서 그려짐
14
// 여기서는 가로 사이즈를 52로 고정하는 데코레이터를 추가
15
Story => (
16
<div className="w-52">
17
<Story />
18
</div>
19
),
20
],
21
} satisfies Meta<typeof Product>
22
23
/**
24
* StoryObj 타입 : Storybook의 스토리 객체를 나타냄
25
*/
26
export default meta
27
type Story = StoryObj<typeof meta>
28
29
export const DefaultProduct: Story = {
30
args: {
31
title: '샘플 제품',
32
price: 50_000,
33
createdAt: '2021-01-01',
34
imageUrl: faker.image.dataUri(), // Faker 랜덤 이미지 주소 생성
35
},
36
}
37
38
export const SoldOutProduct: Story = {
39
args: {
40
title: '샘플 제품',
41
price: 50_000,
42
createdAt: '2021-01-01',
43
imageUrl: faker.image.dataUri(), // Faker 랜덤 이미지 주소 생성
44
isSoldOut: true,
45
},
46
}

8. ShopProfileImage 컴포넌트 스토리 예시

8.1 index.tsx

1
import classNames from 'classnames'
2
3
interface Props {
4
/** 상점 프로필 이미지 주소 */
5
imageUrl?: string
6
/** 추가 ClassName 부여가 필요한 경우 사용할 Props */
7
className?: string
8
}
9
10
/**
11
* 상점 프로필 이미지 컴포넌트.
12
* ImageUrl Props를 넘기지 않으면 기본 상점 아이콘을 화면에 보여준다.
13
* */
14
export default function ShopProfileImage({ imageUrl, className }: Props) {
15
// 이미지가 없는 경우
16
if (!imageUrl) {
17
return (
18
<div className={classNames(className, 'rounded-full bg-slate-200 w-14 h-14 flex justify-center items-center')}>
19
<span className="material-symbols-outlined text-slate-500">storefront</span>
20
</div>
21
)
22
}
23
24
// 이미지가 있는 경우
25
return (
26
<div
27
className={classNames(className, 'rounded-full w-14 h-14 bg-cover bg-center')}
28
style={{ backgroundImage: `url(${imageUrl})` }}
29
/>
30
)
31
}

8.2 index.stories.ts

1
import { faker } from '@faker-js/faker'
2
import type { Meta, StoryObj } from '@storybook/react'
3
4
import ShopProfileImage from '.'
5
6
const meta = {
7
title: 'ShopProfileImage',
8
component: ShopProfileImage,
9
tags: ['autodocs'], // "autodocs" tags를 추가하면, 컴포넌트 문서 작성 가능
10
args: {},
11
} satisfies Meta<typeof ShopProfileImage>
12
13
/**
14
* StoryObj 타입 : Storybook의 스토리 객체를 나타냄
15
*/
16
export default meta
17
type Story = StoryObj<typeof meta>
18
19
export const DefaultShopProfileImage: Story = {
20
args: {},
21
}
22
23
export const ImagedShopProfileImage: Story = {
24
args: {
25
imageUrl: faker.image.dataUri(), // Faker 랜덤 이미지 주소 생성
26
},
27
}

9. Shop 컴포넌트 스토리 예시

9.1 index.tsx

1
import classNames from 'classnames'
2
3
import ShopProfileImage from '../ShopProfileImage'
4
import Text from '../Text'
5
6
interface Props {
7
/** 상점 이름 */
8
name: string
9
/** 상점 프로필 이미지 주소 */
10
profileImageUrl?: string
11
/** 상점에 등록된 상품 수 */
12
productCount: number
13
/** 상점을 팔로우 하는 팔로워 수 */
14
followerCount: number
15
/** 상점 컴포넌트 뷰 타입 */
16
type?: 'row' | 'column'
17
/** 상점 타이틀 영역 클릭시 동작할 콜백 함수 */
18
handleClickTitle?: () => void
19
/** 상점 프로필 이미지 영역 클릭시 동작할 콜백 함수 */
20
handleClickProfileImage?: () => void
21
/** ProductCount 영역 클릭시 동작할 콜백 함수 */
22
handleClickProductCount?: () => void
23
/** FollowerCount 영역 클릭시 동작할 콜백 함수 */
24
handleClickFollowerCount?: () => void
25
}
26
27
export default function Shop({
28
name,
29
profileImageUrl,
30
productCount,
31
followerCount,
32
handleClickTitle,
33
handleClickProfileImage,
34
handleClickProductCount,
35
handleClickFollowerCount,
36
type = 'row',
37
}: Props) {
38
return (
39
<div
40
className={classNames(
41
'flex',
42
// row, column에 따라 다른 flex 스타일 적용
43
{
44
'flex-row': type === 'row',
45
'flex-col': type === 'column',
46
},
47
type === 'column' && 'gap-1 items-center',
48
)}
49
>
50
{/* 상점 이미지 영역 */}
51
<div
52
className={classNames('w-14', handleClickProfileImage && 'cursor-pointer')}
53
onClick={handleClickProfileImage}
54
>
55
<ShopProfileImage imageUrl={profileImageUrl} />
56
</div>
57
{/* 상정 설명 영역 */}
58
<div
59
className={classNames(
60
'flex flex-col overflow-hidden', // overflow-hidden : 요소 밖으로 삐져나오는 컨텐츠를 숨김
61
type === 'row' && 'ml-3 justify-around',
62
type === 'column' && 'w-full', // column 타입일 때 가로폭 100%
63
)}
64
>
65
{/* 상점 설명 - 제목 */}
66
<div
67
className={classNames(
68
'truncate', // 글자가 너무 길면 ...으로 표시
69
type === 'column' && 'text-center',
70
handleClickTitle && 'cursor-pointer',
71
)}
72
onClick={handleClickTitle}
73
>
74
<Text>{name}</Text>
75
</div>
76
{/* 상점 설명 - 상품 수, 팔로워 수 */}
77
<Text
78
size="sm"
79
color={type === 'row' ? 'grey' : 'black'}
80
className={classNames('flex gap-2', type === 'column' && 'justify-center')}
81
>
82
{/* 상품 수 */}
83
<div
84
className={classNames('text-center', handleClickProductCount && 'cursor-pointer')}
85
onClick={handleClickProductCount}
86
>
87
상품 {productCount.toLocaleString()}
88
</div>
89
|{/* 팔로워 수 */}
90
<div
91
className={classNames('text-center', handleClickFollowerCount && 'cursor-pointer')}
92
onClick={handleClickFollowerCount}
93
>
94
팔로워 {followerCount.toLocaleString()}
95
</div>
96
</Text>
97
</div>
98
</div>
99
)
100
}

9.2 index.stories.ts

1
import { faker } from '@faker-js/faker'
2
import type { Meta, StoryObj } from '@storybook/react'
3
4
import Shop from '.'
5
6
const meta = {
7
title: 'Shop',
8
component: Shop,
9
tags: ['autodocs'],
10
args: {},
11
// decorators : 추가적인 값을 부여할 수 있는 옵션
12
decorators: [
13
// Story 하나 하나를 그릴 떄, 데코레이터로 감싸져서 그려짐
14
Story => (
15
<div className="border p-2" style={{ width: 300 }}>
16
<Story />
17
</div>
18
),
19
],
20
} satisfies Meta<typeof Shop>
21
22
/**
23
* StoryObj 타입 : Storybook의 스토리 객체를 나타냄
24
*/
25
export default meta
26
type Story = StoryObj<typeof meta>
27
28
export const DefaultShop: Story = {
29
args: {
30
name: '상점',
31
productCount: 999,
32
followerCount: 999,
33
type: 'row',
34
},
35
}
36
37
export const ImagedShop: Story = {
38
args: {
39
name: '상점',
40
productCount: 999,
41
followerCount: 999,
42
type: 'row',
43
profileImageUrl: faker.image.dataUri(), // Faker 랜덤 이미지 주소 생성
44
},
45
}

10. Pagination 컴포넌트 스토리 예시

10.1 index.tsx

1
type Props = {
2
/** 현재 사용자가 보고 있는 페이지 */
3
currentPage: number
4
/** 전체 항목의 갯수 (단, 한 페이지는 10개의 항목을 가지고 있어야 한다) */
5
count: number
6
/** 사용자가 페이지를 변경하였을때 호출할 콜백 함수 */
7
handlePageChange: (pageNumber: number) => void
8
}
9
10
const btnClassName =
11
'border border-slate-300 px-2 py-2 flex justify-center items-center leading-none disabled:opacity-30 hover:bg-slate-200'
12
13
/**
14
* 페이지네이션을 표시하기 위한 컴포넌트
15
*/
16
export default function Pagination({ currentPage, count, handlePageChange }: Props) {
17
const totalPage = Math.ceil(count / 10) // 한 페이지에 10개의 항목이 있다고 가정, 올림 처리
18
19
// max함수를 이용해 1보다 작은 값이 나오지 않도록 처리
20
const startPageIndex = Math.max(
21
1, // 1부터 시작하도록
22
// 5개의 페이지의 시작페이지가 1이 되고, 현재 페이지가 중앙에 위치하도록 3인 경우 1이 나오도록
23
Math.min(totalPage - 4, currentPage - 2),
24
)
25
// 마지막 페이지가 1인 경우 4를 더하고, 전체 페이지 중 최소값
26
const endPageIndex = Math.min(startPageIndex + 4, totalPage)
27
28
if (totalPage < 2) return null
29
30
return (
31
<div className="flex gap-1 my-3">
32
{/* 이전 버튼 */}
33
<button className={btnClassName} disabled={currentPage === 1} onClick={() => handlePageChange(currentPage - 1)}>
34
이전
35
</button>
36
{/* 페이지 버튼 */}
37
{Array.from({ length: endPageIndex - startPageIndex + 1 }).map((_, idx) => {
38
const pageIndex = startPageIndex + idx
39
return (
40
<button
41
className={btnClassName}
42
key={pageIndex}
43
disabled={pageIndex === currentPage}
44
onClick={() => handlePageChange(pageIndex)}
45
>
46
{pageIndex}
47
</button>
48
)
49
})}
50
{/* 다음 버튼 */}
51
<button
52
className={btnClassName}
53
disabled={currentPage === totalPage}
54
onClick={() => handlePageChange(currentPage + 1)}
55
>
56
다음
57
</button>
58
</div>
59
)
60
}

10.2 index.stories.ts

1
import type { Meta, StoryObj } from '@storybook/react'
2
3
import Pagination from '.'
4
5
const meta = {
6
title: 'Pagination',
7
component: Pagination,
8
tags: ['autodocs'],
9
args: {},
10
} satisfies Meta<typeof Pagination>
11
12
export default meta
13
type Story = StoryObj<typeof meta>
14
15
export const DefaultProduct: Story = {
16
args: {
17
currentPage: 1,
18
count: 100,
19
},
20
}