はじめに
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;