Storybook을 활용하여 본격적으로 디자인 시스템 구축하기

Minjun Kim·2019년 11월 24일
142
post-thumbnail

스토리북을 쓰는 방법을 어느정도 배웠으니, 이제 Hello 컴포넌트 말고 정말 디자인 시스템에 있어서 유의미한 컴포넌트들을 만들어봅시다.

그런데, 어떤 컴포넌트를 만들어야 할까요?

사실 가장 이상적인것은 프로젝트를 만드는 과정에서 처음부터 디자인 시스템을 구축하고 재사용이 자주 될 것 같은 컴포넌트를 만들게 될 때마다 디자인 시스템에 컴포넌트를 하나씩 추가해가면서 문서화하는 방향입니다.

하지만, 현실세계에선 체계가 처음부터 잘 잡혀있는 조직에서, 어떠한 프로젝트를 처음부터 만들게 되지 않는 이상 이렇게 작업하기가 쉽지 않습니다.

대부분의 경우엔 이미 존재하는 프로젝트가 있고, 나중에 가서 디자인 시스템의 필요성을 느껴 이를 도입하는 흐름이 꽤나 일반적입니다.

디자인 시스템을 만들어서 기존 프로젝트에 적용하고, 이를 관찰 후 디자인 시스템을 개선하고, 또 반영하고. 이런 흐름으로 진행되는 것이죠.

이러한 상황에서 먼저 해야 하는 것은, UI 인벤토리입니다 (UI Auditing 이라고도 불립니다)

프로젝트에서 사용되는 UI 들의 스크린샷을 찍어서 종류별로 나열하는것이죠.

이렇게 UI 인벤토리를 작업을 하면, 어떤 UI 들이 일관성 없게 사용되고 있는지 쉽게 조사 할 수 있습니다. 그리고, 재사용성을 높인 컴포넌트를 만들게 될 때 어떤 옵션들을 줘야 할 지 결정을 해야 할 때도 큰 도움이 됩니다.

UI 인벤토리를 할때는 대충 Keynote, Powerpoint, Photoshop, Figma, Sketchapp 등 아무거나 사용하셔도 됩니다.

그 다음엔 우선순위를 정하세요. 우선순위를 정할 땐 정해진 방식은 없지만 제가 개인적으로 권장하는 방식은 다음 요소들을 고려해보는 것 입니다:

  • 많이 사용 되는가?
  • 만들기 쉬운가?

4-1. Button 만들기

일반적으로, Button 부터 시작하면 좋습니다. Button이 없는 서비스는 사실상 찾기 힘들죠.

그런데, Button. 쉬울 것 같지만 사실 생각보다 어렵습니다. 고려할 게 생각보다 많거든요.

다른 성공적인 디자인 시스템을 보고 배워봅시다.

1. 버튼의 상태

마우스 커서, 포커스, 비활성화에 따라 다르게 보여지는 버튼의 상태. 참 중요한 요소죠.

2. 버튼의 theme (또는 variations)

3. 버튼의 사이즈

4. 아이콘과 함께 사용될 때

고려해야 할 것들 정말 많죠?

디자인 시스템에서 사용 할 컴포넌트를 만들 때는 다음과 같이 어떤 버튼들을 만들 지 Sketchapp 또는 Figma로 계획을 하고 진행을 하는 것이 좋습니다.

특정 조직 내에서 디자인 시스템을 만든다면 위와 같은 작업을 디자이너가 해주시겠지요.

하지만 이 강의에서 디자인 도구를 사용하는 것 까지 모두 다룰 수는 없으니, 우리는 바로 코드를 작성하여 컴포넌트를 만들어주도록 하겠습니다.

자, 본격적으로 Button 컴포넌트를 개발해봅시다. 이젠 우리가 기존에 만들었던 Hello 컴포넌트와 Bye 컴포넌트는 더 이상 필요하지 않으므로 해당 디렉터리를 제거하셔도 됩니다.

src 디렉터리에 Button 디렉터리를 만들고, 그 안에 Button.tsx 파일을 다음과 같이 작성해보세요.

src/Button/Button.tsx

import React from 'react';

export type ButtonProps = {
  children: React.ReactNode;
};

function Button({ children }: ButtonProps) {
  return <button>{children}</button>;
}

export default Button;

TypeScript 에 익숙하지 않은 분들을 위해 설명을 드리자면, React.ReactNodechildren 을 위한 타입을 지정 할 때 사용하는 타입입니다.

type ReactNode =
  | ReactChild
  | ReactFragment
  | ReactPortal
  | boolean
  | null
  | undefined;

이 타입은 children 으로 들어올 수 있는 모든 값을 허용해줍니다.

emotion을 사용하여 컴포넌트 스타일링하기

컴포넌트를 스타일링하기 위하여 일반 css 파일을 작성하셔도 되긴 하지만, 저는 그 대신에 emotion 이라는 CSS-in-JS 라이브러리를 사용하는 것을 권장합니다. 그 이유는, 나중에 라이브러리를 배포하게 될 때 css-loader 쪽에 대해서 신경 쓸 필요 없고, 라이브러리를 사용하게 될 때에도 css 를 불러오는 것에 대해서 신경쓰지 않아도 되기 때문입니다.

대체제로 styled-components 가 사용 될 수 있지만, 라이브러리 용도로는 emotion 을 사용하는 것을 권장드립니다. 3가지 이유가 있는데요.

1. 파일 사이즈가 더 작다

2. 인기도 또한 지난 8월에 추월

물론, 다운로드 수 = 인기도 라고 볼 수는 없습니다. emotion이 추월한 것은 인기도라기 보다는, UI 라이브러리에서 많이 사용 되다 보니 이로 인한 영향이 있을것이라는 것을 감안해야 합니다. 실제로는, 국내 인지도는 매우 낮습니다.

3. 서버사이드 렌더링 시 styled-components 는 서버쪽에서 해줘야 할 작업이있음. emotion 은 신경 안써도 됨.

styled-compnents는 서버사이드 렌더링을 할 경우에 ServerStyleSheet 라는 것을 사용하여 별도의 작업을 진행해주어야 합니다. 반면 emotion은 별도의 작업 없이 바로 서버사이드 렌더링이 문제없이 작동합니다.


물론, styled-components 도 굉장히 훌륭한 라이브러리이기 때문에 그게 좋으면 쓰셔도 전혀 상관없습니다! 다만 emotion을 써보신적이 없으시다면 오늘은 이번 기회에 한번 emotion 사용을 해보면 어떨까요?

emotion을 설치해봅시다!

yarn add --peer @emotion/core
# 또는 npm install --save @emotion/core

우리는 나중에 이 프로젝트를 라이브러리로 만들어서 npm 등록할 것이며, 나중에 이 라이브러리를 설치할 때 @emotion/core를 자동으로 설치하지 않고 사용하는 프로젝트에서 별개로 원하는 버전을 설치하여 사용할 수 있게 해 줄 것입니다. 그렇게 하기 위해서는 peerDependency로 패키지를 설치해주어야 합니다.

yarn을 사용하지 않고 npm을 사용하고 계신다면 peerDependency로 바로 설치 할 수는 없습니다(CLI 해당 옵션이 존재하지 않습니다). 따라서, npm을 사용하시는 분들은 나중에 package.json에서 dependenciespeerDependencies로 바꿔주는 작업을 해야합니다. 이는 npm패키지를 등록하는 과정에서 다뤄볼 예정이니 지금은 그냥 --save 옵션으로 설치해주세요.

이제, 우리가 만든 버튼을 스타일링 해봅시다. 나중에 시간 날 때 공식 문서를 읽어보시는 것을 권장드립니다. 지금은 그냥 따라해보시면서 저런식으로 사용하는구나! 하고 이해하시면 충분합니다.

emotion을 설치하셨으면, 버튼을 스타일링해봅시다. onClick 함수도 파라미터로 받아와주도록 하겠습니다.

src/Button/Button.tsx

/** @jsx jsx */
import { jsx, css } from '@emotion/core';

type ButtonProps = {
  /** 버튼 안의 내용 */
  children: React.ReactNode;
  /** 클릭했을 때 호출할 함수 */
  onClick?: (e?: React.MouseEvent<HTMLButtonElement>) => void;
};

/** `Button` 컴포넌트는 어떠한 작업을 트리거 할 때 사용합니다.  */
const Button = ({ children, onClick }: ButtonProps) => {
  return (
    <button css={style} onClick={onClick}>
      {children}
    </button>
  );
};

const style = css`
  outline: none;
  border: none;
  box-sizing: border-box;
  height: 2rem;
  font-size: 0.875rem;
  padding: 0.5rem 1rem;
  background: #20c997;
  color: white;
  border-radius: 0.25rem;
  line-height: 1;
  font-weight: 600;
  &:focus {
    box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.2);
  }
  &:hover {
    background: #38d9a9;
  }
  &:active {
    background: #12b886;
  }
`;

export default Button;

emotion 에서는 css props 를 사용하기 위해서 상단에 /** @jsx jsx */ 라는 JSX Pragma 를 설정하고, jsx 를 emotion 에서 불러와야 합니다.

이 Pragma 는 babel 이 JSX 를 변환 할 때 React.createElement 대신에 emotion의 jsx 함수를 사용하게 해줍니다. 이렇게 JSX Pragma를 사용하게 되면, 컴포넌트 코드에서 상단에 import React from 'react';를 생략해도 됩니다.

버튼에서 사용된 색상은 open-color 에서 가져왔습니다. 나중에 디자인 시스템에서 사용 할 색상에 대한 문서도 mdx 형태로 작성하시면 좋습니다. 예시1 예시2 예시3

이제 버튼을 위한 스토리를 작성해봅시다!

src/Button/Button.stories.tsx

import React from 'react';
import Button from './Button';

export default {
  title: 'components|Button',
  component: Button
};

export const button = () => {
  return <Button>BUTTON</Button>;
};

button.story = {
  name: 'Default'
};

export const primaryButton = () => {
  return <Button>PRIMARY</Button>;
};

다음과 같은 결과가 나타났나요?

버튼의 theme 만들기

우리는 버튼에 총 세가지 theme 을 만들어보도록 하겠습니다.

  1. primary
  2. secondary
  3. tertiary (3번째, 라는 의미를 가지고 있습니다)

우리는 Button 컴포넌트에 theme 이라는 props 를 받아오도록 설정을 해줄건데요, 여기서 theme 의 타입은 다음과 같이 설정하세요.

/** 버튼의 생김새를 설정합니다. */
theme: 'primary' | 'secondary' | 'tertiary';

각기 다른 theme 을 위하여 스타일을 작성 할 때에는 다음과 같이 스타일을 작성하고

const themes = {
  primary: css``,
  secondary: css``,
  tertiary: css``
};

컴포넌트쪽에서 사용 할 땐 다음과 같이 props 에서 받아온 theme 값을 사용하여 필요한 스타일을 뽑아쓰면 됩니다.

css={[style, themes[theme]]}

css props 를 사용하면, 위 코드와 같이 배열 안에 여러가지 스타일을 넣으면 나중에 하나의 className 으로 조합해주어 스타일을 적용해줍니다.

Button 컴포넌트의 theme 기능을 다음과 같이 구현해보세요.

src/Button/Button.tsx

/** @jsx jsx */
import { jsx, css } from '@emotion/core';

type ButtonProps = {
  /** 버튼 안의 내용 */
  children: React.ReactNode;
  /** 클릭했을 때 호출할 함수 */
  onClick?: (e?: React.MouseEvent<HTMLButtonElement>) => void;
  /** 버튼의 생김새를 설정합니다. */
  theme: 'primary' | 'secondary' | 'tertiary';
};

/** `Button` 컴포넌트는 어떠한 작업을 트리거 할 때 사용합니다.  */
const Button = ({ children, theme, onClick }: ButtonProps) => {
  return (
    <button css={[style, themes[theme]]} onClick={onClick}>
      {children}
    </button>
  );
};

Button.defaultProps = {
  theme: 'primary'
};

const style = css`
  outline: none;
  border: none;
  box-sizing: border-box;
  height: 2rem;
  font-size: 0.875rem;
  padding: 0 1rem;
  border-radius: 0.25rem;
  line-height: 1;
  font-weight: 600;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  &:focus {
    box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.2);
  }
`;

const themes = {
  primary: css`
    background: #20c997;
    color: white;
    &:hover {
      background: #38d9a9;
    }
    &:active {
      background: #12b886;
    }
  `,
  secondary: css`
    background: #e9ecef;
    color: #343a40;
    &:hover {
      background: #f1f3f5;
    }
    &:active {
      background: #dee2e6;
    }
  `,
  tertiary: css`
    background: none;
    color: #20c997;
    &:hover {
      background: #e6fcf5;
    }
    &:active {
      background: #c3fae8;
    }
  `
};

export default Button;

다 작성하셨으면, 스토리들도 새로 정의를 해주세요.

src/Button/Button.stories.tsx

import React from 'react';
import Button from './Button';

export default {
  title: 'components|Button',
  component: Button
};

export const button = () => {
  return <Button>BUTTON</Button>;
};

button.story = {
  name: 'Default'
};

export const primaryButton = () => {
  return <Button>PRIMARY</Button>;
};

export const secondaryButton = () => {
  return <Button theme="secondary">SECONDARY</Button>;
};

export const tertiaryButton = () => {
  return <Button theme="tertiary">TERTIARY</Button>;
};

스토리를 다 만들고나면 다음과 같이 여러 theme을 가진 버튼들이 보여질 것입니다.

버튼을 여러가지 크기로 보여주기

이번엔 작은 버튼과 큰 버튼들을 만들 수 있게 해주는 size props 를 구현해보겠습니다.

방식은 우리가 기존에 theme props 를 구현한 방법과 매우 비슷합니다. 우리가 구현 할 사이즈는 small, medium, big 이며 기본 값을 medium으로 해주도록 하겠습니다.

다음과 같이 다양한 크기로 버튼을 보여주는 기능을 구현해보세요.

src/Button/Button.tsx

/** @jsx jsx */
import { jsx, css } from '@emotion/core';

type ButtonProps = {
  /** 버튼 안의 내용 */
  children: React.ReactNode;
  /** 클릭했을 때 호출할 함수 */
  onClick?: (e?: React.MouseEvent<HTMLButtonElement>) => void;
  /** 버튼의 생김새를 설정합니다. */
  theme: 'primary' | 'secondary' | 'tertiary';
  /** 버튼의 크기를 설정합니다 */
  size: 'small' | 'medium' | 'big';
};

/** `Button` 컴포넌트는 어떠한 작업을 트리거 할 때 사용합니다.  */
const Button = ({ children, theme, size, onClick }: ButtonProps) => {
  return (
    <button css={[style, themes[theme], sizes[size]]} onClick={onClick}>
      {children}
    </button>
  );
};

Button.defaultProps = {
  theme: 'primary',
  size: 'medium'
};

const style = css`
  outline: none;
  border: none;
  box-sizing: border-box;
  height: 2rem;
  font-size: 0.875rem;
  padding: 0 1rem;
  border-radius: 0.25rem;
  line-height: 1;
  font-weight: 600;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  &:focus {
    box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.2);
  }
`;

const themes = {
  primary: css`
    background: #20c997;
    color: white;
    &:hover {
      background: #38d9a9;
    }
    &:active {
      background: #12b886;
    }
  `,
  secondary: css`
    background: #e9ecef;
    color: #343a40;
    &:hover {
      background: #f1f3f5;
    }
    &:active {
      background: #dee2e6;
    }
  `,
  tertiary: css`
    background: none;
    color: #20c997;
    &:hover {
      background: #e6fcf5;
    }
    &:active {
      background: #c3fae8;
    }
  `
};

const sizes = {
  small: css`
    height: 1.75rem;
    font-size: 0.75rem;
    padding: 0 0.875rem;
  `,
  medium: css`
    height: 2.5rem;
    font-size: 1rem;
    padding: 0 1rem;
  `,
  big: css`
    height: 3rem;
    font-size: 1.125rem;
    padding: 0 1.5rem;
  `
};

export default Button;

이제 각 버튼 사이즈를 보여주는 스토리를 만들건데요, 그냥 크기별로 모두 무작정 나열만 하면 버튼이 붙어있는 상태로 나오기 때문에 보기 좋지 않으니까, 스토리에서도 emotion 으로 간단하게 스타일링을 해보세요.

src/Button/Button.stories.tsx

/** @jsx jsx */
import Button from './Button';
import { jsx, css } from '@emotion/core';

export default {
  title: 'components|Button',
  component: Button
};

export const button = () => {
  return <Button>BUTTON</Button>;
};

button.story = {
  name: 'Default'
};

export const primaryButton = () => {
  return <Button>PRIMARY</Button>;
};

export const secondaryButton = () => {
  return <Button theme="secondary">SECONDARY</Button>;
};

export const tertiaryButton = () => {
  return <Button theme="tertiary">TERTIARY</Button>;
};

const buttonWrapper = css`
  .description {
    margin-bottom: 0.5rem;
  }
  & > div + div {
    margin-top: 2rem;
  }
`;

export const sizes = () => {
  return (
    <div css={buttonWrapper}>
      <div>
        <div className="description">Small</div>
        <Button size="small">BUTTON</Button>
      </div>
      <div>
        <div className="description">Medium</div>
        <Button size="medium">BUTTON</Button>
      </div>
      <div>
        <div className="description">Big</div>
        <Button size="big">BUTTON</Button>
      </div>
    </div>
  );
};

이런 스토리가 잘 만들어졌나요?

비활성화된 버튼 고려하기

이번에는 버튼이 비활성화됐을 때 다른 스타일을 주도록 컴포넌트를 수정해보겠습니다. 버튼을 비활성화하기 위해서 사용 할 disabled 라는 props 를 구현해보세요.

비활성화된 버튼을 스타일링 할 때에는 :disabled CSS Selector 를 사용하면 됩니다.

그리고 비활성화 됐을 땐 마우스를 올렸을 때 색상이 변하지 않도록 기존 :hover:hover:enabled 로 변경하셔야합니다.

src/Button/Button.tsx

/** @jsx jsx */
import { jsx, css } from '@emotion/core';

type ButtonProps = {
  /** 버튼 안의 내용 */
  children: React.ReactNode;
  /** 클릭했을 때 호출할 함수 */
  onClick?: (e?: React.MouseEvent<HTMLButtonElement>) => void;
  /** 버튼의 생김새를 설정합니다. */
  theme: 'primary' | 'secondary' | 'tertiary';
  /** 버튼의 크기를 설정합니다 */
  size: 'small' | 'medium' | 'big';
  /** 버튼을 비활성화 시킵니다. */
  disabled?: boolean;
};

/** `Button` 컴포넌트는 어떠한 작업을 트리거 할 때 사용합니다.  */
const Button = ({ children, theme, size, disabled, onClick }: ButtonProps) => {
  return (
    <button
      css={[style, themes[theme], sizes[size]]}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  );
};

Button.defaultProps = {
  theme: 'primary',
  size: 'medium'
};

const style = css`
  ...
  &:focus {
    box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.2);
  }
  &:disabled {
    cursor: not-allowed;
  }
`;

const themes = {
  primary: css`
    background: #20c997;
    color: white;
    &:hover:enabled {
      background: #38d9a9;
    }
    &:active:enabled {
      background: #12b886;
    }
    &:disabled {
      background: #aed9cc;
    }
  `,
  secondary: css`
    background: #e9ecef;
    color: #343a40;
    &:hover:enabled {
      background: #f1f3f5;
    }
    &:active:enabled {
      background: #dee2e6;
    }
    &:disabled {
      color: #c6d3e1;
    }
  `,
  tertiary: css`
    background: none;
    color: #20c997;
    &:hover:enabled {
      background: #e6fcf5;
    }
    &:active:enabled {
      background: #c3fae8;
    }
    &:disabled {
      color: #bcd9d0;
    }
  `
};

const sizes = {
  ...
};

export default Button;

... 은 생략된 코드를 의미합니다.

disabled props를 구현하셨으면 버튼의 새로운 스토리도 구현을 해봅시다.

src/Button/Button.stories.tsx

export const disabled = () => {
  return (
    <div css={buttonWrapper}>
      <div>
        <Button disabled>PRIMARY</Button>
      </div>
      <div>
        <Button disabled theme="secondary">
          SECONDARY
        </Button>
      </div>
      <div>
        <Button disabled theme="tertiary">
          TERTIARY
        </Button>
      </div>
    </div>
  );
};

비활성화된 버튼의 스토리도 한번 잘 만들어졌나 볼까요?

width 구현하기

마지막으로, width props를 구현해보겠습니다. 이 값을 통하여 버튼의 너비를 고정시킬 수 있습니다. 예를 들어서 텍스트 길이가 다른 두 버튼을 같은 너비로 보여주고 싶다던지, 또는 버튼이 전체영역을 차지하고 싶게 한다던지 할 때에는 버튼의 너비를 직접 정할 수 있어야 합니다.

src/Button/Button.tsx

/** @jsx jsx */
import { jsx, css } from '@emotion/core';

type ButtonProps = {
  /** 버튼 안의 내용 */
  children: React.ReactNode;
  /** 클릭했을 때 호출할 함수 */
  onClick?: (e?: React.MouseEvent<HTMLButtonElement>) => void;
  /** 버튼의 생김새를 설정합니다. */
  theme: 'primary' | 'secondary' | 'tertiary';
  /** 버튼의 크기를 설정합니다 */
  size: 'small' | 'medium' | 'big';
  /** 버튼을 비활성화 시킵니다. */
  disabled?: boolean;
  /** 버튼의 너비를 임의로 설정합니다. */
  width?: string | number;
};

/** `Button` 컴포넌트는 어떠한 작업을 트리거 할 때 사용합니다.  */
const Button = ({
  children,
  theme,
  size,
  disabled,
  width,
  onClick
}: ButtonProps) => {
  return (
    <button
      css={[style, themes[theme], sizes[size], { width }]}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  );
};

emotion을 사용하면 { width }와 같이 객체 형태의 스타일도 css props 에 넣을 수 있습니다.

width props도 구현이 끝났으면 커스텀 너비를 지정 한 스토리를 새로 만들어주세요.

src/Button/Button.stories.tsx

export const customSized = () => {
  return (
    <div css={buttonWrapper}>
      <div>
        <Button width="20rem">CUSTOM WIDTH</Button>
      </div>
      <div>
        <Button width="100%">FULL WIDTH</Button>
      </div>
    </div>
  );
};

이제 Button 컴포넌트는 여기서 마무리하겠습니다!

디자인 시스템을 개발하기 시작 할 때, 너무 나중 일을 생각하면서 확장성을 과도하게 고려하는건 별로 일 수 있습니다. 시간이 너무 많이 들어가기 때문이거든요. 사람의 의지에 따라 다르긴 하겠지만 시작부터 너무 어렵고 시간이 많이 소비되면 지쳐버려서 디자인 시스템을 만든다는 의지 자체가 흐지부지하게 꺾여버릴 수 있습니다.

사용하지도 않을 스타일 때문에 시간이 허비되는건 좋지 않죠. 실제로 사용되는 스타일들을 위주로 컴포넌트를 최대한 간단하게 만들고 나중에 계속해서 개선해 나가는 방식이 더 좋습니다.

마무리 작업으로 Knobs 와 Actions 애드온을 사용해보세요.

src/Button/Button.stories.tsx

/** @jsx jsx */
import Button from './Button';
import { jsx, css } from '@emotion/core';
import { withKnobs, text, boolean, select } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';

export default {
  title: 'components|Button',
  component: Button,
  decorators: [withKnobs]
};

export const button = () => {
  const label = text('children', 'BUTTON');
  const size = select('size', ['small', 'medium', 'big'], 'medium');
  const theme = select(
    'theme',
    ['primary', 'secondary', 'tertiary'],
    'primary'
  );
  const disabled = boolean('disabled', false);
  const width = text('width', '');

  return (
    <Button
      size={size}
      theme={theme}
      disabled={disabled}
      width={width}
      onClick={action('onClick')}
    >
      {label}
    </Button>
  );
};

button.story = {
  name: 'Default'
};

Knobs 적용이 끝났으면 다음과 같이 Storybook 화면에서 버튼의 바로 속성을 커스터마이징하고 잘 리렌더링되는지 확인해보세요.

추가적으로, 버튼을 클릭했을 때 onClick 액션이 발생하는지도 확인해보세요.

수고하셨습니다! 버튼 컴포넌트의 개발이 끝났습니다. 어떤가요? 간단할 것 같은 버튼도 결코 쉽지 않죠? 버튼 컴포넌트의 경우 모양새가 다양하기 때문에 난이도가 꽤나 높습니다. 하지만, 이렇게 한번 해보고 나면, 나중에 다른 컴포넌트들에 배리에이션(variation)을 주는 작업을 수월하게 할 수 있게 될 것입니다.

4-2. ButtonGroup 만들기

이번에는 ButtonGroup 라는 컴포넌트를 만들어보겠습니다.

이 컴포넌트는 여러가지 Button 들을 함께 사용 할 때 레이아웃하는 작업을 도와줄 컴포넌트입니다.

이 강의에서는 강의 문서 작성 편의상 ButtonGroup 컴포넌트의 기능을 한꺼번에 구현하고, 그 다음에 스토리 파일을 작성합니다. 하지만 사실 실무에서는 컴포넌트를 보면서 작업하지 않고 한꺼번에 UI 관련 코드를 한번에 왕창 작성한 다음에 모든 기능이 성공적으로 이루어지는 일은 드뭅니다.

실제로 작업을 할 때에는 다음과 같은 프로세스로 개발을 하면 편합니다.

  1. 컴포넌트 파일 생성
  2. props 정의
  3. 스토리 파일 생성
  4. 구현하고자 하는 기능을 스토리로 작성
  5. 컴포넌트에서 기능 구현
  6. Storybook에서 잘 작동하는지 확인

그리고, 모든 기능이 구현 될 때 까지 4~6번을 반복하시면 됩니다. 그리고 Knobs를 적용하는 것은 모든 스토리 작성이 끝나고나서 하셔도 됩니다.

src 안에 ButtonGroup 디렉터리를 만드시고, ButtonGroup.tsx 를 생성하여 다음과 같이 코드를 작성해보세요.

src/ButtonGroup/ButtonGroup.tsx

/** @jsx jsx */
import { css, jsx } from '@emotion/core';

export type ButtonGroupProps = {
  /** 버튼을 보여줄 방향 */
  direction: 'row' | 'column';
  /** 버튼을 우측에 보여줍니다. */
  rightAlign?: boolean;
  /** 버튼과 버튼사이의 간격을 설정합니다. */
  gap: number | string;
  /** 버튼 그룹에서 보여줄 버튼들 */
  children: React.ReactNode;
  /* 스타일 커스터마이징 하고싶을 때 사용 */
  className?: string;
};

/**
 * 여러개의 `Button` 컴포넌트를 보여주고 싶거나, 버튼을 우측에 정렬하고 싶을 땐 `ButtonGroup` 컴포넌트를 사용하세요.
 */
const ButtonGroup = ({
  direction,
  rightAlign,
  children,
  gap,
  className
}: ButtonGroupProps) => {
  return (
    <div
      css={[
        {
          display: 'flex',
          flexDirection: direction
        },
        gapStyle(direction, gap),
        rightAlign && rightAlignStyle
      ]}
      className={className}
    >
      {children}
    </div>
  );
};

ButtonGroup.defaultProps = {
  direction: 'row',
  gap: '0.5rem'
};

// direction 에 따라 margin-left 또는 margin-top 설정
const gapStyle = (direction: 'row' | 'column', gap: number | string) => {
  const marginType = direction === 'row' ? 'marginLeft' : 'marginTop';
  return css({
    'button + button': {
      [marginType]: gap
    }
  });
};

const rightAlignStyle = css`
  justify-content: flex-end;
`;

export default ButtonGroup;

그 다음에는, 이 컴포넌트를 위한 스토리 파일도 작성해봅시다.

src/ButtonGroup/ButtonGroup.stories.tsx

import React from 'react';
import ButtonGroup from './ButtonGroup';
import Button from '../Button/Button';
import { withKnobs, text, radios, boolean } from '@storybook/addon-knobs';

export default {
  title: 'components|ButtonGroup',
  component: ButtonGroup,
  decorators: [withKnobs]
};

export const buttonGroup = () => {
  const direction = radios(
    'direction',
    { Row: 'row', Column: 'column' },
    'row'
  );
  const rightAlign = boolean('rightAlign', false);
  const gap = text('gap', '0.5rem');

  return (
    <ButtonGroup direction={direction} rightAlign={rightAlign} gap={gap}>
      <Button theme="tertiary">취소</Button>
      <Button>확인</Button>
    </ButtonGroup>
  );
};

buttonGroup.story = {
  name: 'Default'
};

export const rightAlign = () => {
  return (
    <ButtonGroup rightAlign>
      <Button theme="tertiary">취소</Button>
      <Button>확인</Button>
    </ButtonGroup>
  );
};

export const column = () => {
  return (
    <ButtonGroup direction="column">
      <Button>CLICK ME</Button>
      <Button>CLICK ME</Button>
    </ButtonGroup>
  );
};

export const customGap = () => {
  return (
    <ButtonGroup gap="1rem">
      <Button theme="tertiary">취소</Button>
      <Button>확인</Button>
    </ButtonGroup>
  );
};

export const customGapColumn = () => {
  return (
    <ButtonGroup direction="column" gap="1rem">
      <Button>CLICK ME</Button>
      <Button>CLICK ME</Button>
    </ButtonGroup>
  );
};

스토리가 잘 만들어졌나요?

4-3. Icon 만들기

이번에는 Icon이라는 컴포넌트를 만들어서 디자인 시스템 내에서 사용되는 아이콘을 관리하는 방법을 다뤄보겠습니다. 이번에 우리가 배울 아이콘 방식은 지금처럼 라이브러리를 만드는게 목적이 아니더라 하더라도, 일반 리액트 프로젝트에서도 이번에 배우는 방식대로 진행을 하시면 매우 유용합니다.

일반적으로, Icon 의 경우엔 디자이너가 만들어서 svg로 전달을 해주거나, iconmonstr 같은 웹서비스에서 검색하여 svg를 다운로드 받아서 사용 할 것입니다.

이 강의가 디자이너를 위한 강의는 아니니, 아이콘을 직접 그리진 않고 다운로드해서 이를 컴포넌트형태로 사용하는 방법을 다뤄보겠습니다.

다음 3가지 아이콘들을 다운로드 받으세요. 파일 이름을 우측에 괄호로 적혀있는 이름으로 저장하세요.

그 다음엔, src/Icon/svg 경로를 만들고, 방금 다운로드 받은 3가지 아이콘들을 그 안에 넣어주세요.

이제 컴포넌트에서 svg를 불러와서 사용할건데요, 우리는 img 태그를 사용하지 않고, svg 를 jsx 형태로 바로 렌더링 할 것입니다.

이렇게 하기 위해선 babel-plugin-named-asset-import 라는 babel 플러그인을 사용해야 합니다.

create-react-app 을 사용해보신 분들이라면 SVG 를 컴포넌트 타입으로 불러올 수 있다는 것을 알고 계실 것입니다. 우리가 적용할 플러그인이 이와 동일한 플러그인입니다.

우선, 이 패키지를 설치하세요.

yarn add --dev babel-plugin-named-asset-import
# 또는 npm --save-dev babel-plugin-named-asset-import

그 다음에는 웹팩 설정의 babel-loader 부분에 방금 설치한 플러그인을 적용해주어야 합니다. .storybook 경로에 잇는 webpack.config.js를 열어서 다음과 같이 수정하세요.

.storybook/webpack.config.js

module.exports = ({ config, mode }) => {
  config.module.rules.push({
    test: /\.(ts|tsx)$/,
    use: [
      {
        loader: require.resolve('babel-loader'),
        options: {
          presets: [['react-app', { flow: false, typescript: true }]],
          plugins: [
            [
              require.resolve('babel-plugin-named-asset-import'),
              {
                loaderMap: {
                  svg: {
                    ReactComponent: '@svgr/webpack?-svgo,+titleProp,+ref![path]'
                  }
                }
              }
            ]
          ]
        }
      },
      require.resolve('react-docgen-typescript-loader')
    ]
  });
  config.resolve.extensions.push('.ts', '.tsx');
  return config;
};

위 코드는 CRA 의 webpack.config.js 에서 그대로 복사해온 코드입니다.

코드를 수정하셨으면, src/typings.d.ts 파일을 열어서 ".svg" 에 대한 타입을 설정하세요. 이 설정을 통하여 TypeScript파일에서 svg를 불러올 때 컴포넌트 타입으로 인식 할 수 있습니다.

src/typing.d.ts

declare module '*.mdx';

declare module '*.svg' {
  import * as React from 'react';

  export const ReactComponent: React.FunctionComponent<React.SVGProps<
    SVGSVGElement
  >>;

  const src: string;
  export default src;
}

svg를 불러와서 사용 할 준비가 끝났습니다. 여기까지 수정을 다 하셨으면 Storybook 서버를 한번 재시작해주세요.

그 다음에는 src/Icon/svg 경로에 index.ts 파일을 생성하시고 다음과 같이 코드를 작성해주세요.

src/Icon/svg/index.ts

export { ReactComponent as exit } from './exit.svg';
export { ReactComponent as heart } from './heart.svg';
export { ReactComponent as pencil } from './pencil.svg';

코드를 위와 같이 작성하면, svg 를 컴포넌트 타입으로 불러와서 우리가 정해준 이름으로 바로 내보내줍니다.

이렇게 아이콘을 내보내주셨으면, src/Icon/Icon.tsx 파일을 만들어서 컴포넌트를 다음과 같이 작성해보세요.

src/Icon/Icon.tsx

/** @jsx jsx */
import { jsx } from '@emotion/core';
import * as icons from './svg';

type IconType = keyof typeof icons;
export const iconTypes: IconType[] = Object.keys(icons) as any[]; // 스토리에서 불러오기 위함

export type IconProps = {
  /** 사용 할 아이콘 타입 */
  icon: IconType;
  /** 아이콘 색상 */
  color?: string;
  /** 아이콘 크기 */
  size?: string | number;
  className?: string;
};

/** 아이콘을 보여주고 싶을 땐 `Icon` 컴포넌트를 사용하세요.
 *
 * 이 컴포넌트는 svg 형태로 아이콘을 보여주며, props 또는 스타일을 사용하여 아이콘의 색상과 크기를 정의 할 수 있습니다.
 *
 * 스타일로 모양새를 설정 할 때에는 `color`로 색상을 설정하고 `width`로 크기를 설정하세요.
 */
const Icon = ({ icon, color, size, className }: IconProps) => {
  const SVGIcon = icons[icon];
  return (
    <SVGIcon
      css={{ fill: color || 'currentColor', width: size, height: 'auto' }}
      className={className}
    />
  );
};

export default Icon;

아이콘들을 불러올 때, import * as icons from './svg'; 라고 작성하여 svg/index.ts 파일에서 내보내고 있는 모든 아이콘들을 icons 라는 하나의 객체로 불러왔습니다.

그리고 IconProps 를 설정하는 부분에서 IconType 이라는 타입을 만들어서 사용했는데요, keyof typeof icons의 의미는 icons 객체가 가지고 있는 key 들을 추출하여 타입으로 사용하겠다는 것 입니다. 그러면 해당 타입의 결과는 "exit" | "heart" | "pencil" 이 됩니다.

이제 이 컴포넌트를 위하여 스토리를 작성해봅시다.

src/Icon/Icon.stories.tsx

/** @jsx jsx */
import { jsx } from '@emotion/core';
import Icon from './Icon';

export default {
  component: Icon,
  title: 'components|Icon'
};

export const icon = () => <Icon icon="heart" />;
icon.story = {
  name: 'Default'
};

export const customSize = () => <Icon icon="heart" size="4rem" />;

export const customColor = () => <Icon icon="heart" color="red" />;

export const customizedWithStyle = () => (
  <Icon icon="heart" css={{ color: 'red', width: '4rem' }} />
);

스토리가 잘 만들어졌나요?

이제 우리가 준비한 3가지 아이콘들을 모두 나열해보겠습니다.

src/Icon/Icon.stories.tsx

/** @jsx jsx */
import { jsx, css } from '@emotion/core';
import Icon, { iconTypes } from './Icon';

export default {
  component: Icon,
  title: 'components|Icon'
};

export const icon = () => <Icon icon="heart" />;
icon.story = {
  name: 'Default'
};

export const customSize = () => <Icon icon="heart" size="4rem" />;

export const customColor = () => <Icon icon="heart" color="red" />;

export const customizedWithStyle = () => (
  <Icon icon="heart" css={{ color: 'red', width: '4rem' }} />
);

export const listOfIcons = () => {
  return (
    <ul css={iconListStyle}>
      {iconTypes.map(icon => (
        <li key={icon}>
          <Icon icon={icon} />
          {icon}
        </li>
      ))}
    </ul>
  );
};

const iconListStyle = css`
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  li {
    box-sizing: border-box;
    width: 25%;
    padding: 1rem;
    display: flex;
    align-items: center;
    svg {
      margin-right: 1rem;
    }
  }
`;

이렇게 코드를 작성해주고 나면, 우리가 추가한 아이콘들이 모두 리스팅 될 것입니다.

만약 아이콘이 더 많아지게 된다면 다음과 같이 보여지게 되겠죠.

Button에서 아이콘 고려하기

이제 Button 컴포넌트에서 아이콘을 사용하는 상황을 고려해봅시다.

첫번째로 할 것은 아이콘과 텍스트가 함께 사용 될 때를 고려하는 것 입니다.

  1. svg 의 크기가 폰트사이즈와 동일하도록 width 를 1em 으로 설정해야합니다.
  2. svg 의 margin-right 를 1em 으로 설정해야 합니다.
  3. themes 부분에서 svg 의 fill 를 설정해야 합니다.

Button 컴포넌트를 다음과 같이 수정해주세요.

src/Button/Button.tsx

/** @jsx jsx */
import { jsx, css } from '@emotion/core';

...

const style = css`
  ...
  svg {
    width: 1em;
    margin-right: 1em;
  }
`;

const themes = {
  primary: css`
    background: #20c997;
    color: white;
    svg {
      fill: white;
    }
    &:hover:enabled {
      background: #38d9a9;
    }
    &:active:enabled {
      background: #12b886;
    }
    &:disabled {
      background: #aed9cc;
    }
  `,
  secondary: css`
    background: #e9ecef;
    color: #343a40;
    svg {
      fill: #343a40;
    }
    &:hover:enabled {
      background: #f1f3f5;
    }
    &:active:enabled {
      background: #dee2e6;
    }
    &:disabled {
      color: #c6d3e1;
      svg {
        fill: #c6d3e1;
      }
    }
  `,
  tertiary: css`
    background: none;
    color: #20c997;
    svg {
      fill: #20c997;
    }
    &:hover:enabled {
      background: #e6fcf5;
    }
    &:active:enabled {
      background: #c3fae8;
    }
    &:disabled {
      color: #bcd9d0;
      svg {
        fill: #bcd9d0;
      }
    }
  `
};

...

코드를 모두 수정하셨으면, Button의 스토리 파일에 withIcon 이라는 스토리를 만들어보세요.

src/Button/Button.stories.tsx

/** @jsx jsx */
import Button from './Button';
import { jsx, css } from '@emotion/core';
import { withKnobs, text, boolean, select } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import ButtonGroup from '../ButtonGroup/ButtonGroup';
import Icon from '../Icon/Icon';

...

export const withIcon = () => {
  return (
    <div>
      <ButtonGroup>
        <Button size="small">
          <Icon icon="heart" /> LIKE
        </Button>
        <Button>
          <Icon icon="heart" /> LIKE
        </Button>
        <Button size="big">
          <Icon icon="heart" /> LIKE
        </Button>
      </ButtonGroup>
    </div>
  );
};

버튼들을 보여주는 과정에서 우리가 이전에 만들었던 ButtonGroup 컴포넌트를 활용했습니다.

두번째로 할 것은, 아이콘만 보여주는 버튼을 만들 때를 고려하는 것 입니다. iconOnly 라는 props 를 설정하여 이 값이 true 가 되면 버튼의 모양을 원으로 만들어주도록 하겠습니다.

src/Button/Button.tsx

/** @jsx jsx */
import { jsx, css } from '@emotion/core';

type ButtonProps = {
  /** 버튼 안의 내용 */
  children: React.ReactNode;
  /** 클릭했을 때 호출할 함수 */
  onClick?: (e?: React.MouseEvent<HTMLButtonElement>) => void;
  /** 버튼의 생김새를 설정합니다. */
  theme: 'primary' | 'secondary' | 'tertiary';
  /** 버튼의 크기를 설정합니다. */
  size: 'small' | 'medium' | 'big';
  /** 버튼을 비활성화 시킵니다. */
  disabled?: boolean;
  /** 버튼의 너비를 임의로 설정합니다. */
  width?: string | number;
  /** 버튼에서 아이콘만 보여줄 때 이 값을 `true`로 설정하세요. */
  iconOnly?: boolean;
};

/** `Button` 컴포넌트는 어떠한 작업을 트리거 할 때 사용합니다.  */
const Button = ({
  children,
  theme,
  size,
  disabled,
  width,
  iconOnly,
  onClick
}: ButtonProps) => {
  return (
    <button
      css={[
        style,
        themes[theme],
        sizes[size],
        { width },
        iconOnly && [iconOnlyStyle, iconOnlySizes[size]]
      ]}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  );
};

...

const iconOnlyStyle = css`
  padding: 0;
  border-radius: 50%;
  svg {
    margin: 0;
  }
`;

const iconOnlySizes = {
  small: css`
    width: 1.75rem;
  `,
  medium: css`
    width: 2.5rem;
  `,
  big: css`
    width: 3rem;
  `
};

export default Button;

수정을 다 하셨으면 아이콘만 보여주는 버튼들을 보여주는 스토리도 만들어보세요.

src/Button/Button.stories.tsx

...

export const iconOnly = () => {
  return (
    <div>
      <ButtonGroup>
        <Button iconOnly size="small">
          <Icon icon="heart" />
        </Button>
        <Button iconOnly>
          <Icon icon="heart" />
        </Button>
        <Button iconOnly size="big">
          <Icon icon="heart" />
        </Button>
      </ButtonGroup>
    </div>
  );
};

스토리가 잘 보여지고 있나요?

이제 Icon 을 위한 작업은 모두 끝났습니다!

4-4. Dialog 만들기

Dialog 컴포넌트는 다음과 같이 어두운 레이어로 기존 화면을 가리고 흰색 박스를 중앙에 띄워서 원하는 정보를 보여주는 컴포넌트입니다. 이 컴포넌트는 우리가 이 강의에서 마지막으로 만들어볼 컴포넌트입니다.

우선, Dialog 의 틀부터 잡아줍시다.

src/Dialog 디렉터리를 만들고, Dialog.tsx 파일을 다음과 같이 작성해보세요.

src/Dialog/Dialog.tsx

/** @jsx jsx */
import { Fragment } from 'react';
import { css, jsx } from '@emotion/core';
import ButtonGroup from '../ButtonGroup/ButtonGroup';
import Button from '../Button/Button';

export type DialogProps = {};

const Dialog = (props: DialogProps) => {
  return (
    <Fragment>
      <div css={[fullscreen, darkLayer]}></div>
      <div css={[fullscreen, whiteBoxWrapper]}>
        <div css={whiteBox}>
          <h3>포스트 삭제</h3>
          <p>포스트를 정말로 삭제하시겠습니까?</p>
          <ButtonGroup css={{ marginTop: '3rem' }} rightAlign>
            <Button theme="tertiary">취소</Button>
            <Button>삭제</Button>
          </ButtonGroup>
        </div>
      </div>
    </Fragment>
  );
};

const fullscreen = css`
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
`;

const darkLayer = css`
  z-index: 10;
  background: rgba(0, 0, 0, 0.5);
`;

const whiteBoxWrapper = css`
  z-index: 15;
  display: flex;
  align-items: center;
  justify-content: center;
`;

const whiteBox = css`
  box-sizing: border-box;
  border-radius: 4px;
  width: 25rem;
  background: white;
  box-shadow: 0px 4px 8px 8px rgba(0, 0, 0, 0.05);
  padding: 2rem;

  h3 {
    font-size: 1.5rem;
    color: #343a40;
    margin-top: 0;
    margin-bottom: 1rem;
  }

  p {
    font-size: 1.125rem;
    margin: 0;
    color: #868e96;
  }
`;

export default Dialog;

이렇게 지금은 props 는 신경쓰지 않고 UI만 준비해주세요. 그 다음에는 이 컴포넌트를 위한 스토리 파일을 작성하세요.

src/Dialog/Dialog.stories.tsx

import React from 'react';
import Dialog from './Dialog';

export default {
  title: 'components|Dialog',
  component: Dialog
};

export const dialog = () => {
  return <Dialog />;
};

dialog.story = {
  name: 'Default'
};

이렇게 하고 나면 문제점이 한가지 있습니다. Docs 를 열었을 때 Docs 화면 자체를 다 가려버린다는 것 입니다.

이를 방지하기 위해선 docs 의 inlineStories 라는 파라미터를 false 로 설정하면 됩니다.

src/Dialog/Dialog.stories.tsx

import React from 'react';
import Dialog from './Dialog';

export default {
  title: 'components|Dialog',
  component: Dialog,
  parameters: {
    docs: {
      inlineStories: false
    }
  }
};

export const dialog = () => {
  return <Dialog />;
};

dialog.story = {
  name: 'Default'
};

수정하시면 Docs 부분에서 스토리를 보여줄 때 iframe 을 사용하게 되면서 컴포넌트가 영역 밖에 렌더링 되는 이슈가 해결 됩니다.

이제, 이 컴포넌트의 props 를 설정해봅시다. 우리는 Dialog 안에 보여지는 모든 UI 를 숨길 수 있도록 Dialog 를 설계 할 것입니다. 그리고 필요할 경우엔 Dialog 내부에서 보여지는 내용을 커스터마이징 할 수 있도록 children 이 존재한다면 이를 렌더링하도록 구현해보세요.,

추가적으로, 버튼의 텍스트도 커스터마이징 할 수 있어야 하고, 필요할 땐 버튼을 하나만 보여주기도 해야 합니다.

src/Dialog/Dialog.tsx

/** @jsx jsx */
import { Fragment } from 'react';
import { css, jsx } from '@emotion/core';
import ButtonGroup from '../ButtonGroup/ButtonGroup';
import Button from '../Button/Button';

export type DialogProps = {
  visible: boolean;
  title?: string;
  description?: string;
  children?: React.ReactNode;
  hideButtons?: boolean;
  cancellable?: boolean;
  cancelText: string;
  confirmText: string;
  onCancel?: () => void;
  onConfirm?: () => void;
};

const Dialog = ({
  visible,
  title,
  description,
  hideButtons,
  cancellable,
  cancelText,
  confirmText,
  children,
  onCancel,
  onConfirm
}: DialogProps) => {
  if (!visible) return null;

  return (
    <Fragment>
      <div css={[fullscreen, darkLayer]}></div>
      <div css={[fullscreen, whiteBoxWrapper]}>
        <div css={whiteBox}>
          {title && <h3>{title}</h3>}
          {description && <p>{description}</p>}
          {children}
          {!hideButtons && (
            <ButtonGroup css={{ marginTop: '3rem' }} rightAlign>
              {cancellable && (
                <Button theme="tertiary" onClick={onCancel}>
                  {cancelText}
                </Button>
              )}
              <Button onClick={onConfirm}>{confirmText}</Button>
            </ButtonGroup>
          )}
        </div>
      </div>
    </Fragment>
  );
};

Dialog.defaultProps = {
  cancelText: '취소',
  confirmText: '확인'
};

이제 이에 맞춰 새로운 스토리들도 작성하고, Knobs 설정도 해주세요.

src/Dialog/Dialog.stories.tsx

    import React from 'react';
    import Dialog from './Dialog';
    import { withKnobs, text, boolean } from '@storybook/addon-knobs';

    export default {
      title: 'components|Dialog',
      component: Dialog,
      parameters: {
        docs: {
          inlineStories: false
        }
      },
      decorators: [withKnobs]
    };

    export const dialog = () => {
      const title = text('title', '결제 성공');
      const description = text('description', '결제가 성공적으로 이루어졌습니다.');
      const visible = boolean('visible', true);
      const confirmText = text('confirmText', '확인');
      const cancelText = text('cancelText', '취소');
      const cancellable = boolean('cancellable', false);

      return (
        <Dialog
          title={title}
          description={description}
          visible={visible}
          confirmText={confirmText}
          cancelText={cancelText}
          cancellable={cancellable}
        />
      );
    };

    dialog.story = {
      name: 'Default'
    };

    export const cancellable = () => {
      return (
        <Dialog
          title="포스트 삭제"
          description="포스트를 정말로 삭제하시겠습니까?"
          visible={true}
          confirmText="삭제"
          cancellable
        />
      );
    };

    export const customContent = () => {
      return (
        <Dialog visible={true} hideButtons>
          Custom Content
        </Dialog>
      );
    };

react-spring으로 트랜지션 구현하기

이번에는 우리가 만든 Dialog 컴포넌트에 트랜지션 애니메이션을 구현해보겠습니다. 트랜지션 애니메이션을 구현하기 위하여, 그냥 css 의 transition 속성을 사용하거나, keyframe 을 사용하거나, 또는 react-transition-group이라는 라이브러리를 쓰셔도 됩니다.

여러분이 나중에 실제로 여러분의 컴포넌트를 만드실땐 무엇을 쓰시던지 상관 없는데, 이 튜토리얼에서는 react-spring 을 사용해보겠습니다. 이 라이브러리를 사용하면 Hooks 를 기반으로 더욱 수준 높은 애니메이션을 쉽게 구현 할 수 있습니다.

react-spring 은 대부분의 상황엔 좋은 성능을 보여주지만, 스마트 TV 및 처럼 하드웨어 성능이 그렇게 좋지 못한 디바이스에서는 버벅임을 유발하기도 하니 이 점 주의하시길 바랍니다.

이 라이브러리를 우선 설치해주세요.

yarn add --peer react-spring
# 또는 npm install --save react-spring

이 라이브러리는 자체적으로 TypeScript 지원을 하주기 때문에 별도로 타입 설치를 하실 필요가 없습니다.

우리는, useTransition을 사용할 것입니다. 단순 애니메이션을 구현 할 때에는 useSpring을 사용하면 되지만, 애니메이션이 끝나고 화면에서 DOM을 아예 없애야 하는 상황엔 useTransition 을 씁니다.

이 라이브러리를 사용하기 위해선 useTransitionanimated 를 Dialog 컴포넌트 에서 import 해주세요.

import { useTransition, animated } from 'react-spring';

그 다음엔 컴포넌트에서 기존의 if (!visible) return null 을 지우시고, 다음과 같이 useTransition hook 을 사용해보세요.

const fadeTransition = useTransition(visible, null, {
  from: { opacity: 0 },
  enter: { opacity: 1 },
  leave: { opacity: 0 }
});

const slideUpTransition = useTransition(visible, null, {
  from: {
    transform: `translateY(200px) scale(0.8)`,
    opacity: 0
  },
  enter: {
    transform: `translateY(0px) scale(1)`,
    opacity: 1
  },
  leave: {
    transform: `translateY(200px) scale(0.8)`,
    opacity: 0
  }
});

여기서 첫번째 파라미터는 배열일수도 있고, boolean일 수도 있습니다. useTransition으로 단순히 가시성을 설정 할 때에는 boolean 을 넣고, 배열의 변화함에 따라 트랜지션을 주며 추가/제거를 하고 싶을 때는 배열을 넣습니다.

두번째 파라미터는 배열을 넣을 경우 다음과 같이 key 를 설정해주는 함수인데, 지금은 배열을 다루는게 아니니까 그냥 null 이라고 입력하시면 됩니다.

세번째 파라미터에서는 트랜지션을 설정하는데요, from은 시작할때, enter 는 들어왔을 때, leave 는 떠날 때 스타일을 설정합니다.

그 다음엔 컴포넌트에서 리턴하는 JSX 부분을 다음과 같이 고쳐보세요.

return (
  <Fragment>
    {fadeTransition.map(({ item, key, props }) =>
      item ? (
        <animated.div
          css={[fullscreen, darkLayer]}
          key={key}
          style={props}
        ></animated.div>
      ) : null
    )}

    {slideUpTransition.map(({ item, key, props }) =>
      item ? (
        <animated.div
          css={[fullscreen, whiteBoxWrapper]}
          style={props}
          key={key}
        >
          <div css={whiteBox}>
            {title && <h3>{title}</h3>}
            {description && <p>{description}</p>}
            {children}
            {!hideButtons && (
              <ButtonGroup css={{ marginTop: '3rem' }} rightAlign>
                {cancellable && (
                  <Button theme="tertiary" onClick={onCancel}>
                    {cancelText}
                  </Button>
                )}
                <Button onClick={onConfirm}>{confirmText}</Button>
              </ButtonGroup>
            )}
          </div>
        </animated.div>
      ) : null
    )}
  </Fragment>
);

이제 트랜지션 구현이 끝났습니다! 저장을 하고 나면 다음과 같이 트랜지션이 보여질것입니다.

react-spring 을 사용하면 configs를 조정하여 애니메이션을 세부적으로 설정 할 수 있습니다. configs 링크를 눌러 mass, tesnsion, friction 을 조정해보고 어떤 변화가 나타나는지 확인해보세요.

한번, slideUpTransitiontension 을 조금 더 올리고, friction 을 낮춰보겠습니다.

const slideUpTransition = useTransition(visible, null, {
  from: {
    transform: `translateY(200px) scale(0.8)`,
    opacity: 0
  },
  enter: {
    transform: `translateY(0px) scale(1)`,
    opacity: 1
  },
  leave: {
    transform: `translateY(200px) scale(0.8)`,
    opacity: 0
  },
  config: {
    tension: 200,
    friction: 15
  }
});

트랜지션에 조금 더 통통 튀는듯한 느낌이 적용됐습니다. 최종 코드는 다음과 같습니다. 혹시 위 코드 조각으로 구현이 제대로 안됐다면 다음 코드를 확인하고 어떤 부분이 잘못됐는지 확인해보세요.

src/Dialog/Dialog.tsx

/** @jsx jsx */
import { Fragment } from 'react';
import { css, jsx } from '@emotion/core';
import ButtonGroup from '../ButtonGroup/ButtonGroup';
import Button from '../Button/Button';
import { useTransition, animated } from 'react-spring';

export type DialogProps = {
  visible: boolean;
  title?: string;
  description?: string;
  children?: React.ReactNode;
  hideButtons?: boolean;
  cancellable?: boolean;
  cancelText: string;
  confirmText: string;
  onCancel?: () => void;
  onConfirm?: () => void;
};

const Dialog = ({
  visible,
  title,
  description,
  hideButtons,
  cancellable,
  cancelText,
  confirmText,
  children,
  onCancel,
  onConfirm
}: DialogProps) => {
  const fadeTransition = useTransition(visible, null, {
    from: { opacity: 0 },
    enter: { opacity: 1 },
    leave: { opacity: 0 }
  });

  const slideUpTransition = useTransition(visible, null, {
    from: {
      transform: `translateY(200px) scale(0.8)`,
      opacity: 0
    },
    enter: {
      transform: `translateY(0px) scale(1)`,
      opacity: 1
    },
    leave: {
      transform: `translateY(200px) scale(0.8)`,
      opacity: 0
    },
    config: {
      tension: 200,
      friction: 15
    }
  });

  return (
    <Fragment>
      {fadeTransition.map(({ item, key, props }) =>
        item ? (
          <animated.div
            css={[fullscreen, darkLayer]}
            key={key}
            style={props}
          ></animated.div>
        ) : null
      )}

      {slideUpTransition.map(({ item, key, props }) =>
        item ? (
          <animated.div
            css={[fullscreen, whiteBoxWrapper]}
            style={props}
            key={key}
          >
            <div css={whiteBox}>
              {title && <h3>{title}</h3>}
              {description && <p>{description}</p>}
              {children}
              {!hideButtons && (
                <ButtonGroup css={{ marginTop: '3rem' }} rightAlign>
                  {cancellable && (
                    <Button theme="tertiary" onClick={onCancel}>
                      {cancelText}
                    </Button>
                  )}
                  <Button onClick={onConfirm}>{confirmText}</Button>
                </ButtonGroup>
              )}
            </div>
          </animated.div>
        ) : null
      )}
    </Fragment>
  );
};

Dialog.defaultProps = {
  cancelText: '취소',
  confirmText: '확인'
};

const fullscreen = css`
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
`;

const darkLayer = css`
  z-index: 10;
  background: rgba(0, 0, 0, 0.5);
`;

const whiteBoxWrapper = css`
  z-index: 15;
  display: flex;
  align-items: center;
  justify-content: center;
`;

const whiteBox = css`
  box-sizing: border-box;
  border-radius: 4px;
  width: 25rem;
  background: white;
  box-shadow: 0px 4px 8px 8px rgba(0, 0, 0, 0.05);
  padding: 2rem;

  h3 {
    font-size: 1.5rem;
    color: #343a40;
    margin-top: 0;
    margin-bottom: 1rem;
  }

  p {
    font-size: 1.125rem;
    margin: 0;
    color: #868e96;
  }
`;

export default Dialog;

이제 우리 디자인 시스템의 컴포넌트 개발이 끝났습니다! 물론 이것으로 디자인 시스템이 완성된다는 것은 아닙니다. 당연히, Typography 또는 Color 에 대한 문서도 MDX로 작성을 해주고, 더 댜앙한 컴포넌트를 만들어주어야 하겠죠. 우리가 이렇게 컴포넌트를 만든 것 처럼 하나씩 하나씩 채워가면 멋진 시스템이 구축 될 것입니다.

가장 중요한건 미루지 않고, 꾸준히 준비해나가는 것 입니다.

이제 우리가 만든 컴포넌트들을 다른 프로젝트에서 설치하여 불러와서 사용 할 수 있도록 npm에 등록하는 방법을 배워보도록 하겠습니다!

profile
CEO @ Chaf Inc. 사용자들이 좋아하는 프로덕트를 만듭니다.

4개의 댓글

comment-user-thumbnail
2019년 12월 4일

감사합니다.
확실히 유용성과 필요성을 알 수 있게 됬네요. 👍

답글 달기
comment-user-thumbnail
2019년 12월 6일

감사인사 드릴려고 눈팅만 하다가 처음으로 로그인해서 댓글쓰네요.

초년생 개발자인데 리액트로 실무에서 개발하다가
애니메이션 관련해서 많이 고민하고 있었는데
벨로퍼트님께서 알려주신 라이브러리 덕분에 잘 해결할 수 있었습니다.
정말 감사합니다.

앞으로 좀더 실력 쌓아서 좋은 글 남길 수 있는 개발자로 성장하면
벨로그 유저분들께 많이 기여하겠습니다.

감사합니다. ^^7

답글 달기
comment-user-thumbnail
2020년 1월 22일

와...제가 storybook을 얼마나 대충 쓰고 있었는지 반성하게 되는군요... 덕분에 제대로 배우고 갑니다! 감사합니다!

답글 달기
comment-user-thumbnail
2020년 2월 28일

민준님, css-in-js 는 emotion 으로 대체하신건가요? ~_~)

답글 달기