🏛️

Angular/Ionic で Storybook を試してみた

2020/12/19に公開

Ionic アドベントカレンダー 19 日目です。
Angular/Ionic を本格的に業務で触るようになって半年程度なので、何か間違っていることなどあればご指摘いただけるとありがたいです。

前置き

Angular/IonicでStorybookを試した際に情報が少なくて色々と困りました。それでも、ないない言っていても増えないのでまず自分から知っていることを発信しようと思い、Adventカレンダーに参加させていただきました。

この記事で書くこと•書かないこと

書くこと

  • Storybook を導入した理由
  • 試してみた導入手順
    • インストール
    • コンポーネント追加
    • ドキュメント追加
  • ハマりどころ・試行錯誤中のこと•簡単な tips

書かないこと

  • Storybook とは何か(他に良記事が多くあるので)
  • Ionic/Angular で Storybook を導入する際のベストプラクティス(教えてください <(_ _)>)

対象読者

以下のような読者を想定しています。

  • Storybook が何かは分かる
  • Angular/Ionic で Storybook を導入したい

検証環境

当方の環境は以下の通りです。

% sw_vers
ProductName: Mac OS X
ProductVersion: 10.15.7

% npm -version
6.11.3

成果物

最終形をGitHubに上げています。作業ステップごとにコミットをまとめて、コミットメッセージに実行したコマンドを記載しているので、そちらを合わせて参照していただくと分かりやすいと思います。

Storybook を導入した理由

Storybook導入したいんじゃーと私が会社で言っているのは大きく分けて2つの理由からです。

  • デザイン•UI 実装関連ドキュメントの一本化

開発時には色々なドキュメントを参照しながら作業していかなければなりません。

  1. デザインチームが作ったデザインカンプ
  2. システムチームで作ったアプリのアーキ解説ドキュメント
  3. ビジネスチームが作った設計書
  4. (もちろん)HTML/CSS/Javascript

これが色々な場所に点在していたら、参照するのも大変ですしお互いに同期を取って更新していくのも大変ですよね。Storybookのdocsという機能を使うと全て一箇所にまとめることができます。

特にアーキ解説ドキュメントについて言うと、Storybookでモックしたデータを変えたり実際に触ってみたりしながらドキュメントが読めるようになるので、新メンバーのオンボーディング資料としても使えるんじゃないかなと思っています。

  • デザイン•実装不備の早期検知/修正後の再確認の簡易化

Storybookを使うと、データをモックしてコンポーネントを単体で表示できます。これの何が嬉しいかと言うと、トップページからの画面遷移が不要なため、他画面の開発が完了する前から表示の確認が可能になります。

トップページ → 画面A → 画面B → 画面C

と言う遷移があったとして、アプリ上で画面Cのデザインの確認をするためにはトップページ ~ 画面Bの実装も終わっている必要があります。画面Cだけを表示できれば、画面Cだけ実装が終わっていれば良いので分業している場合には特にデザイン•実装をより早い段階から確認できます。

また、アプリ上でデザインを確認する場合にはDBにデータを投入しておく必要があるケースが多いと思いますが、Storybookであればモックしたデータを利用して画面表示できるため、異常系含めた複数パターンの確認も容易になります。

そこでデザイン•実装の不備が見つかり修正した後も、画面遷移なしに同一のデータを利用した表示が可能なので、修正後の確認も簡単になります。

試してみた導入手順

npx が使える、5.2.0 以降の npm がインストールされている前提で進めます。

プロジェクト作成

まず、Ionic プロジェクトを作成します。以下のコマンドをターミナルで実行します。プロジェクト名•テンプレート•Capacitor の有無はお好みでどうぞ。
npx ionic start ionic-storybook blank --type=angular --capacitor

Storybook(Angular) 追加

プロジェクトができたら、cd ionic-storybookでプロジェクトのルートに移動します。プロジェクト名を違うものにしていた場合、パスは適宜読み替えてください。

プロジェクトのルートでnpx -p @storybook/cli sb initを実行します。
このコマンドが完了すれば、Storybook が動かせるようになっているはずです。npm run storybookで確認してください。

Storybook を起動すると、Compodoc がドキュメントを生成しているかと思います。.gitignore に入れておきましょう。
echo 'documentation.json' >> .gitignore

Ionic CDN 追加

ここまでの設定で Angular + Storybook の構成が出来上がりました。ここに Ionic を追加します。

Storybook は Ionic Framework に対応していませんが、.storybook/preview-head.htmlに script タグ•link タグを追加することでスタイルシートや Javascript を読み込めます。

ここに Ionic のCDN 版を指定します。touch .storybook/preview-head.htmlでファイルを作成し、以下の内容を貼り付けてください。

<script
  type="module"
  src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.esm.js"
></script>
<script
  nomodule
  src="https://cdn.jsdelivr.net/npm/@ionic/core/dist/ionic/ionic.js"
></script>
<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/@ionic/core/css/ionic.bundle.css"
/>
<script
  type="module"
  src="https://cdn.jsdelivr.net/npm/ionicons/dist/ionicons/ionicons.esm.js"
></script>
<script
  nomodule
  src="https://cdn.jsdelivr.net/npm/ionicons/dist/ionicons/ionicons.js"
></script>

公式では script タグの src 属性 が以下の URL になっていますが、リンク切れになっています。

<!-- 「@4.7.4」がついている -->
<script
  type="module"
  src="https://cdn.jsdelivr.net/npm/ionicons@4.7.4/dist/ionicons/ionicons.esm.js"
></script>
<script
  nomodule
  src="https://cdn.jsdelivr.net/npm/ionicons@4.7.4/dist/ionicons/ionicons.js"
></script>

(一応 pull request を投げたのですけど、OSS に参加するのは初めてなのでやり方間違ってるかも...)

サンプル削除

こんな使い方あるんだなー、こんなリンクがあるなーを確認した後は必要ないので、サンプルを削除します。

rm -rf src/stories

コンポーネント追加(ボタン)

コンポーネントを追加します。大•中•小それぞれの大きさのボタンを定義し、アプリの中で再利用する場合を考えてみます。

ボタンコンポーネントを作成し、スタイルを定義します。

ionic g component components/buttonを実行し、button.component.html, button.component.ts, button.component.scss をそれぞれ以下のように変えてください。

button.component.html

<ion-button [class]="cssClass"></ion-button>

button.component.ts

import { Component, Input } from "@angular/core";

/**
 * カスタムボタン
 *
 * @export
 * @class ButtonComponent
 * @implements {OnInit}
 */
@Component({
  selector: "app-button",
  templateUrl: "./button.component.html",
  styleUrls: ["./button.component.scss"],
})
export class ButtonComponent {
  /**
   * ボタンの大きさ
   *
   * 現状実装されているのは"large" | "medium" | "small"の3種類
   *
   * large= 16rem * 8rem, medium= 8rem * 4rem, small= 4rem * 2rem
   *
   * @type {("large" | "medium" | "small")}
   * @memberof ButtonComponent
   */
  @Input() size: "large" | "medium" | "small";
  constructor() {}
}

button.component.scss

@mixin size($height) {
  width: $height * 2;
  height: $height;
}

.large {
  @include size(8rem);
}
.medium {
  @include size(4rem);
}
.small {
  @include size(2rem);
}

このボタンの Story を追加します。touch src/app/components/button/button.stories.tsでファイルを作成し、以下の内容を貼り付けます。プロダクションコードにより近い CSF で書いていますが、MDX で Story を書くこともできます。より詳細な説明は英語ですが公式にあります。

button.stories.ts

import { CommonModule } from "@angular/common";
import { IonicModule } from "@ionic/angular";
import { moduleMetadata } from "@storybook/angular";
import { ButtonComponent } from "src/app/components/button/button.component";

//#region モジュール定義
const imports = [CommonModule, IonicModule.forRoot()];
const declarations = [ButtonComponent];

export const data = {
  imports,
  declarations,
};

export default {
  title: "ButtonComponent",
  excludeStories: /.*[data]$/,
  decorators: [moduleMetadata(data)],
  component: ButtonComponent,
};
//#endregion

const baseCss = `' md button button-solid ion-activatable ion-focusable hydrated'`;
const Template = (args: ButtonComponent) => ({
  component: ButtonComponent,
  props: args,
  template: `
    <ion-app>
      <ion-content>
        <app-button
          [size]="size + ${baseCss}"
        ></app-button>
      </ion-content>
    </ion-app>
  `,
});

export const ボタン_大 = Template.bind({});
ボタン_大.args = {
  size: "large",
};
export const ボタン_中 = Template.bind({});
ボタン_中.args = {
  size: "medium",
};
export const ボタン_小 = Template.bind({});
ボタン_小.args = {
  size: "small",
};

ここで Storybook を開くと Story が 3 つ表示されます。つどつどアプリを起動してトップページから遷移しなくても、スタイルが確認できるようになりました。

ハマったところ

  • control で CSS を書き換えると、デフォルトで指定しているものも書き換えてしまう

上記の Story では以下のように Ionic が付与する CSS も合わせて app-button に指定していました。

const baseCss = `' md button button-solid ion-activatable ion-focusable hydrated'`;
//中略
template: `
<ion-app>
  <ion-content>
    <app-button [size]="size + ${baseCss}"></app-button>
  </ion-content>
</ion-app>
`,

control という拡張機能で Input を変更すると、この CSS ごと上書きされます。なので、control で CSS を変える場合は上記のようにデフォルトの CSS も合わせて指定する必要があります。もちろん、アプリの中でこのコンポーネントを使用する際には必要ありません。

こんな具合で Storybook で何かうまく行かないことがあっても、パニックにならずこのツールも HTML/CSS/Javascript の集まりだということを思い出して、地道に Chrome の dev ツールとにらめっこしたら大体なんとかなります。

(でもこれだと後述の docs で表示するソースで「${baseCss}」も表示されてしまうんですよね。。。他にいい方法あれば教えてください。)

ダイアログ追加

次にダイアログを Storybook で表示してみます。まずコンポーネントと Story を作成します。

ionic g component components/dialog
touch src/app/components/dialog/dialog.stories.ts

以下の内容を各ファイルに貼り付けてください。

global.scss

.custom-modal {
  --background: #222;
}

dialog.component.html

<p>{{ message }}</p>

dialog.component.ts

import { Component, Input } from "@angular/core";

/**
 * ダイアログ
 *
 * @export
 * @class DialogComponent
 */
@Component({
  selector: "app-dialog",
  templateUrl: "./dialog.component.html",
  styleUrls: ["./dialog.component.scss"],
})
export class DialogComponent {
  /**
   * ダイアログ内で表示するメッセージ
   *
   * @type {string}
   * @memberof DialogComponent
   */
  @Input() message: string;
  constructor() {}
}

dialog.stories.ts

import { CommonModule } from "@angular/common";
import { Component, Input, NgModule } from "@angular/core";
import { IonicModule, ModalController } from "@ionic/angular";
import { moduleMetadata } from "@storybook/angular";
import { DialogComponent } from "./dialog.component";

//#region テストデータ
//#endregion

//#region モジュール定義
@Component({
  selector: "app-dialog-launcher",
  template: ` <button (click)="launch()">ダイアログ起動</button> `,
})
class DialogLauncherComponent {
  @Input() message: string;
  constructor(private modalController: ModalController) {}

  public async launch() {
    const modal = await this.modalController.create({
      component: DialogComponent,
      cssClass: "custom-modal",
      componentProps: { message: this.message },
    });
    await modal.present();
  }
}
const DECLARATIONS = [DialogComponent, DialogLauncherComponent];
@NgModule({
  declarations: DECLARATIONS,
  exports: DECLARATIONS,
  entryComponents: [DialogComponent],
  imports: [CommonModule, IonicModule.forRoot()],
  providers: [ModalController],
})
class DialogLauncherComponentModule {}

const imports = [DialogLauncherComponentModule];

export const data = {
  imports,
};

export default {
  title: "DialogComponent",
  excludeStories: /.*[(data)]$/,
  decorators: [moduleMetadata(data)],
  component: DialogLauncherComponent,
};
//#endregion

const Template = (args: DialogLauncherComponent) => ({
  component: DialogLauncherComponent,
  props: args,
});

export const ダイアログ = Template.bind({});
ダイアログ.args = {
  message: "ダイアログです",
};

このように Story の中で用意した、ダイアログを立ち上げる用のコンポーネントを表示し、そこからモーダルを立ち上げています。

<ion-modal>
  <app-dialog-component [message]="message"> </app-dialog-component>
</ion-modal>

のように表示してみたのですが、バックドロップなどのダイアログの CSS がうまく適用されなかったのでこのような形にしています。

このようなワークアラウンドなしで直接ダイアログを表示できたという人がいれば、やり方を教えて欲しいです。

ドキュメント追加

次にドキュメントを表示する設定をします。

上記の通りsb initで Storybook をインストールしていれば、「@storybook/addon-docs」という拡張機能がデフォルトでついています。この拡張機能と compodoc がコンポーネントのコメントからドキュメントを生成してくれます。ですが、、、

docs 内の iframe がコンポーネントに合わせて高さを変えないので、ボタンが見切れてしまっています。これは GitHub に issue が上がっています(が、1 年以上経ってる。。。コントリビュートしたいけど React 分からない 😭)。

control が表示されているので、そこから表示が変えられそうに見えますが、Angular はこの機能に対応していません

このままだとスクロールできないので(私の環境だけ?)、preview-head.html にスタイルタグを追加します。リロードだと反映されないので、一度 Storybook を止めてからnpm run storybookしなおします。

<style>
  .sbdocs-wrapper {
    height: 90vh;
    overflow: scroll;
  }
</style>

これで準備ができたので、アプリ全体のことについてのドキュメントとボタンについてのドキュメントを作ります。ボタンと特に違いはないので、ダイアログについてのドキュメントは割愛します。

touch src/app/general-document.stories.mdx
touch src/app/components/button/button-document.mdx

画像はお好きのものかこちらを使ってください

general-document.png

button-document.png

general-document.mdx

import imageFile from "./general-document.png";

<Meta title="document" />

# ドキュメント

開発時には本ドキュメントを参照しながら進めてください

<style>
  {`
  img {
    border: dashed 2px #999;
  }
`}
</style>

<img src={imageFile} alt="ドキュメント" />

button-document.mdx

import imageFile from "./button-document.png";
import { ArgsTable, Story, Source, Canvas } from "@storybook/addon-docs/blocks";
import { ButtonComponent } from "./button.component";
import dedent from "ts-dedent";

# ButtonComponent

## ドキュメント

<style>
  {`
  img {
    border: dashed 2px #999;
  }
`}
</style>

<img src={imageFile} alt="ドキュメント" />

## コンポーネント

<Canvas>
  <Story id="dialogcomponent--ダイアログ" />
</Canvas>

## プロパティ

<style>
  {`
  .padding {
    padding-bottom: 18px;
  }
`}
</style>

<div className="padding">
  <ArgsTable of={ButtonComponent} />
</div>

ボタンのストーリーの default export を変えてドキュメントを読み込みます。

まず型定義ファイルを作成します。

mkdir typings
touch mdx.d.ts

mdx.d.ts

declare module "*.mdx" {
  let mdx: any;
  export default mdx;
}

.storybook というディレクトリにsb initで生成された型定義ファイルがあるので、同じようにこちらにも mdx の型定義を追加します。

.storybook/typings.d.ts

declare module "*.mdx" {
  let mdx: any;
  export default mdx;
}

最後にボタンのストーリーからドキュメントを読み込みます。

button.stories.ts

import mdx from "./button-document.mdx";

//中略
export default {
  title: "ButtonComponent",
  excludeStories: /.*[data]$/,
  decorators: [moduleMetadata(data)],
  component: ButtonComponent,
  //new!
  parameters: {
    docs: {
      page: mdx,
    },
  },
};
//後略

これでストーリーと一緒にドキュメントも読めるようになりました。

ハマりどころ・試行錯誤中のこと

以下、上記の手順を試す中でハマったところや気づいた tips などです。

  • Story の名前に StoryName はだめ
export const StoryName = Template.bind({});

としたことがあるのですが、Storybook が内部で使用する識別子と衝突するようで該当のストーリーが画面に表示されませんでした。

  • parameters で設定をカスタマイズ 1.並べ替え

.storybook/preview.js というファイルで export している parameters をいじることで Storybook の挙動をカスタマイズできます。

例えば、Story を一定の並び順で表示したい場合

export const parameters = {
  options: {
    storySort: {
      method: 'alphabetical',
    }
  },
};

とするとアルファベット順に整列できます。他にも色々な並べ替えオプションがあります。同様に Canvas タブ/docs タブの並び順も変更できます。

export const parameters = {
  previewTabs: { 'storybook/docs/panel': { index: -1 } }
};
  • parameters で設定をカスタマイズ 2.画面サイズ

色々なデバイスの大きさで表示を確認したい場合、sb initでインストールされる addon-viewport という拡張機能を使うと便利です。ブラウザの検証ツールで表示を変更してもいいのですが、スマホのサイズにするとサイドバーなどが表示されなくなってしまいます。

import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport';
//中略
export const parameters = {
  viewport: {
    viewports: INITIAL_VIEWPORTS
  },
};

  • parameters で設定をカスタマイズ 3.余白を消したい

画面の余白を調整するとき、Story にデフォルトでついている padding を消したいことがありました。

export const parameters = {
  layout: 'fullscreen'
};

とすると余白が消せます。「画面を表示する時は余白を消したいけど、画面のコンポーネントを表示しているときには残したい」という場合には、storybook-addon-paddings という拡張機能が使えます。

  • Ionic のスタイルが適用されないことがある

button.stories.ts にある Template は以下のようにも書けます。

const Template = (args: ButtonComponent) => ({
  component: ButtonComponent,
  props: args,
});

これでも表示されますが、以前のバージョンだとクリック時のリップルエフェクトがつきませんでした(今回試してみたら、つくように直っていた)。ion-button に限らず他のコンポーネントでも、ion-app の内側に入れないと Ionic のスタイルが効かないことがあります。

まとめ

以上、Angular/Ionic で Storybook を試してみた際の導入手順とハマりポイントでした。
明日はmaki_sakiさんがStencilについて書いてくれるそうです。今気付きましたが、Stencilでやった方がStorybookとの相性良かったかも。。。

Discussion