reactjs
react-storybook
AtomicDesign

Atomic Design を react-storybook を通じて学ぶ

環境

使用する OS は MacOSX です。私の環境は以下の通り

% sw_vers
ProductName:    Mac OS X
ProductVersion: 10.12.6
BuildVersion:   16G29

使用するコマンドは以下のとおり

% create-react-app --version
1.4.0
% getstorybook --version
3.2.9

これらのコマンドを最新の状態にするには npm i -g create-react-app @storybook/cli を実行します。

練習用のプロジェクトディレクトリを準備

プロジェクトを生成し、storybook を導入する。ついでに react-icons をあらかじめ install しておく

create-react-app tutorial-atomic-design && cd $_
getstorybook -f
yarn add  react-icons --save 

components 用のディクレトリと作成予定のファイルを生成

mkdir src/components
touch src/components/{Buttons.js,Ratings.js,Cards.js}

yarn run storybook を実行して http://localhost:9009/ を開きます。以下の画面が表示される事を確認します。

FireShot Capture 8 - Storybook_ - http___localhost_9009_.png

本記事で扱う Atomic design について

Atomic design は5つのパートに分かれていますが、本記事では以下のパートを採用します。小さなパーツを積み上げて大きなパーツが出来上がっている過程を真似ているだけなのでこの記事の説明は厳密でないかもしれませんが Atomic design と react-storybook がやろうとしていることが非常に似ているので本記事で紹介している react-storybook の tutorial を通じて Atomic design の概念に触れることができると思います。本記事では小さなパーツから大きなパーツへ組み立てていくのですが、小さなパーツと大きなパーツを以下のように分類しています。

ATOMS = 原子
MOLECULES = 分子
ORGANISMS = 有機体

また CSS の記述方法は src/index.css に追記するものとします。今回の記事では CSS in JS は採用しません.

原子: button を作成

create src/components/Buttons.js

import React from "react";
import "../index.css";

export const Button = ({ primary, children, onClick }) => {

  const className = primary ? "btn btn-primary" : "btn";

  return(
    <div className={className} onClick={onClick}>
      {children}
    </div>
  )
};

edit src/stories/index.js

import React from 'react';

import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { linkTo } from '@storybook/addon-links';

import { Button } from '../components/Buttons';

storiesOf('Atoms', module)
  .add('button', () =>
    <div style={{display: "flex"}}>
      <Button onClick={action('clicked')}>btn</Button>
      <Button primary onClick={action('clicked')}>btn-primary</Button>
    </div>
  )

edit src/index.css

body {
  margin: 0;
  padding: 0;
  font-family: sans-serif;
  font-size: 14px;
}

.btn {
  display: flex;
  align-items: center;
  border-radius: 3px;
  padding: 0.25em 1em;
  margin: 0 1em;  // 本当は親component  padding で制御したほうがよい
  background: transparent;
  color: palevioletred;
  border: 2px solid palevioletred;
}

.btn-primary {
  background: palevioletred;
  color: white;
}

動作確認

FireShot Capture 9 - Storybook_ - http___localhost_9009_.png

分子: iconButton を作成

次に react-icons を使用してアイコン付きのボタンを作成します。

edit src/components/Buttons.js

import React from "react";
import "../index.css";

import FaMapMarker from 'react-icons/lib/fa/map-marker';
import MdEmail from 'react-icons/lib/md/email';

export const Button = ({ primary, icon, children, onClick }) => {

  const className = primary ? "btn btn-primary" : "btn";

  let IconComponent;
  switch (icon) {
  case "map":
      IconComponent = () => <span className="btn-icon"><FaMapMarker /></span>
      break
  case "contact":
      IconComponent = () => <span className="btn-icon"><MdEmail /></span>
      break
  default:
      IconComponent = () => null
  }

  return(
    <div className={className} onClick={onClick}>
      {icon && <IconComponent />}
      {children}
    </div>
  )
};

edit src/index.css

   background: palevioletred;
   color: white;
 }
+
+.btn-icon {
+  display: flex;
+  padding: 0 5px 0 0;
+  margin-left: -5px;
+}

ecit src/stories/index.js

+
+storiesOf('Molecules', module)
+  .add('icon + button', () =>
+    <div style={{display: "flex"}}>
+      <Button icon="map" onClick={action('clicked')}>Google Map</Button>
+      <Button icon="contact" primary onClick={action('clicked')}>Contact Me</Button>
+    </div>
+  )

動作確認

FireShot Capture 10 - Storybook_ - http___localhost_9009_.png

分子: star-rating を作成

create src/components/Ratings.js

import React from "react";
import "../index.css";

import FaStarO from "react-icons/lib/fa/star-o";
import FaStar from "react-icons/lib/fa/star";

const StarIcon = ({color, size, empty}) => {
  return (empty ? <FaStarO size={size} color={color} /> : <FaStar size={size} color={color} />)
}

export const StarRating = (props) => {

  const rating = props.rating || 3;
  const total = props.total || 5;

  const iconProps = []
  for (let i = 0; i < total; i++) {
    iconProps.push({empty: i >= rating})
  }

  return (
    <div className="star-rating">
      {iconProps.map((prop, index) => (
        <StarIcon key={index} color="#ffcc66" size={16} empty={prop.empty} />
      ))}
    </div>
  )
}

edit src/index.css

   padding: 0 5px 0 0;
   margin-left: -5px;
 }
+
+.star-rating {
+  margin: 0 1em;
+  padding-bottom: 5px;
+}

edit src/stories/index.js

 import { linkTo } from '@storybook/addon-links';

 import { Button } from '../components/Buttons';
+import { StarRating } from '../components/Ratings';

 storiesOf('Atoms', module)
   .add('button', () =>
@@ -21,3 +23,10 @@ storiesOf('Molecules', module)
       <Button icon="contact" primary onClick={action('clicked')}>Contact Me</Button>
     </div>
   )
+  .add('star-rating', () =>
+    <div style={{display: "flex", flexDirection: "columns"}}>
+      <StarRating rating={2} total={5} />
+      <StarRating rating={3} total={5} />
+      <StarRating rating={3} total={6} />
+    </div>
+  )

FireShot Capture 12 - Storybook_ - http___localhost_9009_.png

有機体: Card layout + star-rating + iconButton を作成

create src/components/Cards.js

import React from "react";
import "../index.css";

import { Button } from './Buttons';
import { StarRating } from './Ratings';

export const Card = ({title, rating, onClick }) => {

  return(
    <div className="card">
      <h4>{title}</h4>
      <div className="star-rating"><StarRating rating={rating} total={5} /></div>
      <div className="thumbnail">
        <img src="https://scontent-nrt1-1.cdninstagram.com/t51.2885-15/s320x320/e35/21689115_104174550297726_4853457458460360704_n.jpg" />
      </div>
      <div className="buttons">
        <Button icon="map" onClick={onClick}>Google Map</Button>
        <Button icon="contact" onClick={onClick}>問い合わせ</Button>
      </div>
    </div>
  )
};

edit src/index.css

   padding-bottom: 5px;
 }

+.card {
+  display: flex;
+  text-align: center;
+  flex-direction: column;
+  width: 360px;
+}
+
+.card .thumbnail {
+  width: 100%;
+}
+
+.card .buttons {
+  padding: 15px 20px;
+  display: flex;
+  justify-content: space-between;
+}

edit src/stories/index.js

+
+storiesOf('Organisms', module)
+  .add('card = star-rating + icon-button', () =>
+    <div style={{display: "flex", padding: "30px"}}>
+      <Card title="asatte" rating={4} onClick={action('clicked')} />
+    </div>
+  )

動作確認

FireShot Capture 13 - Storybook_ - http___localhost_9009_.png

まとめ

というわけで 原子である button component と icon compnent(react-icons) を組み合わせて、分子となる icon-button component を作成しました。また、原子である icon component(react-icons) を組み合わせて分子となる rating component を作成しました。

最後に分子である icon-button component と rating component を組み合わせて有機体となる card component を作成しました。

このように細かいパーツから component 化をしていくと再利用しやすいですし、動きのあるデザインも作りやすくなると思うのでフロントエンドエンジニアだけでなくデザイナーやコーダーのみなさんも是非お試しください。