storybookでのコンポーネント管理

# はじめに

[フロントエンド勉強会](https://qiita.com/masayasviel/private/0e5a403388509e98313d)

[storybook](https://storybook.js.org/)を用いて、コンポーネントを管理・UIテストを行う。
また、合わせて[SVG](https://developer.mozilla.org/ja/docs/Web/SVG)の触りも行う。

# 環境構築

[vue.js クイックスタート](https://ja.vuejs.org/guide/quick-start)に従いvueプロジェクトを作成。

```sh
npm create vue@latest
```

色々訊かれるので以下のように回答。
`%project_name%` ではプロジェクト名を聞かれているので、好きな名前に置き換える。

```
Ok to proceed? (y) # Enter
Vue.js - The Progressive JavaScript Framework

✔ Project name: … %project_name%
✔ Add TypeScript? … Yes
✔ Add JSX Support? … No
✔ Add Vue Router for Single Page Application development? … No
✔ Add Pinia for state management? … No
✔ Add Vitest for Unit Testing? … No
✔ Add an End-to-End Testing Solution? › No
✔ Add ESLint for code quality? … No
✔ Add Prettier for code formatting? … No
```

ここまでVueの設定。
[storybook](https://storybook.js.org/)を導入する。
[Install Storybook](https://storybook.js.org/docs/get-started/install)に従う。

```sh
npx storybook@latest init
```

これまで通り要らないものは消す。

```vue
<script setup lang="ts">
</script>

<template>
  <main>
  </main>
</template>

<style scoped>
</style>
```

`components/` 配下のコンポーネントが要らなくなったので削除。
`stories/` も要らない。

```sh
rm -rf src/components/*
rm -rf src/stories
```

消していいか確認されたら y でOK。

```sh
sure you want to delete the only file in /path/to/%project_name%/src/components [yn]? y
```

`.storybook/main.ts` も下記のように書き換え。

```ts
const config: StorybookConfig = {
  stories: ["../src/components/**/*.stories.@(js|ts)"],
  ...
};
```

# 実行

## Vueの実行

```sh
npm run dev
```

## storybookの実行

```sh
npm run storybook
```

# 課題

## ボタンコンポーネントの作成

```sh
$ mkdir src/components/my-button
$ touch src/components/my-button/my-button.vue
$ touch src/components/my-button/my-button.stories.ts
```

### components

```vue
<script setup lang="ts">
type ColorTheme = 'primary' | 'warning' | 'error';
const props = withDefaults(
  defineProps<{
    text: string;
    rounded: boolean;
    color: ColorTheme;
  }>(),
  {
    text: '',
    rounded: false,
    color: 'primary',
  }
);
</script>

<template>
  <button :class="{
    primary: props.color === 'primary',
    warning: props.color === 'warning',
    error: props.color === 'error',
    round: props.rounded,
  }">{{ props.text }}</button>
</template>

<style scoped>
button {
  text-align: center;
  vertical-align: middle;
  text-decoration: none;
  margin: auto;
  padding: 1rem 4rem;
  transition: 0.2s;
}

.round {
  border-radius: 100vh;
}

.primary {
  background: white;
  border: 1px solid #3B82F6;
  color: #3B82F6;
}

.primary:hover {
  color: white;
  background: #3B82F6;
}

.warning {
  background: white;
  border: 1px solid #f97316;
  color: #f97316;
}

.warning:hover {
  color: white;
  background: #f97316;
}

.error {
  background: white;
  border: 1px solid #ef4444;
  color: #ef4444;
}
.error:hover {
  color: white;
  background: #ef4444;
}
</style>
```

### storybook

```ts
import type { Meta, StoryObj } from "@storybook/vue3";
import MyButton from './my-button.vue';

type Story = StoryObj<typeof MyButton>;

const meta: Meta<typeof MyButton> = {
  title: 'MyButton',
  component: MyButton,
  argTypes: {
    text: {
      control: 'text',
    },
    color: {
      control: { type: 'radio' },
      options: ['primary', 'warning', 'error'],
    },
    rounded: {
      control: 'boolean',
    },
    onClick: { action: 'click' }
  },
};

export const Default: Story = {
  render: (args) => ({
    components: { MyButton },
    setup() {
      return { args };
    },
    template: '<MyButton v-bind="args" />',
  }),
  args: {
    text: 'default',
    color: 'primary',
    rounded: false,
  },
};

export default meta;
```

- [args](https://storybook.js.org/docs/writing-stories/args)
- [controls](https://storybook.js.org/docs/essentials/controls)
- [actions](https://storybook.js.org/docs/essentials/actions)

## ツールチップ

[カスタムディレクティブ](https://ja.vuejs.org/guide/reusability/custom-directives)の紹介を忘れていたのでここで行う。

今回はtooltipを作成する。
実際は下記のようにUIフレームワークにtooltipコンポーネントやディレクティブが用意されているので、そちらを用いる。

今回はディレクティブ紹介のために自作している。

- [vuetify#tooltip](https://vuetifyjs.com/en/components/tooltips/)
- [primevue#tooltip](https://primevue.org/tooltip/)
- [vue-directive-tooltip](https://www.npmjs.com/package/vue-directive-tooltip)

```sh
$ mkdir src/directive
$ mkdir src/directive/tooltip
$ touch src/directive/tooltip/tooitip.directive.ts
```

### directive

hoverされたらDOMを作成し、bodyの末尾に挿入。
hoverが外れたらツールチップNodeを消すようにしている。

bodyに差し込むので、位置指定しないと明後日の位置に挿入される。
そこで[getBoundingClientRect](https://developer.mozilla.org/ja/docs/Web/API/Element/getBoundingClientRect)を用いて現在位置を取得し、その位置に `position = absolute` で表示するようにしている。
また、できればツールチップを表示する要素下の真ん中に表示されて欲しいので、 `+ boundingClientRect.width / 2` としている。

Vueのディレクティブには[lifecycle](https://ja.vuejs.org/guide/essentials/lifecycle)の他に、[custom-directive#フック関数](https://v2.ja.vuejs.org/v2/guide/custom-directive#%E3%83%95%E3%83%83%E3%82%AF%E9%96%A2%E6%95%B0)がある。
ここではマウント時に対象要素に動作を設定、値に更新があった場合(`binding.value`)が変わったら再設定、要素のアンマウント時に要素の削除を行なっている。

```ts
import type { Directive, DirectiveBinding } from 'vue';

let tooltipElement: HTMLDivElement | null = null;
let tooltipText = '';

export const tooltipDirective: Directive = {
  mounted: (el: HTMLElement, binding: DirectiveBinding) => {
    el.style.cursor = 'pointer';
    tooltipText = binding.value;
    el.addEventListener('mouseenter', () => {
      tooltipElement = document.createElement('div');
      tooltipElement.textContent = tooltipText;
      tooltipElement.style.display = 'inline-block';
      tooltipElement.style.position = 'absolute';
      tooltipElement.style.backgroundColor = '#333';
      tooltipElement.style.color = '#fff';
      tooltipElement.style.borderRadius = '3px';
      tooltipElement.style.transition = '0.3s ease-in'
      const boundingClientRect = el.getBoundingClientRect();
      tooltipElement.style.top = `${boundingClientRect.top + boundingClientRect.height}px`;
      tooltipElement.style.left = `${boundingClientRect.left + boundingClientRect.width / 2}px`;
      document.querySelector('body')!.append(tooltipElement);
    });
    el.addEventListener('mouseleave', () => {
      if (tooltipElement) {
        tooltipElement.remove();
        tooltipElement = null;
      }
    })
  },

  updated: (_el: HTMLElement, binding: DirectiveBinding) => {
    tooltipText = binding.value;
  },

  unmounted: (_el: HTMLElement, _binding: DirectiveBinding) => {
    tooltipText = '';
    tooltipElement?.remove();
    tooltipElement = null;
  }
}
```

- [カスタムディレクティブ](https://ja.vuejs.org/guide/reusability/custom-directives)

main.tsでディレクティブを登録する。

```ts
createApp(App)
  .directive('tooltip', tooltipDirective)  // ここで登録
  .mount('#app');
```

## プログレスバー

上記で作成したtooltipディレクティブを用いる。

```sh
$ mkdir src/components/progress-bar
$ touch src/components/progress-bar/progress-bar.vue
$ touch src/components/progress-bar/progress-bar.stories.ts
```

### components

```vue
<script setup lang="ts">
const props = withDefaults(
  defineProps<{
    percentage: number;
    achieveFill: string;
    notAchieveFill: string;
  }>(),
  {
    percentage: 50,
    achieveFill: '#3B82F6',
    notAchieveFill: '#e9ecef',
  }
);
</script>

<template>
  <svg width="100%" height="100%">
    <rect
      :width="`${props.percentage}%`"
      v-tooltip="`${props.percentage}%`"
      height="100%"
      x="0"
      :fill="props.achieveFill"
    ></rect>
    <rect
      :width="`${100 - props.percentage}%`"
      :x="`${props.percentage}%`"
      height="100%"
      :fill="props.notAchieveFill"
    ></rect>
  </svg>
</template>

<style scoped>
</style>
```

### storybook

setupでdirectiveを登録してやる必要がある。(他にいい方法があれば教えて欲しい)

```ts
import type { Meta, StoryObj } from "@storybook/vue3";
import { setup } from '@storybook/vue3';
import { tooltipDirective } from '@/directive/tooltip/tooitip.directive';
import ProgressBar from './progress-bar.vue';

setup((app) => {
  app.directive('tooltip', tooltipDirective);
});

type Story = StoryObj<typeof ProgressBar>;

const meta: Meta<typeof ProgressBar> = {
  title: 'ProgressBar',
  component: ProgressBar,
  argTypes: {
    percentage: {
      control: {
        type: 'range',
        min: 0,
        max: 100
      }
    },
    achieveFill: {
      control: {
        type: 'color',
      }
    },
    notAchieveFill: {
      control: {
        type: 'color',
      }
    }
  },
};

export const Default: Story = {
  render: (args) => ({
    components: { ProgressBar },
    setup() {
      return { args };
    },
  template: `
    <div style="width: 100%; height: 24px;">
      <ProgressBar v-bind="args" />
    </div>`,
  }),
  args: {
    percentage: 50,
    achieveFill: '#3B82F6',
    notAchieveFill: '#e9ecef'
  },
};

export default meta;
```

- [svg#rect](https://developer.mozilla.org/ja/docs/Web/SVG/Element/rect)

## パーセンテージサークル

```sh
mkdir src/components/circle-percent
touch src/components/circle-percent/circle-percent.vue
touch src/components/circle-percent/circle-percent.stories.ts
```

### components

```vue
<script setup lang="ts">
import { computed } from 'vue';

/** 半径 */
const RADIUS = 40;
/** 円周 */
const circumference = RADIUS * 2 * Math.PI;

const props = withDefaults(
  defineProps<{
    percentage: number;
    fill: string;
  }>(),
  {
    percentage: 50,
    fill: '#3B82F6',
  }
);

const dashesLength = computed(() => circumference * props.percentage / 100);
</script>

<template>
  <svg viewbox="0 0 100 100">
    <circle
      cx="50"
      cy="50"
      :r="RADIUS"
      fill="none"
      :stroke="props.fill"
      stroke-width="5"
      :stroke-dasharray="`${dashesLength} ${circumference}`"
      transform="rotate(-90, 50, 50)"
    />
    <text
      x="50"
      y="50"
      text-anchor="middle"
      dominant-baseline="central"
    >{{ props.percentage }}%</text>
  </svg>
</template>

<style scoped>
</style>
```

まず、circleで円を作成。fillをnoneにしつつstrokeをつけることで円の輪郭のみ表示させる。
パーセンテージ分表示させたいので、stroke-dasharrayで破線表示。
描画長と間隔を交互に定義し、あとはそれが繰り返される。描画長は「表示パーセンテージ分の円周の長さ」、残りは表示されて欲しくないので、間隔は「円周の長さ」として、

`表示パーセンテージ分の円周の長さ 円周の長さ`

となる。

そのままだと右横からスタートになるので、 `transform="rotate(-90, 50, 50)"` で-90度回転してあげて真上スタートにしてやれば完成。

- [SVG基本的な図形#円](https://developer.mozilla.org/ja/docs/Web/SVG/Tutorial/Basic_Shapes#%E5%86%86)
- [SVG#text](https://developer.mozilla.org/ja/docs/Web/SVG/Element/text)
- [SVG#transform](https://developer.mozilla.org/ja/docs/Web/SVG/Attribute/transform)
- [SVG#stroke-dasharray](https://developer.mozilla.org/ja/docs/Web/SVG/Attribute/stroke-dasharray)

### storybook

```ts
import type { Meta, StoryObj } from "@storybook/vue3";
import CirclePercent from './circle-percent.vue';

type Story = StoryObj<typeof CirclePercent>;

const meta: Meta<typeof CirclePercent> = {
  title: 'CirclePercent',
  component: CirclePercent,
  argTypes: {
    percentage: {
      control: {
        type: 'range',
        min: 0,
        max: 100
      }
    },
    fill: {
      control: {
        type: 'color',
      }
    },
  },
};

export const Default: Story = {
  render: (args) => ({
    components: { CirclePercent },
    setup() {
      return { args };
    },
    template: `<CirclePercent v-bind="args" />`,
  }),
  args: {
    percentage: 50,
    fill: '#3B82F6'
  },
};

export default meta;
```