🦩

Headless UI + Vue3で作るUIコンポーネント

2021/09/12に公開

はじめに

Headless UIはTailwind CSSを開発しているTailwind Labsを中心に開発されているUIライブラリです。特徴として、スタイルは実装者側で自由に制御できること、アクセシビリティはライブラリ側で確保してくれること等が上げられます。React用とVue.js用がそれぞれ用意されていますが、今回はVue.jsでUIコンポーネントを作成してみました。
全体のコードは以下のリポジトリに公開しています。
https://github.com/K-shigehito/headlessui-example

導入

Headless UIのドキュメントですが、サンプルも豊富でかなり分かりやすく書かれています。
https://headlessui.dev/
ReactとVueのドキュメントをタブで切り替えて表示することができます。現時点(2021.09.11)で以下のようなコンポーネントが利用できます(どれもWEBでよくみるUIではないかと思います)。

環境構築

今回の主な使用技術は以下となっています。

Headless UIがサポートしているVue.jsのバージョンは、現時点(2021.09.11)でVue3のみとなっています。開発ツールは高速な開発サーバーを提供してくれるViteを利用しました。CSSは必ずしもTailwind CSSを使用する必要はありませんが、Headless UIと相性がよいと公式で推奨されています。
以下を参照しながら環境構築をしました。

https://ja.vitejs.dev/guide/#最初の-vite-プロジェクトを生成する
https://tailwindcss.com/docs/guides/vue-3-vite

  • 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.jspostcss.config.jsが生成されます。

  • tailwind.config.jspurge設定を書き換える
tailwind.config.js
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から読み込む
src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
src/main.ts
  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のコンポーネントを読み込みます。

BasicTabs.vue
<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で使用します。

BasicTabs.vue
<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で選択要素をアクティブにしたりできるようになっています。

https://www.w3.org/TR/wai-aria-practices-1.2/#tabpanel
https://developer.mozilla.org/ja/docs/Web/Accessibility/ARIA/Roles/Tab_Role

このように、「アクセシビリティやUIの振る舞いはこちらで担保するから、見た目に関する部分のスタイルは思う存分やってくれ」というのがHeadless UIの考え方なのかなと思います。

データの定義

表示するデータはsetup内で定義してTabTabPanelでそれぞれリストレンダリングしています(Options APIでも記述できます)。

BasicTabs.vue
<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の値を変更しています。

https://jp.vuejs.org/v2/guide/components-slots.html#スコープ付きスロット

BasicTabs.vue
// ...
 <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>
// ...

後はその他のスタイルをガシガシと書いていけば完成です。
https://github.com/K-shigehito/headlessui-example/blob/main/src/components/BasicTabs.vue

Transitionコンポーネントを利用したDialog UI

次に以下のようなDialog UIをアニメーションあり/なしのパターンで作成してみます(分かりづらいですが、左のボタンがアニメーションなし、右のボタンがアニメーションありのパターンです)。

Dialog(アニメーションなし)

新規にVueコンポーネントを作成して必要なHeadless UIコンポーネントを読み込みます。Dialog DialogOverlay DialogTitle DialogDescriptionコンポーネントを組み合わせて使用します。
Dialogopenプロパティにtrueが渡ってくるとDialogOverlayとその他の構成要素が表示される仕組みになっています。
Tab UIの時と同様にこの時点では見た目のスタイルは何もあたっていない状態です。

BasicDialog.vue
<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)として持たせて、外側のボタンからダイアログを表示させるように実装しました。

BasicDialog.vue
  <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>

後はその他のスタイルをガシガシと書いていけば完成です。
https://github.com/K-shigehito/headlessui-example/blob/main/src/components/BasicDialog.vue

Dialog(Transitionコンポーネントでアニメーション)

Headless UIにTransitionコンポーネントが別途用意されているので、そちらを利用して先程のDialogにアニメーションをつけてみます。

TransitionコンポーネントはTransitionRootTransitionChildの2つのコンポーネントを組み合わせて使用するので、こちらをインポートします。

BasicDialog.vue
 <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が利用できます。
今回はオーバーレイ用のDialogOverlayDialogTitleDialogDescription等で構成されているモーダルウィンドウ部分にそれぞれ別のアニメーションをさせるために、TransitionRoot内に2つのTransitionChildを配置して実装しています。アニメーションさせたい要素をTransitionChild内にそれぞれ配置するだけです。

BasicDialog.vue
 <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のアニメーション、ウィンドウ部分にはopacityscaleのアニメーションがそれぞれ実装できました。

後はその他のスタイルをガシガシと書いていけば完成です。
https://github.com/K-shigehito/headlessui-example/blob/main/src/components/TransitionDialog.vue

Vueのビルトインコンポーネントを利用したDisclosure UIのアニメーション

最後に以下のようなDisclosure UI(アコーディオン)をこちらもアニメーションあり/なしで作成しました(分かりづらいですが、右側が開閉時に横移動のアニメーションを付与したものです)。

基本的な実装の流れは今までとあまり変わらないので省略します。
先程のDialog UIのアニメーションにはHeadless UIのTransitionコンポーネントを利用しましたが、こちらのDisclosure UIはVue.jsビルトインのTransitionコンポーネントで実装しています(少しややこしいですが...)。Tailwind CSSを利用しているので、カスタムトランジションクラスで記述しました。
https://jp.vuejs.org/v2/guide/transitions.html#カスタムトランジションクラス

TransitionDisclosure.vue
<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のTransitionRootTransitionChildを組み合わせて実装すると便利だということのようです。

ガシガシと書いてDisclosure UIも完成しました。
https://github.com/K-shigehito/headlessui-example/blob/main/src/components/TransitionDisclosure.vue

おわりに

今まで、アクセシビリティに関してあまり学習できていなかったのですが、Headless UIを利用するとライブラリ側でその部分を担保してくるので安心感があるなと感じました。また、今後アクセシビリティを学習していく際にもとても参考になるのではないかと感じています。自分でコンポーネント開発等をする際にも役立てていけたら良いなと思っています。

参考

  • https://headlessui.dev/
    公式ドキュメント。サイト自体がとても綺麗に作られていて好きです。
  • https://www.codegrid.net/series/2021-headless-ui
    Headless UIの何がよいのか等かなり納得感のある形で解説されています。Reactを使用しての解説ですが、汎用的な内容で書かれているのでVueユーザーにもおすすめです。
GitHubで編集を提案

Discussion