0
0

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

Last updated at Posted at 2024-03-05

はじめに

フロントエンド勉強会

storybookを用いて、コンポーネントを管理・UIテストを行う。
また、合わせてSVGの触りも行う。

環境構築

vue.js クイックスタートに従いvueプロジェクトを作成。

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を導入する。
Install Storybookに従う。

npx storybook@latest init

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

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

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

<style scoped>
</style>

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

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

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

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

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

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

実行

Vueの実行

npm run dev

storybookの実行

npm run storybook

課題

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

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

components

<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

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;

ツールチップ

カスタムディレクティブの紹介を忘れていたのでここで行う。

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

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

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

directive

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

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

Vueのディレクティブにはlifecycleの他に、custom-directive#フック関数がある。
ここではマウント時に対象要素に動作を設定、値に更新があった場合(binding.value)が変わったら再設定、要素のアンマウント時に要素の削除を行なっている。

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;
  }
}

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

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

プログレスバー

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

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

components

<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を登録してやる必要がある。(他にいい方法があれば教えて欲しい)

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;

パーセンテージサークル

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

components

<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度回転してあげて真上スタートにしてやれば完成。

storybook

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;
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0