Storybook と Vite を使用した HTML テンプレート開発

こんにちは、un-T factory の yuzuru です。
本日 12/20 は「生きた化石」シーラカンスの学術調査が実施された日だそうですね。

さて、今年のアドベントカレンダーも終わりが近づいてきましたが、昨今のフロントエンド開発において JS フレームワークの話題が上がることは少なくないかと思います。（自分自身、Vue.js や Nuxt.js についての記事を過去のアドベントカレンダーで書いていたり、Next.js のアップデートに振り回されたりと忙しくも飽きのこない日々を送っています。）

新しいものを取り入れることによってよりよい開発・制作物を追い求めることは魅力的ですが、最新の技術が日々進化する中で、時に変わらないものの価値が重要になるケースがありますよね。
そう、シーラカンスのように。

直近自分が検討を行った技術選定のケースとして『純粋な HTML を使用したテンプレートのリニューアル』があげられます。
※本記事での「テンプレート」とは、特定の HTML 構造に対して CSS および JS が適用され、より具体的なデザインおよび機能を備えた HTML を意味します。

「純粋な HTML テンプレート」として自分が記述しているものの例として [Swiper](https://swiperjs.com/get-started#add-swiper-html-layout) の HTML Layout を取り上げます。

```html
<!-- Slider main container -->
<div class="swiper">
  <!-- Additional required wrapper -->
  <div class="swiper-wrapper">
    <!-- Slides -->
    <div class="swiper-slide">Slide 1</div>
    <div class="swiper-slide">Slide 2</div>
    <div class="swiper-slide">Slide 3</div>
    ...
  </div>
  <!-- If we need pagination -->
  <div class="swiper-pagination"></div>

  <!-- If we need navigation buttons -->
  <div class="swiper-button-prev"></div>
  <div class="swiper-button-next"></div>

  <!-- If we need scrollbar -->
  <div class="swiper-scrollbar"></div>
</div>
```

Swiper の場合、必要な CSS, JS の読み込みが記述されたサイト内で上記の HTML の記述を行うことでカルーセル UI として動作します。

最近ではデザインシステムの一部としてコンポーネントカタログを Storybook で公開されている企業も少なくありませんが、SaaS のデザインシステムでは大元の技術選定として JS フレームワーク（React, Vue など）が採用されていることがほとんどかと思います。

本記事では次の要件をベースとし、JS フレームワークを使用しない HTML テンプレートの Storybook 管理と Vite 環境の構築について記述します。

[Storybook](https://storybook.js.org/) および [Vite](https://ja.vite.dev/) についての説明は割愛しますので、必要に応じて公式のドキュメントを参照してください。

## 要件

JS フレームワークを使用しない HTML テンプレートを利用したいケースとして下記のようなものが考えられるかと思います。

- Web サイト内で共通のテンプレートを管理・使用したい
- 動的なテンプレートがあまり多くない、ランタイムでの JS 処理を可能な範囲で避けたいなどの理由で JS フレームワークの利用を避けたい
- CMS 上で共通の HTML を記述して更新を行う必要がある
  - 非エンジニアも含めてコードのコピー&ペーストが中心の運用になるため、極力コードを触らずに作業を完結させられるのが理想
  
自分のケースではシステムリプレイスに伴うのテンプレートの整理が目的であり、加えて
- すでに運用されている HTML ベースのテンプレートがあり、JS フレームワークに置き換えるには工数がかかりすぎる

という側面もありました。
テンプレート外の記述との競合なども踏まえると Web components の方が適していると思うケースでもありますが、運用に関わられていた非エンジニアの方が引き続き触ることも踏まえて、運用方法が大きく変わらないことも押さえるべきポイントの 1 つかと思っています。

上記を踏まえて、純粋な HTML と、クライアント側での運用を少しでも楽にする目的で Storybook の選定としました。

## 開発環境の各種バージョン

- Vite: 6.0.1
- Storybook: 8.4.7

## 運用のイメージ

### CMS での記事更新運用

Storybook では出力コードのコピーが可能です。

<https://storybook.js.org/docs/get-started/browse-stories#use-stories-to-build-uis>

こちらの機能を使用して、下記のステップで CMS 側にペーストするコードの出力を行います。

1. Storybook 上で使用したいテンプレートを検索
2. Storybook のインプットフォームを使用し、テンプレートの一部を差し替え
3. Storybook からコードをコピーし、CMS 側にペースト
4. 管理画面上の仕様などで 2 で対応できないものは CMS 上でコードを変更

### テンプレートの動作環境

Storybook だけではなく、テンプレートが動作する環境を作成する必要があります。
CMS でテンプレートが更新されるページで必要な CSS, JS を読み込ませておけばよいだけの話ですが、今回はこちらのファイルビルドに Vite を使用しました。
Vite で出力された CSS, JS をページ内で読み込ませておき、そのページ内で前述の HTML テンプレートを使用するイメージですね。

## なにはともあれ環境構築

### Vite

Vite 環境からまず作成します。

`$ npm create vite@latest`

インストール時に聞かれる内容に対しては、今回はデフォルトままの下記設定としました。

```
✔ Project name: … vite-project
✔ Select a framework: › Vanilla
✔ Select a variant: › TypeScript
```

vite.config.ts の設定は下記の様にしておきます。
テンプレートの CSS, JS ファイルが CMS 側でコンテンツ管理されているページに読み込まれていればよいので、Vite では lib モードを使用し CSS, JS のみをビルドで出力することとします。
また出力される JS の影響範囲をテンプレートとして出力する JS に閉じる目的で `formats: ["iife"]` を指定し、即時関数として出力します。

```ts:vite.config.ts
export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, "src/main.ts"),
      name: "template",
      fileName: "template",
      formats: ["iife"],
    },
  },
});
```

### Storybook

Storybook のセットアップは CLI で行うと簡単です。
下記のコマンドにより、プロジェクトに必要なセットアップが自動的に行われます。*

`$ npx sb init --type html`

※ プロジェクト内またはグローバルに Storybook がインストールされていない場合、`sh: create-storybook: command not found` のようなエラーが出力されます。
この場合は `$ npm install -g @storybook/cli` や `$ npm install -D @storybook/cli` などで Storybook を使用できる環境を整えてください。

## ディレクトリ構成を整理する

ここまでの作業で、おおむね下記の様なディレクトリ構成になっているかと思います。
（Vite の初期設定で作成される、デフォルトの一部ファイルを除きます）

```
vite-project
├── .storybook
│   ├── main.ts
│   └── preview.ts
├── index.html
├── package-lock.json
├── package.json
├── public
├── src
│   ├── stories
│   ├── main.ts
│   ├── style.css
│   └── vite-env.d.ts
├── tsconfig.json
└── vite.config.ts
```

そのまま運用しても問題ないですが運用の都合上、役割として適切でない命名や階層が発生するため若干命名や階層構造を調整します。
タブ UI のテンプレートを作成すると想定し、src ディレクトリの構成を下記の様に変更します。

```terminal
vite-project
└── src
    ├── templates（main.ts から内部の JS を呼び出すこともあるので、stories から命名変更）
    │   └── tab
    │       ├── index.stories.ts
    │       ├── index.ts（テンプレートの HTML に対応する DOM を生成する）
    │       ├── tab.css
    │       └── tab.ts（テンプレートの JS 部分）
    ├── main.ts
    └── style.css
```

## いざ、テンプレート実装

Storybook でのテンプレート部分は下記のように実装しました。
stories ファイルについては React などの JS フレームワークを使用する場合と大きく変わりませんが、CSS, JS 部分は Vite 側でも import する必要があるため、Storybook に表示するテンプレートの HTML 部分（`index.ts`）と CSS（`tab.css`）, JS（`tab.ts`）は分離しておきます。
※記事中の CSS, JS によるタブについては、簡単のため動作最低限の処理としています。

```ts:index.ts
import "./tab.css";
import { tab } from "./tab.ts";

export interface TabProps {
  tabs: {
    label: string;
    content: string;
  }[];
}

export const createTemplate = ({ tabs }: TabProps) => {
  queueMicrotask(() => {
    tab();
  })

  const tabButtons = tabs
    .map((tab, index) => {
      return /* html */ `
      <button class="tab__button js-tab__button" aria-controls="tab-${index}">${tab.label}</button>
    `;
    })
    .join("");

  const tabPanels = tabs
    .map((tab, index) => {
      return /* html */ `
      <div class="tab__panel js-tab__panel" id="tab-${index}" aria-hidden="${index === 0 ? "false" : "true"}">${tab.content}</div>
    `;
    })
    .join("");

  return /* html */ `
    <div class="tab js-tab">
      <div class="tab__buttons">
        ${tabButtons}
      </div>
      <div class="tab__panels">
        ${tabPanels}
      </div>
    </div>
  `;
};
```

```ts:index.stories.ts
import type { Meta, StoryObj } from "@storybook/html";
import { createTemplate, type TabProps } from ".";

const meta = {
  title: "Template/Tab",
  tags: ["autodocs"],
  render: (args) => {
    return createTemplate(args);
  },
} satisfies Meta<TabProps>;

export default meta;
type Story = StoryObj<TabProps>;

export const Primary: Story = {
  args: {
    tabs: [
      {
        label: "Tab 1",
        content: "Content 1",
      },
      {
        label: "Tab 2",
        content: "Content 2",
      },
      {
        label: "Tab 3",
        content: "Content 3",
      },
    ],
  },
};
```

```css:tab.css
.tab__panel[aria-hidden="true"] {
  display: none;
}
```

```ts:tab.ts
export const tab = () => {
  const tabs = document.getElementsByClassName(
    "js-tab"
  ) as HTMLCollectionOf<HTMLElement>;

  if (!tabs) return;

  const _closeAllTabs = (contents: HTMLCollectionOf<Element>) => {
    for (const content of Array.from(contents)) {
      content.setAttribute("aria-hidden", "true");
    }
  };

  const _openTab = (tab: HTMLElement, targetId: string) => {
    const target = tab.querySelector(`#${targetId}`);

    target?.setAttribute("aria-hidden", "false");
  };

  for (const tab of Array.from(tabs)) {
    const tabButtons = tab.getElementsByClassName("js-tab__button");
    const tabPanel = tab.getElementsByClassName("js-tab__panel");

    for (const button of Array.from(tabButtons)) {
      button.addEventListener("click", () => {
        const targetId = button.getAttribute("aria-controls");

        if (!targetId) return;

        _closeAllTabs(tabPanel);
        _openTab(tab, targetId);
      });
    }
  }
};
```

## 実装のポイント

### queueMicrotask

JS によるタブの初期化処理は [queueMicrotask](https://developer.mozilla.org/ja/docs/Web/API/Window/queueMicrotask) 内での実行としました。
Storybook 上で props によってテンプレートの表示内容を切り替える上、でテンプレート部分を JS で生成する必要がありますが、そのまま初期化を行うだけでは DOM の生成よりもタブの初期化が先に実行されてしまい正常に動作しません。
setTimeout で実行を後に回すのと同じようなイメージですが、setTimeout のコールバックに指定されたタスクより queueMicrotask のコールバックのほうが先に実行されます。

[JavaScript で queueMicrotask() によるマイクロタスクの使用](https://developer.mozilla.org/ja/docs/Web/API/HTML_DOM_API/Microtask_guide)

### tabButtons, tabPanel の .join('')

index.ts のタブのボタンおよびパネルを配列から生成する箇所では、Storybook の props から渡された配列を map で処理します。
createTemplate 関数が返すのはただの文字列なので、そのままだとテンプレートリテラル内の表示箇所 `${tabButtons}`, `${tabPanels}` にはそのまま配列を文字列として出力する際のカンマが表示に含まれてしまいます。
`.join('')` で配列を結合し文字列としておくことでカンマを取り除きます。

| .join('') なし | .join('') あり |
|------------|-------------|
| ![スクリーンショット 2024-12-17 21.03.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/390806/33d83650-8b05-cdaf-b6be-5b8299a02127.png) | ![スクリーンショット 2024-12-17 21.03.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/390806/224105bf-1091-3afe-50fb-ec95c1ba067c.png) |

### テンプレートリテラルでの HTML のシンタックスハイライト

テンプレートリテラルの前に記述されているコメント箇所 `/* html */` ですが、下記の VS Code 拡張を使用して HTML のシンタックスハイライトを当てています。
Storybook上の表示には影響ないですが、開発者目線としてはありがたいですね。

[Comment tagged templates](https://marketplace.visualstudio.com/items?itemName=bierner.comment-tagged-templates)

| ハイライトなし | ハイライトあり |
|------------|-------------|
| ![code1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/390806/d55bb541-2d51-3d20-6fe6-1a2cd87bd23e.png) | ![code.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/390806/a7fb67e6-664d-d1bc-a259-5ff74a8d143b.png) |

### autodocs によるドキュメント生成

テンプレートのドキュメントは stories の `tags: ["autodocs"]` で指定します。
[Automatic documentation and Storybook](https://storybook.js.org/docs/writing-docs/autodocs#set-up-automated-documentation)
ほぼ全ページでドキュメントが表示されていて欲しい場合、stories ではなく preview に記述することで一括反映が可能です。

```ts
import type { Preview } from "@storybook/html";

const preview: Preview = {
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
  tags: ["autodocs"], // これを追加
};

export default preview;
```

autodocs では Storybook 上で表示するドキュメントをコードから自動で生成してくれますが、HTML テンプレートでうまくいかなかったポイントとして、Props の型に対する補足が JSDoc から生成されない、という内容があげられます。
props の説明や description が Docs にも自動で入ってくれるとよい（React コンポーネントだと入る模様）のですが、この部分は別途手動で補完する必要がありそうです。

GitHub にも下記のような Issue が上がっていました。@storybook/html での利用時だけでなく、フレームワークによってはうまく動作しないものがあるようです。

[[Bug]: @storybook/html doesn't support Autodocs #26628](https://github.com/storybookjs/storybook/issues/26628)
[[Bug]: autodoc for angular components does not include jsdoc-documentation #28506](https://github.com/storybookjs/storybook/issues/28506)

結果として preview に設定した autodocs は除去し、Docs を MDX で別途記述することによって Description を追加することとしました。
props のテーブルにテキストで補足を入れる方法はいまのところ見つけられていません。
（もしご存じの方いらっしゃればコメントいただきたい。。）

```Docs.mdx
import { Canvas, Meta, Controls } from "@storybook/blocks";

import * as TabStories from "./index.stories";

<Meta of={TabStories} />

# Tab

description here!

<Canvas of={TabStories.Primary} />
<Controls />
```

## Vite 側のビルド設定

テンプレートの CSS, JS としてビルドするため、Vite のエントリーポイントとなる TS ファイルに必要なファイルを読み込ませておきます。
とはいえ Storybook の表示で使用している templates ディレクトリ内の CSS, JS ファイルを Vite がビルドする TS ファイルで読み込み初期化する程度です。

```ts:main.ts
import "./templates/tab/tab.css";
import { tab } from "./templates/tab/tab";

const init = () => {
  tab();
};

init();
```

## 動作検証

Storybook 上の copy code から出力された HTML をペーストして動作検証を行います。
Vite でビルドした CSS, JS ファイルの読み込みを追加した HTML ファイルを用意し、そちらにテンプレートを貼り付けます。

```html
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>HTML Template Sample</title>
    <!-- NOTE: Vite でビルドした CSS, JS を読み込み -->
    <script type="module" crossorigin src="/template.iife.js"></script>
    <link rel="stylesheet" href="/template.css" />
  </head>
  <body>
    <div class="tab js-tab">
      <div class="tab__buttons">
        <button class="tab__button js-tab__button" aria-controls="tab-0">
          Tab 1
        </button>
        <button class="tab__button js-tab__button" aria-controls="tab-1">
          Tab 2
        </button>
        <button class="tab__button js-tab__button" aria-controls="tab-2">
          Tab 3
        </button>
      </div>
      <div class="tab__contents">
        <div
          class="tab__content js-tab__content"
          id="tab-0"
          aria-hidden="false"
        >
          Content 1
        </div>
        <div class="tab__content js-tab__content" id="tab-1" aria-hidden="true">
          Content 2
        </div>
        <div class="tab__content js-tab__content" id="tab-2" aria-hidden="true">
          Content 3
        </div>
      </div>
    </div>
  </body>
</html>
```

問題なく動作確認ができました。

## まとめ

本記事では、JS フレームワークを使用しない HTML ベースのテンプレートを Storybook で管理し、Vite を活用して必要な CSS, JS をビルドする手法を紹介しました。
JS フレームワークを使用しない Storybook の運用は、第一選択肢として頻繁にピックアップされるものではないかと考えていますが、要件によってはこの方法が実装・運用上ちょうどよいこともあります。今回は既存のHTMLテンプレートを生かしつつ、CMSとの統合をスムーズに進めるというニーズにうまくマッチした例かと思います。

現代の海に生きるシーラカンスが必要となった際に、この記事の方法が一部でも誰かの役に立つことがあれば嬉しく思います。
みなさま今年もよいクリスマスを！