Vue3.5からのuseIdでアクセシブルな汎用コンポーネントを作ってみよう


このスライドは[UV Study : Vue.js LT会 ~業務で活きる実践的なVue~](https://uniquevision.connpass.com/event/343955/)での発表資料です。

---

## 自己紹介

---

### 大山奥人（Okuto Oyama）

<img src="https://i.gyazo.com/7dd64c107ef628cfb259a65860a87ceb.png" alt="マイクを持って発表している大山奥人" width="240" height="240">

- [株式会社Schoo](https://corp.schoo.jp/)
  - 2024年6月入学
  - フロントエンドエンジニア
- 一児の父 & 🐱🐱🐱🐶
- yamanoku
  - [X](https://x.com/yamanoku), [Hollo](https://hollo.yamanoku.net/@yamanoku), [GitHub](https://github.com/yamanoku)

---

## 今日は<br>[Vue Fes Japan 2024](https://vuefes.jp/2024/)で<br>落選したプロポーザルの<br>供養に来ました

---

## 突然ですが質問です

---

## アプリケーション内で<br>ID属性をすべて一意で<br>管理できる自信、ありますか？

---

たとえばこんなコンポーネントを作ったとします。

```vue
<script setup lang="ts">
const props = defineProps<{
  label: string
}>();
</script>

<template>
  <div>
    <label for="form-control">
      {{ props.label }}
    </label>
    <input id="form-control" type="text" />
  </div>
</template>
```
---

## これ、複数で使うと<br>どうなるんだっけ？

---

```vue
<FormTextControl label="姓" />
<FormTextControl label="名" />
```

↓

```html
<div>
    <label for="form-control">姓</label>
    <input id="form-control" type="text">
</div>
<div>
    <label for="form-control">名</label>
    <input id="form-control" type="text">
</div>
```

---

## IDが一意じゃない！🤯

---

### なぜ一意じゃないといけない？

> HTML要素で指定される場合、id属性値は、要素のツリーですべてのIDに共通して一意でなければならず、かつ少なくとも1つの文字を含まなければならない。値は一切のASCII空白文字を含んではならない。
[HTML Living Standard 3.2.6 グローバル属性](https://momdo.github.io/html/dom.html#global-attributes:~:text=HTML%E8%A6%81%E7%B4%A0%E3%81%A7%E6%8C%87%E5%AE%9A%E3%81%95%E3%82%8C%E3%82%8B%E5%A0%B4%E5%90%88%E3%80%81id%E5%B1%9E%E6%80%A7%E5%80%A4%E3%81%AF%E3%80%81%E8%A6%81%E7%B4%A0%E3%81%AE%E3%83%84%E3%83%AA%E3%83%BC%E3%81%A7%E3%81%99%E3%81%B9%E3%81%A6%E3%81%AEID%E3%81%AB%E5%85%B1%E9%80%9A%E3%81%97%E3%81%A6%E4%B8%80%E6%84%8F%E3%81%A7%E3%81%AA%E3%81%91%E3%82%8C%E3%81%B0%E3%81%AA%E3%82%89%E3%81%9A%E3%80%81%E3%81%8B%E3%81%A4%E5%B0%91%E3%81%AA%E3%81%8F%E3%81%A8%E3%82%821%E3%81%A4%E3%81%AE%E6%96%87%E5%AD%97%E3%82%92%E5%90%AB%E3%81%BE%E3%81%AA%E3%81%91%E3%82%8C%E3%81%B0%E3%81%AA%E3%82%89%E3%81%AA%E3%81%84%E3%80%82%E5%80%A4%E3%81%AF%E4%B8%80%E5%88%87%E3%81%AEASCII%E7%A9%BA%E7%99%BD%E6%96%87%E5%AD%97%E3%82%92%E5%90%AB%E3%82%93%E3%81%A7%E3%81%AF%E3%81%AA%E3%82%89%E3%81%AA%E3%81%84%E3%80%82)

---

操作も期待したものではなくなってしまう…

<img src="https://i.gyazo.com/d965eeb71c9471e2effc427f9f6b48da.gif" alt="姓と名のテキストフィールドが存在しており、名のラベル部分をクリックすると姓のテキストフィールドにフォーカスが当たってしまう" width=640>

---

## 一意にするための<br>解決策を考えてみる

---

### [uuid](https://www.npmjs.com/package/uuid)を使用する

```vue
<script setup>
import { v4 as uuid } from "uuid";
const id = uuid();
// ex: 0523f051-09b4-49f5-a6e0-92fa62ead546
</script>
```

- 基本衝突することはない
- ただしライブラリ依存になってしまう

---

### [Web Crypto API](https://developer.mozilla.org/ja/docs/Web/API/Web_Crypto_API)を使用する

```vue
<script setup>
const id = crypto.randomUUID();
</script>
```

- ライブラリ依存ではなくなる
  - Node.js > 19から使える
- ただしSSR時、ハイドレーションミスマッチとなる可能性あり
    - uuidのときも同様

---

## Vue.js公式APIの [`useId`](https://ja.vuejs.org/api/composition-api-helpers#useid)

- Vue3.5から追加された
- 一意のIDを生成してくれる
- SSRでも安全に使用できる
- 似た仕組みをもつUIライブラリ
    - [React](https://ja.react.dev/reference/react/useId)
    - [Preact](https://preactjs.com/guide/v10/hooks/?lang=en#useid)
    - [SolidJS](https://docs.solidjs.com/reference/component-apis/create-unique-id)

---

## 使い方

```diff:vue
<script setup lang="ts">
+ import { useId } from 'vue';
+ const formId = useId();
const props = defineProps<{
  label: string
}>();
</script>

<template>
  <div>
-    <label for="form-control">{{ props.label }}</label>
-    <input id="form-control" type="text" />
+    <label :for="formId">{{ props.label }}</label>
+    <input :id="formId" type="text" />
  </div>
</template>
```

---

```vue
<FormTextControl label="姓" />
<FormTextControl label="名" />
```

↓

```html
<div>
  <label for="v-0">姓</label>
  <input id="v-0" type="text">
</div>
<div>
  <label for="v-1">名</label>
  <input id="v-1" type="text">
</div>
```

---

## IDが一意になった :thumbsup_tone2:

- `useId`指定すればアプリケーション全体で被らない！
- ハイドレーションミスマッチもなくなる！
- `app.config.idPrefix`でプレフィックスの変更が可能！
  - `app.config.idPrefix = 'my-app'`
  - `my-app-1`のように生成される

---

## Nuxtでも使用できた`useId`

- [Nuxt 3.10](https://nuxt.com/blog/v3-10#ssr-safe-accessible-unique-id-creation)から登場（Vue.jsよりも先）
- Vue3.5から`useId`が登場してから[内部処理がVue.jsのものに置き換わった](https://github.com/nuxt/nuxt/pull/28285)

---

## ID属性は<br>アクセシビリティを向上させる<br>UIコンポーネント作成に<br>活用できる

---

## 様々なパターンで<br>`useId`を適応してみる

---

:::note warn
これから提示するパターンはあくまでも仕様に沿った例であり、具体的な操作に関する部分は省略しています。
実際に使用する際はブラウザ上でアクセシビリティチェックをして問題なく期待した動きになるかを確認してください。
:::

---

### フォームの詳細な説明

```vue
<script setup lang="ts">
import { useId } from 'vue';
const fromId = useId();
const hintId = useId();
const props = defineProps<{
  label: string,
  hintMessage: string,
}>();
</script>

<template>
  <div>
    <label :for="id">
      {{ props.label }}
    </label>
    <input 
      :id="id"
      type="text"
      :aria-describedby="hintId"
    />
    <p :id="hintId">
      {{ props.hintMessage }}
    </p>
  </div>
</template>
```

入力するフォームのヒント、例えばパスワードでは何文字以上・半角英数字込みなのかなどの情報を載せることがあるので、それと関連付けさせる。

---

### タブUIでのタブとタブパネル紐づけ

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

import Tab from './Tab.vue';
import TabPanel from './TabPanel.vue';

defineProps<{
  tabs: { title: string; content: string }[];
}>();

const tabId = useId();
const tabPanelId = useId();
</script>

<template>
  <div>
    <Tab
      v-for="(tab, index) in tabs"
      :id="`${tabId}-${index}`"
      :key="tab.title"
      :aria-controls="`${tabPanelId}-${index}`"
    >
      {{ tab.title }}
    </Tab>
  </div>
  <div>
    <TabPanel
      v-for="(tab, index) in tabs"
      :id="`${tabPanelId}-${index}`"
      :key="tab.content"
      :aria-labelledby="`${tabId}-${index}`"
    >
      <div>{{ tab.content }}</div>
    </TabPanel>
  </div>
</template>
```

現在表示されているタブパネルの内容が操作するタブと紐づいているかを関連付ける。
※TabとTabPanelの実装については省略

---

### モーダルダイアログの説明

```vue
<script setup lang="ts">
import { useId } from 'vue';
const modalHeadlineId = useId();
const modalDescriptionId = useId();
const props = defineProps<{
  headline: string,
  description: string,
}>();
</script>

<template>
  <dialog
    :id="modalId"
    :aria-labelledby="modalHeadlineId"
    :aria-describedby="modalDescriptionId"
  >
    <div>
      <h2 :id="modalHeadlineId">
        {{ props.headline }}
    　</h2>
      <p :id="modalDescriptionId">
        {{ props.description }}
      </p>
    </div>
  </dialog>
</template>
```

どういったモーダルダイアログが表示されているかを説明するために紐付ける。
詳細な説明文がある場合は`aria-describedby`で関連させる。

---

## UIライブラリでの`useId`活用

- [NuxtUI](https://github.com/nuxt/ui/pull/1211)
- [Vuetify](https://github.com/vuetifyjs/vuetify/commit/cbd14a7ace7e210fab91b4b2003ff0bcd7892d32)
- [RadixVue](https://github.com/unovue/radix-vue/blob/3c66cb5d9c7e70dd1815fc70d3974212989773b8/packages/core/src/shared/useId.ts#L21-L23)

:::note
ライブラリによっては独自の`useId`を実装していたが、Vue.jsの`useId`を使用するように非推奨にしていたりします。
> - [useId | Oku Primitives](https://primitives.oku-ui.com/utilities/use-id#useid)
> - [useId | Reka UI](https://reka-ui.com/docs/utilities/use-id#usage)
> - [PrimeVue](https://github.com/primefaces/primevue/commit/566613be9f49b834bb3ecd64efe59a7565005ce0)
:::

---

## まとめ

- `useId`を使うと一意のIDが出力される :tada: 
- アクセシブルなUIを作るために有用 :muscle_tone2: 
- SSRフレンドリーなのでぜひ活用してみてね :raised_hands_tone2: