Headless UI + Vue3で作るUIコンポーネント
はじめに
Headless UIはTailwind CSSを開発しているTailwind Labsを中心に開発されているUIライブラリです。特徴として、スタイルは実装者側で自由に制御できること、アクセシビリティはライブラリ側で確保してくれること等が上げられます。React用とVue.js用がそれぞれ用意されていますが、今回はVue.jsでUIコンポーネントを作成してみました。
全体のコードは以下のリポジトリに公開しています。
導入
Headless UIのドキュメントですが、サンプルも豊富でかなり分かりやすく書かれています。
環境構築
今回の主な使用技術は以下となっています。
- 開発ツール:Vite
- フレームワーク:Vue3(TypeScript)
- UIライブラリ:Headless UI
- CSSフレームワーク:Tailwind CSS
Headless UIがサポートしているVue.jsのバージョンは、現時点(2021.09.11)でVue3のみとなっています。開発ツールは高速な開発サーバーを提供してくれるViteを利用しました。CSSは必ずしもTailwind CSSを使用する必要はありませんが、Headless UIと相性がよいと公式で推奨されています。
以下を参照しながら環境構築をしました。
- Viteプロジェクトの生成
$ npm init vite@latest healessui-example
√ Select a framework: » vue
√ Select a variant: » vue-ts
$ cd healessui-example
$ npm install
- Tailwind CSSのインストール
$ npm install -D tailwindcss@latest postcss@latest autoprefixer@latest
- Tailwind CSSの設定ファイルの作成
$ npx tailwindcss init -p
tailwind.config.js
とpostcss.config.js
が生成されます。
-
tailwind.config.js
のpurge
設定を書き換える
module.exports = {
+ mode: 'jit',
- purge: [],
+ purge: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {},
},
variants: {
extend: {},
},
plugins: [],
}
-
index.css
ファイルを作成してmain.ts
から読み込む
@tailwind base;
@tailwind components;
@tailwind utilities;
import { createApp } from 'vue';
import App from './App.vue';
+ import './index.css';
createApp(App).mount('#app');
かなりざっくりとですが、これでとりあえず開発環境が準備ができました。
Tab UIの作成
最初に以下のようなTab UIを作成してみます。
基本的な使い方
まずプロジェクトにHeadless UIのパッケージをインストールします。
$ npm install @headlessui/vue
Vueコンポーネントを作成してHeadless UIのコンポーネントを読み込みます。
<script lang="ts">
import { defineComponent } from "vue";
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from "@headlessui/vue";
export default defineComponent({
name: "BasicTabs",
components: { TabGroup, TabList, Tab, TabPanels, TabPanel },
// ...
});
</script>
Tabs UIはTabGroup
TabList
Tab
TabPanels
TabPanel
という5つのコンポーネントで構成されていて、これらをtemplateで使用します。
<template>
<TabGroup>
<TabList>
<Tab>Tab 1</Tab>
<Tab>Tab 2</Tab>
<Tab>Tab 3</Tab>
</TabList>
<TabPanels>
<TabPanel>Content 1</TabPanel>
<TabPanel>Content 2</TabPanel>
<TabPanel>Content 3</TabPanel>
</TabPanels>
</TabGroup>
</template>
この状態で確認してみると、以下のようになっています。
TabList
の各Tab
を選択するとTabPanels
内の対応するTabPanel
がアクティブになります。見た目のスタイルは何もついていな状態です。
DevToolsで確認すると以下のようになっています。
各要素にrole
属性やaria
属性が付与されているのが確認できます。このように、フォーカスの制御やスクリーンリーダーへの配慮等、アクセシビリティに関する項目をHeadless UIが管理してくれます。
キー操作は←
→
でタブを選択したり、Enter
Space
で選択要素をアクティブにしたりできるようになっています。
このように、「アクセシビリティやUIの振る舞いはこちらで担保するから、見た目に関する部分のスタイルは思う存分やってくれ」というのがHeadless UIの考え方なのかなと思います。
データの定義
表示するデータはsetup
内で定義してTab
とTabPanel
でそれぞれリストレンダリングしています(Options APIでも記述できます)。
<template>
<TabGroup>
<TabList>
<Tab
v-for="category in Object.keys(sampleData)"
:key="category"
>
<button>{{ category }}</button>
</Tab>
</TabList>
<TabPanels>
<TabPanel
v-for="(details, index) in Object.values(sampleData)"
:key="index"
>
<ul>
<li
v-for="detail in details"
:key="detail.id"
>
<h3>{{ detail.name }}<h3>
<p>{{ detail.description }}</p>
</li>
</ul>
</TabPanel>
</TabPanels>
</TabGroup>
</template>
<script lang="ts">
import { defineComponent, ref } from "vue";
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from "@headlessui/vue";
export default defineComponent({
// ...
setup() {
const sampleData = ref({
tabA: [
{
id: 1,
name: "title A-1",
description: "This is dummy text.",
},
{
id: 2,
name: 'title A-2',
description: 'This is dummy text.',
},
],
tabB: [
// ...
],
tabC: [
// ...
],
});
return {
sampleData,
};
},
});
</script>
選択中のタブのスタイル
Tab
の選択状態はスコープ付きスロットでスロットプロパティのselected
をbool値で受け取ることができます。選択状態によって、<button>要素に三項演算子で動的にクラスを適用しています。今回の例では非選択時とホバー状態の時にopacity
の値を変更しています。
// ...
<Tab
v-for="category in Object.keys(sampleData)"
:key="category"
+ v-slot="{ selected }"
>
<button
+ :class="[
+ selected
+ ? 'bg-green-100 text-green-700' // 選択時のスタイル
+ : 'bg-green-100 text-green-500 opacity-50 hover:opacity-70', // 非選択時のスタイル
+ ]"
+ class="py-[6px] px-[24px] rounded-t-[4px] font-bold" // 共通のスタイル
>
{{ category }}
</button>
</Tab>
// ...
後はその他のスタイルをガシガシと書いていけば完成です。
Transitionコンポーネントを利用したDialog UI
次に以下のようなDialog UIをアニメーションあり/なしのパターンで作成してみます(分かりづらいですが、左のボタンがアニメーションなし、右のボタンがアニメーションありのパターンです)。
Dialog(アニメーションなし)
新規にVueコンポーネントを作成して必要なHeadless UIコンポーネントを読み込みます。Dialog
DialogOverlay
DialogTitle
DialogDescription
コンポーネントを組み合わせて使用します。
Dialog
のopen
プロパティにtrue
が渡ってくるとDialogOverlay
とその他の構成要素が表示される仕組みになっています。
Tab UIの時と同様にこの時点では見た目のスタイルは何もあたっていない状態です。
<template>
<Dialog :open="isOpen" @close="setIsOpen">
<!-- オーバレイ部分 -->
<DialogOverlay />
<!-- モーダルウィンドウ部分 -->
<DialogTitle>Deactivate account</DialogTitle>
<DialogDescription>Description</DialogDescription>
<p>This is simple modal component sample.</p>
<div>
<button @click="setIsOpen(false)">OK</button>
<button @click="setIsOpen(false)">Cancel</button>
</div>
</Dialog>
</template>
次に、開閉状態をリアクティブな値(isOpen
)として持たせて、外側のボタンからダイアログを表示させるように実装しました。
<template>
+ <button type="button" @click="setIsOpen(true)">Open</button>
<Dialog>
<!-- 省略 -->
<Dialog />
</template>
<script lang="ts">
+ import { defineComponent, ref } from 'vue';
import { Dialog, DialogOverlay, DialogTitle, DialogDescription } from '@headlessui/vue';
export default {
components: { Dialog, DialogOverlay, DialogTitle, DialogDescription },
+ setup() {
+ const isOpen = ref(true);
+ const setIsOpen = (value: boolean) => {
+ isOpen.value = value;
+ };
+ return {
+ isOpen,
+ setIsOpen,
+ };
+ },
};
</script>
後はその他のスタイルをガシガシと書いていけば完成です。
Dialog(Transitionコンポーネントでアニメーション)
Headless UIにTransitionコンポーネントが別途用意されているので、そちらを利用して先程のDialogにアニメーションをつけてみます。
TransitionコンポーネントはTransitionRoot
とTransitionChild
の2つのコンポーネントを組み合わせて使用するので、こちらをインポートします。
<script lang="ts">
import { defineComponent, ref } from 'vue';
- import { Dialog, DialogOverlay, DialogTitle, DialogDescription } from '@headlessui/vue';
+ import {
+ Dialog, DialogOverlay, DialogTitle, DialogDescription,
+ TransitionRoot, TransitionChild
+ } from '@headlessui/vue';
// ...
</script>
単純にひとつの要素に対してアニメーションさせる場合はTransitionRoot
のみで実装可能です。TransitionRoot
内にアニメーションさせたい要素を配置するだけで実装できます。
<TransitionRoot
:show="isShowing"
enter="transition-opacity duration-75"
enter-from="opacity-0"
enter-to="opacity-100"
leave="transition-opacity duration-150"
leave-from="opacity-100"
leave-to="opacity-0"
>
// アニメーションさせる要素
</TransitionRoot>
上記の例の場合以下のようなアニメーションとなります。
- 表示するときのアニメーション
enter opacityのアニメーションを75msかけて行う
enterFrom 遷移前はopacity: 0
enterTo 遷移後はopacity: 100 - 非表示にするときのアニメーション
leave opacityのアニメーションを150msかけて行う
leaveFrom 遷移前はopacity: 100
leaveTo 遷移後はopacity: 0
複数の異なるアニメーションを実装する場合にTransitionChild
が利用できます。
今回はオーバーレイ用のDialogOverlay
とDialogTitle
やDialogDescription
等で構成されているモーダルウィンドウ部分にそれぞれ別のアニメーションをさせるために、TransitionRoot
内に2つのTransitionChild
を配置して実装しています。アニメーションさせたい要素をTransitionChild
内にそれぞれ配置するだけです。
<template>
<!-- 省略 -->
+ <TransitionRoot :show="isOpen">
- <Dialog :open="isOpen" @close="setIsOpen">
+ <Dialog @close="setIsOpen">
<!-- オーバレイ部分 -->
+ <TransitionChild
+ enter="duration-300 ease-out"
+ enter-from="opacity-0"
+ leave="duration-200 ease-in"
+ leave-to="opacity-0"
+ >
<DialogOverlay />
+ </TransitionChild>
<!-- モーダルウィンドウ部分 -->
+ <TransitionChild
+ enter="duration-300 ease-out"
+ enter-from="opacity-0 scale-0"
+ enter-to="opacity-100 scale-100"
+ leave="duration-200 ease-in"
+ leave-from="opacity-100 scale-100"
+ leave-to="opacity-0 scale-0"
+ >
<DialogTitle>Title </DialogTitle>
<DialogDescription> Description </DialogDescription>
<p>This is simple modal component sample.</p>
<div>
<button @click="setIsOpen(false)">OK</button>
<button @click="setIsOpen(false)">Cancel</button>
</div>
+ </TransitionChild>
</Dialog>
</TransitionRoot>
</template>
これで表示/非表示の切り替え時にオーバーレイ部分にはopacity
のアニメーション、ウィンドウ部分にはopacity
とscale
のアニメーションがそれぞれ実装できました。
後はその他のスタイルをガシガシと書いていけば完成です。
Vueのビルトインコンポーネントを利用したDisclosure UIのアニメーション
最後に以下のようなDisclosure UI(アコーディオン)をこちらもアニメーションあり/なしで作成しました(分かりづらいですが、右側が開閉時に横移動のアニメーションを付与したものです)。
基本的な実装の流れは今までとあまり変わらないので省略します。
先程のDialog UIのアニメーションにはHeadless UIのTransition
コンポーネントを利用しましたが、こちらのDisclosure UIはVue.jsビルトインのTransition
コンポーネントで実装しています(少しややこしいですが...)。Tailwind CSSを利用しているので、カスタムトランジションクラスで記述しました。
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="transform -translate-x-3 opacity-0"
enter-to-class="transform translate-x-0 opacity-100"
leave-active-class="transition duration-300 ease-out"
leave-from-class="transform translate-x-0 opacity-100"
leave-to-class="transform -translate-x-3 opacity-0"
>
<DisclosurePanel>
{{ data.answer }}
</DisclosurePanel>
</transition>
Headless UIのTransition
コンポーネントを最初に見たときに、なんかこれ知ってるな...と思ったのですがVue.jsのビルトインコンポーネントと似たような機能となっていて、こちらも利用できるようになっています。
Vue.jsを使用している場合には、基本的にビルトインコンポーネントを利用して良いようです。ただし、先程のDialog UIのように複数の異なるアニメーションを制御する場合にHeadless UIのTransitionRoot
とTransitionChild
を組み合わせて実装すると便利だということのようです。
ガシガシと書いてDisclosure UIも完成しました。
おわりに
今まで、アクセシビリティに関してあまり学習できていなかったのですが、Headless UIを利用するとライブラリ側でその部分を担保してくるので安心感があるなと感じました。また、今後アクセシビリティを学習していく際にもとても参考になるのではないかと感じています。自分でコンポーネント開発等をする際にも役立てていけたら良いなと思っています。
参考
-
https://headlessui.dev/
公式ドキュメント。サイト自体がとても綺麗に作られていて好きです。 -
https://www.codegrid.net/series/2021-headless-ui
Headless UIの何がよいのか等かなり納得感のある形で解説されています。Reactを使用しての解説ですが、汎用的な内容で書かれているのでVueユーザーにもおすすめです。
Discussion