> For the complete documentation index, see [llms.txt](https://yamoo9.gitbook.io/learning-react-app/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://yamoo9.gitbook.io/learning-react-app/tip-and-references/storybook-for-react/write-story.md).

# Story 작성

## Story란? <a href="#what-is-story" id="what-is-story"></a>

Story는 UI 컴포넌트의 렌더링 된 상태를 캡처합니다. 인자 집합이 주어지면 컴포넌트 상태를 반환하는 함수입니다.

{% hint style="info" %}
Storybook은 React 또는 Vue의 **props**, Angular의 **@Input** 및 기타 유사한 개념(전달 된 속성)을 일반적인 용어인 인자(줄여서 **args**)를 사용합니다.
{% endhint %}

![](/files/-MVW8dBiA74HDYPiZ5P8)

## Story 구성 <a href="#configure-story" id="configure-story"></a>

Storybook은 컴포넌트와 그 하위 스토리의 2가지 기본 단계로 구성되어 있습니다. 스토리는 컴포넌트에 대한 개별 이야기입니다. 필요한 만큼의 스토리를 컴포넌트 별로 작성할 수 있습니다.

```python
컴포넌트
├── 스토리
├── 스토리
├── ...
└── 스토리
```

## Story 파일 생성 <a href="#create-story-file" id="create-story-file"></a>

Story는 컴포넌트 파일이 위치한 디렉토리 안에 작성합니다. 이 파일은 개발용이며 프로덕션 번들에 포함되지 않습니다. Story를 구성할 컴포넌트를 작성한 후, 컴포넌트 파일과 같은 위치에 Story 파일을 추가합니다.

{% tabs %}
{% tab title="디렉토리 구조" %}

```python
components/
└─ StyleInput/
   ├─ StoryInput.scss
   ├─ StyleInput.js
   └─ StyleInput.stories.js # Story 파일
```

{% endtab %}
{% endtabs %}

## Story 파일 포멧 <a href="#story-file-format" id="story-file-format"></a>

[컴포넌트 스토리 포멧(CSF)](https://storybook.js.org/docs/react/api/csf)은 Story를 구성하는 객체를 말하며, 작성할 Story의 정보를 작성합니다.

| **속성**                                                                                          | **설명**                                         |
| ----------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| [title](https://storybook.js.org/docs/react/configure/sidebar-and-urls#permalinking-to-stories) | Storybook 앱 사이드바에 표시되는 컴포넌트 이름                 |
| [component](https://storybook.js.org/docs/react/writing-docs/docs-page#component-parameter)     | Story를 작성 할 컴포넌트 설정 (컴포넌트 설명, `props` 추출)      |
| [args](https://storybook.js.org/docs/react/writing-stories/args#gatsby-focus-wrapper)           | 모든 Story에 공통 적용할 전달 인자 설정                      |
| [argTypes](https://storybook.js.org/docs/react/api/argtypes)                                    | 각 Story 인자(args)의 행동(behaviour) 방식 설정          |
| [decorators](https://storybook.js.org/docs/react/writing-stories/decorators)                    | Story를 감싸는 렌더링 함수 (Story 보강, 렌더링 세부 정보 수집 등)   |
| [parameters](https://storybook.js.org/docs/react/writing-stories/parameters#story-parameters)   | Story에 대한 정적 메타 데이터 정의 (다양한 애드온 구성 제공)         |
| [excludeStories](https://storybook.js.org/docs/react/api/csf#non-story-exports)                 | Storybook에서 Story를 내보낼 때 렌더링에서 제외 설정 (정규 표현 식) |

컴포넌트 스토리 포멧(CSF)은 "[기본(default) 내보내기](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/export)"로 내보내야 합니다.

{% tabs %}
{% tab title="Component Story Format (CSF)" %}

```javascript
export default {
  title: '시스템/그룹/컴포넌트 이름',
  component: Component,
  // args: {},
  // argTypes: {}
  // decorators: []
  // ...
}
```

{% endtab %}
{% endtabs %}

## Story 작성(정의) <a href="#define-story" id="define-story"></a>

컴포넌트의 Story(CSF)는 "[지정 된 이름(named) 내보내기](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/export)"를 사용하여 컴포넌트 Story를 정의합니다. Story 이름은 TitleCase로 작성하는 것이 권장됩니다.

```jsx
export const Story = () => <Button secondary>스토리 버튼</Buton>
```

Storybook에 표시되는 Story 이름을 변경해야 한다면? `storyName` 속성을 사용할 수 있습니다.

```jsx
StoryButton.storyName = 'Secondary Button'
```

## 멀티 Story 작성 <a href="#write-multi-story" id="write-multi-story"></a>

Story는 컴포넌트를 렌더링 하는 방법을 설명하는 함수입니다. 컴포넌트 마다 여러 개의 Story를 가질 수 있습니다. Story를 만드는 가장 간단한 방법은 인자가 다른 컴포넌트를 여러 번 렌더링하는 것입니다.

```jsx
export const Primary = () => <Button children="프라이머리 버튼" />
export const Secondary = () => <Button secondary children="세컨더리 버튼" />
export const Tertiary = () => <Button tertiary children="터시어리 버튼" />
```

{% hint style="warning" %}
이 방법은 작성이 간단하지만, 많은 Story를 관리해야 할 경우 유지보수가 어렵습니다.
{% endhint %}

## 템플릿, 인자 활용 <a href="#using-template-args" id="using-template-args"></a>

하나 이상 컴포넌트의 스토리를 만들 경우 `Template` 변수에 컴포넌트 복사본을 할당하는 것이 편리합니다. 이 패턴을 Story에 도입하면 작성 또는 유지보수 해야 할 코드 양이 줄어듭니다.

```jsx
// Story 템플릿
const Template = (args) => <Button {...args} />

// 템플릿 복사본
export const Primary = Template.bind({})
export const Secondary = () => Template.bind({})
export const Tertiary = () => Template.bind({})

// 복사한 각 템플릿(Copyed Story)에 인자 설정
Primary.args = { children: "프라이머리 버튼" }
Secondary.args = { secondary: true, children: "세컨더리 버튼" }
Tertiary.args = { tertiary: true, children: "터시어리 버튼" }
```

{% hint style="info" %}
**Template.bind({ })**&#xB294; 함수의 복사본을 만드는 [표준 JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind)의 기법입니다. 이 기법을 사용하여 각각의 스토리가 고유한 속성(properties)을 갖지만, 동시에 동일한 구현을 사용하도록 할 수 있습니다.
{% endhint %}

인자(arguments)는 줄여서 `args`를 사용하며, Storybook을 다시 시작하지 않고도 Controls addon으로 컴포넌트를 실시간으로 수정할 수 있습니다. 값이 변하면 컴포넌트도 실시간 업데이트 됩니다.

{% hint style="warning" %}
**Primary.args**에서 의문이 들 수 있습니다. args는 JavaScript 함수의 표준 속성이 아니기 때문이죠. 이는 Storybook이 컴포넌트를 렌더링 하는 과정에서 Primary에 **args**를 전달하므로 정상 작동하는 것입니다.
{% endhint %}

아래 코드는 Story 작성 예시입니다.

{% tabs %}
{% tab title="StoryInput.stories.js" %}

```jsx
// Story를 구성할 컴포넌트 파일 불러오기
import StoryInput from './StoryInput'

/* ------------------------------------------------------------------- */

export default {
  // 컴포넌트 설명을 입력하면 Storybook에 카테고리 되어 표시됩니다.
  title: 'FormControl/StoryInput',
  // 컴포넌트 설정
  component: StoryInput,
  // 전달인자 공통 설정
  args: {
    label: '이메일',
    type: 'email',
    placeholder: 'yamoo9@euid.dev',
  },
  // 전달 인자 유형 설정
  argTypes: {
    backgroundColor: { control: 'color' },
    disabled: { control: 'boolean' },
  },
}

// 컴포넌트 템플릿
// 함수의 복사본을 만드는 표준 JavaScript 기법
const Template = (args) => <StoryInput {...args} />

// sm 사이즈 컴포넌트
export const SmSize = Template.bind({})
SmSize.storyName = 'Small'
SmSize.args = {
  id: 'sm-size-kwdj1',
  size: 'sm',
}
SmSize.parameters = {
  viewport: {
    defaultViewport: 'iphonex',
  },
}

// md 사이즈 컴포넌트
export const MdSize = Template.bind({})
MdSize.storyName = 'Medium'
MdSize.args = {
  id: 'md-size-kwdj5',
  size: 'md',
}
MdSize.parameters = {
  viewport: {
    defaultViewport: 'iphonexr',
  },
}

// lg 사이즈 컴포넌트
export const LgSize = Template.bind({})
LgSize.storyName = 'Large'
LgSize.args = {
  id: 'lg-size-kwdj8',
  size: 'lg',
}
```

{% endtab %}

{% tab title="StoryInput.js" %}

```javascript
import React from 'react'
import PropTypes from 'prop-types'
import './StoryInput.scss'

// Story를 구성할 컴포넌트를 작성합니다.
const StoryInput = ({ id, label, className, size, ...restProps }) => (
  <div className={`storyInput ${className} ${size}`.trim()}>
    <label htmlFor={id}>{label}</label>
    <input id={id} type="text" {...restProps} />
  </div>
)

export default StoryInput

// 컴포넌트 속성 검사를 설정하면 Story 문서에 반영됩니다.
// 컴포넌트에 필요한 데이터 형태를 명시하려면 React에서 propTypes를 사용하는 것이 가장 좋습니다.
// 이는 자체적 문서화일 뿐만 아니라, 문제를 조기에 발견하는 데 도움이 됩니다.
StoryInput.propTypes = {
  /** label 요소와 input 요소를 연결하는 key */
  id: PropTypes.string.isRequired,
  /** UI에 표시되는 레이블 */
  label: PropTypes.string.isRequired,
  /** 레이블을 UI에서 감춤 (스크린 리더 사용자에게는 읽힘) */
  labelHidden: PropTypes.bool,
  /** 플레이스홀더 */
  placeholder: PropTypes.string,
  /** 커스텀 클래스 이름 */
  className: PropTypes.string,
  /** 설정 가능한 인풋 타입 */
  type: PropTypes.oneOf(['text', 'email', 'password', 'search']),
  /** 인풋 크기 */
  size: PropTypes.oneOf(['sm', 'md', 'lg']),
}

// 컴포넌트 기본 속성을 설정하면 Story 문서에 반영됩니다.
StoryInput.defaultProps = {
  type: 'text',
  className: '',
  size: 'md',
  labelHidden: false,
}
```

{% endtab %}

{% tab title="StoryInput.scss" %}

```css
@use 'sass:map';

$colors: (
  dark: (
    label: #767f96,
    input: (
      border: #1f57e7,
      bg: #292f3a,
      fg: #f5f5f5,
    ),
  ),
  light: (
    label: #595d65,
    input: (
      border: #9f9da9,
      bg: #fdfdfd,
      fg: #08163a,
    ),
  ),
);

@function getLabelColor($theme-name: light) {
  $theme: map.get($colors, $theme-name);
  @return map.get($theme, label);
}

@function getInputColor($name, $theme-name: light) {
  $theme: map.get($colors, $theme-name);
  $input: map.get($theme, input);
  @return map.get($input, $name);
}

.storyInput {
  $size: 14px;

  display: inline-flex;
  flex-direction: column;

  label {
    margin-bottom: 0.4em;
    color: getLabelColor();
  }

  input {
    border: 2px solid rgba(getInputColor(border), 0.4);
    border-radius: 8px;
    padding: 1em;
    background: getInputColor(bg);
    color: getInputColor(fg);

    &:focus {
      outline: 0;
      border: 2px solid getInputColor(border);
      box-shadow: 2px solid rgba(getInputColor(border), 0.4);
    }
  }

  // Dark Mode
  .dark & {
    label {
      color: getLabelColor(dark);
    }
    input {
      border-color: rgba(getInputColor(border, dark), 0.4);
      background: getInputColor(bg, dark);
      color: getInputColor(fg, dark);

      &:focus {
        border-color: getInputColor(border, dark);
        box-shadow: 2px solid rgba(getInputColor(border, dark), 0.4);
      }
    }
    ::placeholder {
      color: #767f96;
    }
  }

  // Size

  &.sm {
    label,
    input {
      font-size: $size * 0.8;
    }
  }

  &.md,
  input {
    label {
      font-size: $size;
    }
  }

  &.lg {
    label,
    input {
      font-size: $size * 1.2;
    }
  }
}
```

{% endtab %}
{% endtabs %}

## Storybook 구동 <a href="#run-storybook" id="run-storybook"></a>

storybook 구동 명령을 사용해 Storybook을 웹 브라우저에서 확인할 수 있습니다.

```bash
npm run storybook
```

#### Light 모드

![](/files/-MVW5jGerMj-NK4a166g)

#### Dark 모드 (별도 설정 필요)

![](/files/-MVW5pOEi7z91ViiIhMW)

## Storybook 스니펫 <a href="#snippets" id="snippets"></a>

[Storybook 컴포넌트 스니펫](https://snippet-generator.app/?description=Storybook+%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8+Story\&tabtrigger=sb-component\&snippet=import+%241+from+%27.%2F%241%27%0A%0Aconst+storyConfig+%3D+%7B%0A++%2F%2F+%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8+%EC%84%A4%EB%AA%85%0A++title%3A+%27%24%7B2%3A%EC%8B%9C%EC%8A%A4%ED%85%9C%7D%2F%24%7B3%3A%EA%B7%B8%EB%A3%B9%7D%2F%241%27%2C%0A++%2F%2F+%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8+%EC%84%A4%EC%A0%95%0A++component%3A+%241%2C%0A++%2F%2F+%EC%A0%84%EB%8B%AC+%EC%9D%B8%EC%9E%90+%EC%84%A4%EC%A0%95%0A++%2F%2F+args%3A+%7B%0A++%2F%2F+++%EC%84%A4%EC%A0%95+%EC%98%88%EC%8B%9C+%0A++%2F%2F+++type%3A+%27email%27%2C%0A++%2F%2F+%7D%2C%0A++%2F%2F+%EC%A0%84%EB%8B%AC+%EC%9D%B8%EC%9E%90+%EC%9C%A0%ED%98%95+%EC%84%A4%EC%A0%95%0A++%2F%2F+argTypes%3A+%7B%0A++++%2F%2F+%EC%BB%AC%EB%9F%AC+%ED%94%BC%EC%BB%A4+%EC%BB%A8%ED%8A%B8%EB%A1%A4+%EC%84%A4%EC%A0%95+%EC%98%88%EC%8B%9C%0A++++%2F%2F+backgroundColor%3A+%7B+control%3A+%27color%27+%7D%2C%0A++%2F%2F+%7D%0A%7D%0A%0Aexport+default+storyConfig%0A%0A%2F%2F+%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8+%ED%85%9C%ED%94%8C%EB%A6%BF%0Aconst+Template+%3D+%28args%29+%3D%3E+%3C%241+%7B...args%7D+%2F%3E%0A%0A%2F%2F+%EC%98%88%EC%A0%9C+%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8+%EC%83%9D%EC%84%B1%0Aexport+const+Example+%3D+Template.bind%28%7B%7D%29%0A%2F%2F+%EC%98%88%EC%A0%9C+%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8+props+%EC%84%A4%EC%A0%95%0AExample.args+%3D+%7B%0A++%2F%2F+prop+%EC%86%8D%EC%84%B1+%EC%84%A4%EC%A0%95%0A%7D\&mode=vscode)을 사용하면 손쉽게 컴포넌트 Story를 구성할 수 있습니다.

```jsx
import $1 from './$1'

const storyConfig = {
  // 컴포넌트 설명
  title: '${2:시스템}/${3:그룹}/$1',
  // 컴포넌트 설정
  component: $1,
  // 전달 인자 설정
  // args: {
  //   설정 예시 
  //   type: 'email',
  // },
  // 전달 인자 유형 설정
  // argTypes: {
    // 컬러 피커 컨트롤 설정 예시
    // backgroundColor: { control: 'color' },
  // }
}

export default storyConfig

// 컴포넌트 템플릿
const Template = (args) => <$1 {...args} />

// 예제 컴포넌트 생성
export const Example = Template.bind({})
// 예제 컴포넌트 props 설정
Example.args = {
  // prop 속성 설정
}
```

## 매개변수 활용 <a href="#using-parameters" id="using-parameters"></a>

매개변수는 Story에 대한 정적 메타 데이터를 정의하는 Storybook의 방법입니다. Story의 매개변수를 사용하여 Story 또는 Story 그룹 레벨에서 다양한 애드온에 구성을 제공 할 수 있습니다. 예를 들어 앱의 다른 컴포넌트와 다른 배경에서 컴포넌트를 테스트 하고 싶다면? 다음과 같이 CSF를 설정합니다. (컴포넌트 레의 매개변수 설정)

```jsx
export default {
  title: 'Button',
  component: Button,
  //👇 Story 매개 변수 설정
  parameters: {
    backgrounds: {
      values: [
        { name: 'darkred', value: '#340000' },
        { name: 'storypink', value: '#fb6597' }
      ],
    },
  },
};
```

![](/files/-MVXl5wKYHYX-u3BXmhu)

## 데코레이터 활용 <a href="#using-decorators" id="using-decorators"></a>

데코레이터는 Story를 렌더링 할 때 임의의 마크업으로 컴포넌트를 감싸는 메커니즘입니다. 예를 들어 테마 또는 레이아웃 래퍼(wrapper)가 필요할 수 있습니다. 또는 UI에 특정 컨텍스트 혹은 데이터 공급자(provider)가 필요할 수 있습니다.

간단한 예는 컴포넌트의 Story에 스타일 래퍼를 추가하는 것입니다. 다음과 같이 Story를 감싸는 데코레이터를 작성하면 Storybook 뷰포트에 스타일이 반영된 래퍼 요소가 렌더링 됩니다.

```jsx
export default {
  title: 'Button',
  component: Button,
  decorators: [
    (Story) => (
      <div style={{ margin: '40px' }}>
        <Story />
      </div>
    ),
  ],
};
```

{% hint style="info" %}
보다 복잡한 데코레이터 구성 예(ThemeProvider 등)를 [참고](https://storybook.js.org/docs/react/writing-stories/decorators#context-for-mocking)하세요.
{% endhint %}

## 멀티 컴포넌트 Story <a href="#multi-components-story" id="multi-components-story"></a>

디자인 시스템 또는 컴포넌트 라이브러리를 빌드 할 때 함께 작동하도록 디자인 된 2개 이상 컴포너트의 Story를 작성하려면 상위 컴포넌트와 하위 컴포넌트 모두 불러와 사용하고, 컴포넌트 작동 상황 별 Story를 렌더링 하는 것이 좋습니다.

{% tabs %}
{% tab title="List.stories.js" %}

```jsx
import List from './List';
import ListItem from './ListItem';

export default {
  component: List,
  title: 'List',
};

// 하위 컴포넌트를 포함하지 않은 상위 컴포넌트 Story
export const Empty = (args) => <List {...args} />;

// 1개의 하위 컴포넌트를 포함한 상위 컴포넌트 Story
export const OneItem = (args) => (
  <List {...args}>
    <ListItem />
  </List>
);

// 1개 이상 하위 컴포넌트를 포함한 상위 컴포넌트 Story
export const ManyItems = (args) => (
  <List {...args}>
    <ListItem />
    <ListItem />
    <ListItem />
  </List>
);
```

{% endtab %}
{% endtabs %}

{% hint style="info" %}
컴포넌트 구성이 보다 복잡하다면 [멀티 컴포넌트 Story 워크플로우](https://storybook.js.org/docs/react/workflows/stories-for-multiple-components)를 참고하세요.
{% endhint %}
