Vue.js でのアクセシビリティ対応・改善の実践法

はじめに
現代のWebアプリケーション開発において、アクセシビリティ対応は必要不可欠な要素となっています。特にVue.jsのようなモダンフレームワークを使用したSPA(Single Page Application)では、従来のWebサイトとは異なる課題やアプローチが求められます。
この記事では、Vue.jsアプリケーションでアクセシビリティを向上させる具体的な方法を、基礎知識から実践的な実装まで段階的に解説いたします。すべてのユーザーが快適に利用できるWebアプリケーションを作成するための実用的なガイドとして、ぜひお役立てください。
背景
アクセシビリティとは何か
アクセシビリティとは、障害の有無や年齢、技術的なスキルレベルに関わらず、すべてのユーザーがWebサイトやアプリケーションを等しく利用できることを指します。
具体的には以下のようなユーザーを含む、多様な利用者への配慮が必要です。
ユーザータイプ | 必要な配慮 | 技術的対応 |
---|---|---|
視覚障害者 | スクリーンリーダーによる音声読み上げ | セマンティックHTML、ARIA属性 |
聴覚障害者 | 音声情報の視覚的代替手段 | 字幕、視覚的フィードバック |
運動機能障害者 | キーボードのみでの操作 | フォーカス管理、キーボードナビゲーション |
認知障害者 | 分かりやすいインターフェース | 明確なラベル、一貫したUI |
アクセシビリティ対応は法的義務でもあります。日本では「障害者差別解消法」、アメリカでは「Americans with Disabilities Act(ADA)」により、公的機関や企業にアクセシブルなWebサービスの提供が求められています。
Vue.jsアプリケーションにおけるアクセシビリティの重要性
Vue.jsで構築されたアプリケーションは、従来の静的Webサイトとは異なる特性を持ちます。
mermaidflowchart TD
A[従来のWebサイト] --> B[ページ単位の遷移]
A --> C[サーバーサイドレンダリング]
A --> D[静的なDOM構造]
E[Vue.js SPA] --> F[動的なコンテンツ更新]
E --> G[クライアントサイドルーティング]
E --> H[リアクティブなDOM操作]
F --> I[スクリーンリーダーへの通知が困難]
G --> J[ページ遷移の認識が困難]
H --> K[フォーカス管理の複雑化]
上図のように、Vue.jsアプリケーションでは動的な変更が頻繁に発生するため、従来のアクセシビリティ対応手法だけでは不十分です。
Vue.jsでアクセシビリティが重要な理由は以下の通りです。
- コンポーネントベース設計: 再利用可能なアクセシブルコンポーネントを一度作成すれば、アプリケーション全体の品質が向上します
- リアクティブシステム: データの変更に応じて適切にスクリーンリーダーに情報を伝達する必要があります
- 開発効率: 初期段階からアクセシビリティを組み込むことで、後からの修正コストを大幅に削減できます
Web Content Accessibility Guidelines (WCAG) 2.1の基本原則
WCAG 2.1は、Webアクセシビリティの国際標準ガイドラインです。4つの基本原則に基づいて構成されています。
mermaidflowchart LR
A[WCAG 2.1] --> B[知覚可能<br/>Perceivable]
A --> C[操作可能<br/>Operable]
A --> D[理解可能<br/>Understandable]
A --> E[堅牢<br/>Robust]
B --> B1[代替テキスト]
B --> B2[音声・映像の代替手段]
B --> B3[十分なコントラスト比]
C --> C1[キーボード操作対応]
C --> C2[適切なフォーカス管理]
C --> C3[十分な操作時間]
D --> D1[読みやすいテキスト]
D --> D2[予測可能な動作]
D --> D3[入力支援]
E --> E1[多様な支援技術への対応]
E --> E2[有効なマークアップ]
E --> E3[将来性の確保]
各原則をVue.jsアプリケーションで実装する際のポイントは以下の通りです。
知覚可能(Perceivable)
すべてのユーザーが情報を知覚できるようにします。
typescript// 代替テキストの実装例
<template>
<img
:src="product.image"
:alt="product.description"
:aria-describedby="product.id + '-details'"
/>
<div :id="product.id + '-details'">
{{ product.longDescription }}
</div>
</template>
操作可能(Operable)
すべてのユーザーがインターフェースを操作できるようにします。
typescript// キーボード操作対応の実装例
<template>
<button
@click="handleClick"
@keydown.enter="handleClick"
@keydown.space.prevent="handleClick"
:aria-pressed="isPressed"
>
{{ buttonText }}
</button>
</template>
課題
Vue.jsアプリケーションで発生しがちなアクセシビリティ問題
Vue.jsアプリケーションでよく発生するアクセシビリティ問題を、具体的なコード例とともに見ていきましょう。
問題1: セマンティクスの不適切な使用
javascript// 問題のあるコード例
<template>
<div @click="handleSubmit" class="button-like">
送信する
</div>
<div class="heading-like">
{{ title }}
</div>
</template>
上記のコードでは、ボタンの機能を持つ要素にdiv
要素を使用し、見出しにもセマンティクスのない要素を使用しています。
問題2: ARIA属性の不適切な実装
javascript// 問題のあるコード例
<template>
<div role="button" @click="toggle">
<span aria-expanded="true">{{ isOpen ? '▼' : '▶' }}</span>
メニュー
</div>
</template>
この例では、aria-expanded
が動的に更新されておらず、常にtrue
のままになっています。
問題3: フォーカス管理の不備
javascript// 問題のあるコード例
<template>
<div v-if="showModal" class="modal">
<div class="modal-content">
<input type="text" />
<button @click="closeModal">閉じる</button>
</div>
</div>
</template>
<script>
export default {
methods: {
openModal() {
this.showModal = true;
// フォーカス管理が不適切
},
closeModal() {
this.showModal = false;
// 元の要素にフォーカスが戻らない
}
}
}
</script>
図で理解できる要点:
- セマンティックHTML要素の重要性
- ARIA属性の動的更新の必要性
- フォーカス管理の複雑性
SPAならではのアクセシビリティ課題
Single Page Applicationでは、従来のWebサイトにはない特有の課題があります。
mermaidsequenceDiagram
participant User as ユーザー
participant SR as スクリーンリーダー
participant App as Vue.js App
participant Router as Vue Router
User->>App: ナビゲーションリンクをクリック
App->>Router: ルート変更
Router->>App: コンテンツ更新
Note over SR: ページ変更が認識されない
SR-->>User: 変更の通知なし
上図のように、SPAでは以下の課題が発生します。
課題1: ページ遷移の認識困難
javascript// 問題のあるルート遷移
// ページが変更されてもスクリーンリーダーに通知されない
<router-link to="/products">商品一覧</router-link>
課題2: 動的コンテンツ更新の通知不足
javascript// 問題のある動的更新
<template>
<div>
<button @click="loadMore">もっと読み込む</button>
<div v-for="item in items" :key="item.id">
{{ item.title }}
</div>
</div>
</template>
<script>
export default {
methods: {
async loadMore() {
const newItems = await this.fetchMoreItems();
this.items.push(...newItems);
// 新しいコンテンツの追加がスクリーンリーダーに通知されない
}
}
}
</script>
課題3: 状態管理とアクセシビリティの同期
javascript// Vuexでの状態変更がアクセシビリティ情報に反映されない例
const store = new Vuex.Store({
state: {
loading: false,
error: null
},
mutations: {
SET_LOADING(state, loading) {
state.loading = loading;
// aria-busy や aria-live への反映が不十分
}
}
});
コンポーネント設計時のアクセシビリティ考慮不足
Vue.jsのコンポーネント設計では、以下の点でアクセシビリティの考慮が不足しがちです。
課題1: プロップスでのアクセシビリティ情報の不備
typescript// 不十分なプロップス設計
<script lang="ts">
interface ButtonProps {
text: string;
onClick: () => void;
// アクセシビリティ関連のプロップスが不足
}
</script>
課題2: イベント処理の不完全性
javascript// キーボードイベントの考慮不足
<template>
<div @click="handleClick" class="interactive-element">
クリックできる要素
</div>
</template>
課題3: スロットでのアクセシビリティコンテキストの欠如
javascript// スロットでのアクセシビリティ情報の伝達不足
<template>
<div role="tablist">
<slot></slot>
</div>
</template>
図で理解できる要点:
- SPA特有のページ遷移通知課題
- 動的コンテンツ更新の認識問題
- コンポーネント間でのアクセシビリティ情報共有の重要性
解決策
Vue.jsでのセマンティックHTML活用法
適切なセマンティックHTMLの使用は、アクセシビリティ向上の基礎となります。Vue.jsでの実装方法を見ていきましょう。
基本的なセマンティック要素の活用
vue<template>
<article class="blog-post">
<header>
<h1>{{ post.title }}</h1>
<time :datetime="post.publishedAt">
{{ formatDate(post.publishedAt) }}
</time>
</header>
<main>
<section v-for="section in post.sections" :key="section.id">
<h2>{{ section.title }}</h2>
<p>{{ section.content }}</p>
</section>
</main>
<footer>
<nav aria-label="記事のナビゲーション">
<a :href="prevPost.url" rel="prev">前の記事</a>
<a :href="nextPost.url" rel="next">次の記事</a>
</nav>
</footer>
</article>
</template>
<script>
export default {
name: 'BlogPost',
props: {
post: {
type: Object,
required: true
},
prevPost: Object,
nextPost: Object
},
methods: {
formatDate(dateString) {
return new Date(dateString).toLocaleDateString('ja-JP');
}
}
}
</script>
この実装では、article
、header
、main
、section
、footer
、nav
といったセマンティック要素を適切に使用しています。
フォーム要素でのセマンティック実装
vue<template>
<form @submit.prevent="handleSubmit" novalidate>
<fieldset>
<legend>ユーザー登録情報</legend>
<div class="form-group">
<label for="username">ユーザー名</label>
<input
id="username"
v-model="form.username"
type="text"
:aria-invalid="errors.username ? 'true' : 'false'"
:aria-describedby="errors.username ? 'username-error' : undefined"
required
/>
<div
v-if="errors.username"
id="username-error"
role="alert"
class="error-message"
>
{{ errors.username }}
</div>
</div>
<div class="form-group">
<label for="email">メールアドレス</label>
<input
id="email"
v-model="form.email"
type="email"
:aria-invalid="errors.email ? 'true' : 'false'"
:aria-describedby="errors.email ? 'email-error email-hint' : 'email-hint'"
required
/>
<div id="email-hint" class="hint">
有効なメールアドレスを入力してください
</div>
<div
v-if="errors.email"
id="email-error"
role="alert"
class="error-message"
>
{{ errors.email }}
</div>
</div>
</fieldset>
<button type="submit" :disabled="isSubmitting">
<span v-if="isSubmitting">送信中...</span>
<span v-else>登録する</span>
</button>
</form>
</template>
この例では、fieldset
とlegend
でフォームをグループ化し、label
要素で適切にラベル付けしています。
リスト構造の適切な実装
vue<template>
<section aria-labelledby="product-list-title">
<h2 id="product-list-title">商品一覧</h2>
<ul class="product-list">
<li v-for="product in products" :key="product.id" class="product-item">
<article>
<h3>
<a :href="`/products/${product.id}`">
{{ product.name }}
</a>
</h3>
<img
:src="product.image"
:alt="`${product.name}の商品画像`"
loading="lazy"
/>
<dl class="product-details">
<dt>価格</dt>
<dd>¥{{ product.price.toLocaleString() }}</dd>
<dt>在庫状況</dt>
<dd :class="product.inStock ? 'in-stock' : 'out-of-stock'">
{{ product.inStock ? '在庫あり' : '在庫切れ' }}
</dd>
</dl>
</article>
</li>
</ul>
</section>
</template>
ARIA属性の適切な実装方法
ARIA(Accessible Rich Internet Applications)属性は、HTMLの意味を補強し、支援技術により詳細な情報を提供します。
ライブリージョンでの動的更新通知
vue<template>
<div class="notification-system">
<!-- 重要な通知用 -->
<div
aria-live="assertive"
aria-atomic="true"
class="sr-only"
>
{{ urgentMessage }}
</div>
<!-- 一般的な通知用 -->
<div
aria-live="polite"
aria-atomic="false"
class="sr-only"
>
{{ statusMessage }}
</div>
<!-- 通知表示エリア -->
<div class="notification-display">
<div
v-for="notification in notifications"
:key="notification.id"
:class="['notification', notification.type]"
role="alert"
>
{{ notification.message }}
<button
@click="removeNotification(notification.id)"
:aria-label="`通知「${notification.message}」を削除`"
>
×
</button>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
notifications: [],
urgentMessage: '',
statusMessage: ''
}
},
methods: {
addNotification(message, type = 'info', urgent = false) {
const notification = {
id: Date.now(),
message,
type
};
this.notifications.push(notification);
// スクリーンリーダーへの通知
if (urgent) {
this.urgentMessage = message;
// 通知後にクリア
setTimeout(() => {
this.urgentMessage = '';
}, 100);
} else {
this.statusMessage = message;
setTimeout(() => {
this.statusMessage = '';
}, 100);
}
},
removeNotification(id) {
const index = this.notifications.findIndex(n => n.id === id);
if (index > -1) {
this.notifications.splice(index, 1);
this.statusMessage = '通知を削除しました';
setTimeout(() => {
this.statusMessage = '';
}, 100);
}
}
}
}
</script>
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
展開可能なコンテンツの実装
vue<template>
<div class="accordion">
<div
v-for="(item, index) in accordionItems"
:key="item.id"
class="accordion-item"
>
<h3>
<button
:id="`accordion-button-${index}`"
:aria-expanded="item.isOpen"
:aria-controls="`accordion-panel-${index}`"
@click="toggleItem(index)"
class="accordion-button"
>
{{ item.title }}
<span aria-hidden="true">{{ item.isOpen ? '▼' : '▶' }}</span>
</button>
</h3>
<div
:id="`accordion-panel-${index}`"
:aria-labelledby="`accordion-button-${index}`"
:hidden="!item.isOpen"
class="accordion-panel"
>
<div class="accordion-content">
{{ item.content }}
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
accordionItems: [
{
id: 1,
title: 'セクション1',
content: 'セクション1の内容です。',
isOpen: false
},
{
id: 2,
title: 'セクション2',
content: 'セクション2の内容です。',
isOpen: false
}
]
}
},
methods: {
toggleItem(index) {
this.accordionItems[index].isOpen = !this.accordionItems[index].isOpen;
}
}
}
</script>
フォーカス管理とキーボードナビゲーション
適切なフォーカス管理は、キーボードユーザーやスクリーンリーダーユーザーにとって非常に重要です。
モーダルダイアログでのフォーカストラップ
vue<template>
<div>
<button @click="openModal" ref="triggerButton">
モーダルを開く
</button>
<div
v-if="isModalOpen"
class="modal-overlay"
@click="closeModal"
@keydown.esc="closeModal"
>
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
@click.stop
ref="modal"
>
<header>
<h2 id="modal-title">{{ modalTitle }}</h2>
<button
@click="closeModal"
aria-label="モーダルを閉じる"
ref="closeButton"
>
×
</button>
</header>
<main>
<p id="modal-description">{{ modalDescription }}</p>
<form @submit.prevent="handleSubmit">
<label for="modal-input">入力項目</label>
<input
id="modal-input"
v-model="inputValue"
ref="firstFocusableElement"
/>
<div class="modal-actions">
<button type="button" @click="closeModal">
キャンセル
</button>
<button type="submit" ref="lastFocusableElement">
確定
</button>
</div>
</form>
</main>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
isModalOpen: false,
modalTitle: 'サンプルモーダル',
modalDescription: 'これはアクセシブルなモーダルダイアログの例です。',
inputValue: '',
previousFocusedElement: null
}
},
methods: {
openModal() {
// 現在フォーカスされている要素を記憶
this.previousFocusedElement = document.activeElement;
this.isModalOpen = true;
// 次のティックでフォーカスを設定
this.$nextTick(() => {
this.focusFirstElement();
this.setupFocusTrap();
});
},
closeModal() {
this.isModalOpen = false;
this.inputValue = '';
// 元の要素にフォーカスを戻す
this.$nextTick(() => {
if (this.previousFocusedElement) {
this.previousFocusedElement.focus();
}
});
},
focusFirstElement() {
if (this.$refs.firstFocusableElement) {
this.$refs.firstFocusableElement.focus();
}
},
setupFocusTrap() {
const modal = this.$refs.modal;
if (!modal) return;
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const handleTabKey = (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
modal.addEventListener('keydown', handleTabKey);
// コンポーネント破棄時にイベントリスナーを削除
this.$once('hook:beforeDestroy', () => {
modal.removeEventListener('keydown', handleTabKey);
});
},
handleSubmit() {
// フォーム送信処理
console.log('入力値:', this.inputValue);
this.closeModal();
}
}
}
</script>
カスタムキーボードナビゲーションの実装
vue<template>
<ul
class="custom-listbox"
role="listbox"
:aria-activedescendant="activeOption ? `option-${activeOption.id}` : undefined"
@keydown="handleKeyDown"
tabindex="0"
ref="listbox"
>
<li
v-for="(option, index) in options"
:key="option.id"
:id="`option-${option.id}`"
role="option"
:aria-selected="option.selected"
:class="[
'listbox-option',
{
'active': activeOptionIndex === index,
'selected': option.selected
}
]"
@click="selectOption(index)"
>
{{ option.label }}
</li>
</ul>
</template>
<script>
export default {
props: {
options: {
type: Array,
required: true
}
},
data() {
return {
activeOptionIndex: 0
}
},
computed: {
activeOption() {
return this.options[this.activeOptionIndex];
}
},
methods: {
handleKeyDown(event) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.moveNext();
break;
case 'ArrowUp':
event.preventDefault();
this.movePrevious();
break;
case 'Home':
event.preventDefault();
this.moveToFirst();
break;
case 'End':
event.preventDefault();
this.moveToLast();
break;
case 'Enter':
case ' ':
event.preventDefault();
this.selectOption(this.activeOptionIndex);
break;
}
},
moveNext() {
this.activeOptionIndex = Math.min(
this.activeOptionIndex + 1,
this.options.length - 1
);
},
movePrevious() {
this.activeOptionIndex = Math.max(this.activeOptionIndex - 1, 0);
},
moveToFirst() {
this.activeOptionIndex = 0;
},
moveToLast() {
this.activeOptionIndex = this.options.length - 1;
},
selectOption(index) {
this.$emit('option-selected', this.options[index]);
// 選択状態を更新
this.options.forEach((option, i) => {
option.selected = i === index;
});
}
},
mounted() {
// 初期フォーカス設定
if (this.$refs.listbox) {
this.$refs.listbox.focus();
}
}
}
</script>
スクリーンリーダー対応の実装
スクリーンリーダーユーザーに適切な情報を提供するための実装方法を説明します。
動的コンテンツの読み上げ対応
vue<template>
<div class="search-interface">
<!-- 検索フォーム -->
<form @submit.prevent="performSearch">
<label for="search-input">検索キーワード</label>
<input
id="search-input"
v-model="searchQuery"
type="text"
:aria-describedby="searchQuery ? 'search-count' : undefined"
@input="handleSearchInput"
/>
<button type="submit" :disabled="isSearching">
<span v-if="isSearching">検索中...</span>
<span v-else>検索</span>
</button>
</form>
<!-- 検索結果数の表示 -->
<div
id="search-count"
aria-live="polite"
aria-atomic="true"
>
<span v-if="searchResults.length > 0">
{{ searchResults.length }}件の結果が見つかりました
</span>
<span v-else-if="hasSearched && !isSearching">
検索結果が見つかりませんでした
</span>
</div>
<!-- 検索結果 -->
<section
v-if="searchResults.length > 0"
aria-labelledby="results-heading"
aria-busy="false"
>
<h2 id="results-heading">検索結果</h2>
<ul class="search-results">
<li
v-for="(result, index) in searchResults"
:key="result.id"
class="search-result-item"
>
<article>
<h3>
<a :href="result.url">{{ result.title }}</a>
</h3>
<p>{{ result.description }}</p>
<div class="result-meta">
<span>{{ formatDate(result.date) }}</span>
<span aria-label="カテゴリ">{{ result.category }}</span>
</div>
</article>
</li>
</ul>
</section>
<!-- ローディング状態の表示 -->
<div
v-if="isSearching"
aria-live="assertive"
aria-busy="true"
class="loading-indicator"
>
検索中です。しばらくお待ちください。
</div>
</div>
</template>
<script>
export default {
data() {
return {
searchQuery: '',
searchResults: [],
isSearching: false,
hasSearched: false,
searchTimeout: null
}
},
methods: {
handleSearchInput() {
// デバウンス処理
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
if (this.searchQuery.trim()) {
this.performSearch();
}
}, 300);
},
async performSearch() {
if (!this.searchQuery.trim()) return;
this.isSearching = true;
this.hasSearched = true;
try {
// 模擬的な検索処理
await new Promise(resolve => setTimeout(resolve, 1000));
this.searchResults = [
{
id: 1,
title: 'サンプル記事1',
description: 'これはサンプルの検索結果です。',
url: '/article/1',
date: new Date(),
category: 'テクノロジー'
},
{
id: 2,
title: 'サンプル記事2',
description: '別のサンプル記事です。',
url: '/article/2',
date: new Date(),
category: 'デザイン'
}
];
} catch (error) {
this.searchResults = [];
// エラーハンドリング
} finally {
this.isSearching = false;
}
},
formatDate(date) {
return date.toLocaleDateString('ja-JP');
}
}
}
</script>
図で理解できる要点:
- セマンティックHTMLによる構造化の重要性
- ARIA属性での動的な状態管理
- フォーカストラップによるキーボードナビゲーション
- ライブリージョンでのスクリーンリーダー対応
具体例
フォームコンポーネントのアクセシビリティ改善
実際のフォームコンポーネントを段階的に改善していく例を見ていきましょう。
改善前のフォーム
vue<!-- 問題のあるフォーム例 -->
<template>
<div class="form-container">
<div class="form-title">ユーザー登録</div>
<div>
<span>名前</span>
<input v-model="form.name" />
<span v-if="errors.name" class="error">{{ errors.name }}</span>
</div>
<div>
<span>メールアドレス</span>
<input v-model="form.email" type="email" />
<span v-if="errors.email" class="error">{{ errors.email }}</span>
</div>
<div>
<span>パスワード</span>
<input v-model="form.password" type="password" />
<span v-if="errors.password" class="error">{{ errors.password }}</span>
</div>
<div @click="handleSubmit" class="submit-button">
登録する
</div>
</div>
</template>
上記のフォームには以下の問題があります:
- セマンティックな要素が使用されていない
- ラベルとフィールドの関連付けができていない
- エラーメッセージがスクリーンリーダーに適切に伝わらない
- キーボードで操作できない送信ボタン
改善後のアクセシブルフォーム
vue<template>
<form
@submit.prevent="handleSubmit"
novalidate
aria-describedby="form-description"
>
<fieldset>
<legend>ユーザー登録情報</legend>
<p id="form-description">
すべての項目を入力してください。必須項目には「必須」と表示されています。
</p>
<!-- 名前フィールド -->
<div class="form-group">
<label for="name" class="form-label">
名前
<span class="required" aria-label="必須項目">*</span>
</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-input"
:class="{ 'has-error': errors.name }"
:aria-invalid="errors.name ? 'true' : 'false'"
:aria-describedby="errors.name ? 'name-error name-hint' : 'name-hint'"
required
autocomplete="name"
/>
<div id="name-hint" class="form-hint">
フルネームを入力してください
</div>
<div
v-if="errors.name"
id="name-error"
class="form-error"
role="alert"
aria-live="polite"
>
<span aria-hidden="true">⚠</span>
{{ errors.name }}
</div>
</div>
<!-- メールアドレスフィールド -->
<div class="form-group">
<label for="email" class="form-label">
メールアドレス
<span class="required" aria-label="必須項目">*</span>
</label>
<input
id="email"
v-model="form.email"
type="email"
class="form-input"
:class="{ 'has-error': errors.email }"
:aria-invalid="errors.email ? 'true' : 'false'"
:aria-describedby="errors.email ? 'email-error email-hint' : 'email-hint'"
required
autocomplete="email"
/>
<div id="email-hint" class="form-hint">
例: user@example.com
</div>
<div
v-if="errors.email"
id="email-error"
class="form-error"
role="alert"
aria-live="polite"
>
<span aria-hidden="true">⚠</span>
{{ errors.email }}
</div>
</div>
<!-- パスワードフィールド -->
<div class="form-group">
<label for="password" class="form-label">
パスワード
<span class="required" aria-label="必須項目">*</span>
</label>
<div class="password-input-container">
<input
id="password"
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
class="form-input"
:class="{ 'has-error': errors.password }"
:aria-invalid="errors.password ? 'true' : 'false'"
:aria-describedby="errors.password ? 'password-error password-hint' : 'password-hint'"
required
autocomplete="new-password"
/>
<button
type="button"
@click="togglePasswordVisibility"
class="password-toggle"
:aria-label="showPassword ? 'パスワードを非表示にする' : 'パスワードを表示する'"
:aria-pressed="showPassword"
>
<span aria-hidden="true">{{ showPassword ? '🙈' : '👁' }}</span>
</button>
</div>
<div id="password-hint" class="form-hint">
8文字以上で、大文字・小文字・数字を含めてください
</div>
<div
v-if="errors.password"
id="password-error"
class="form-error"
role="alert"
aria-live="polite"
>
<span aria-hidden="true">⚠</span>
{{ errors.password }}
</div>
</div>
<!-- 利用規約チェックボックス -->
<div class="form-group">
<div class="checkbox-container">
<input
id="terms"
v-model="form.agreedToTerms"
type="checkbox"
class="form-checkbox"
:aria-invalid="errors.terms ? 'true' : 'false'"
:aria-describedby="errors.terms ? 'terms-error' : undefined"
required
/>
<label for="terms" class="checkbox-label">
<a href="/terms" target="_blank" rel="noopener">利用規約</a>に同意します
<span class="required" aria-label="必須項目">*</span>
</label>
</div>
<div
v-if="errors.terms"
id="terms-error"
class="form-error"
role="alert"
aria-live="polite"
>
<span aria-hidden="true">⚠</span>
{{ errors.terms }}
</div>
</div>
</fieldset>
<!-- 送信ボタン -->
<div class="form-actions">
<button
type="submit"
class="submit-button"
:disabled="isSubmitting"
:aria-describedby="isSubmitting ? 'submit-status' : undefined"
>
<span v-if="isSubmitting">登録中...</span>
<span v-else>登録する</span>
</button>
<div
v-if="isSubmitting"
id="submit-status"
aria-live="polite"
class="sr-only"
>
フォームを送信中です。しばらくお待ちください。
</div>
</div>
</form>
</template>
<script>
export default {
name: 'AccessibleRegistrationForm',
data() {
return {
form: {
name: '',
email: '',
password: '',
agreedToTerms: false
},
errors: {},
isSubmitting: false,
showPassword: false
}
},
methods: {
validateForm() {
const errors = {};
// 名前の検証
if (!this.form.name.trim()) {
errors.name = '名前は必須です';
} else if (this.form.name.length < 2) {
errors.name = '名前は2文字以上で入力してください';
}
// メールアドレスの検証
if (!this.form.email.trim()) {
errors.email = 'メールアドレスは必須です';
} else if (!this.isValidEmail(this.form.email)) {
errors.email = '有効なメールアドレスを入力してください';
}
// パスワードの検証
if (!this.form.password) {
errors.password = 'パスワードは必須です';
} else if (!this.isValidPassword(this.form.password)) {
errors.password = 'パスワードは8文字以上で、大文字・小文字・数字を含めてください';
}
// 利用規約の検証
if (!this.form.agreedToTerms) {
errors.terms = '利用規約への同意は必須です';
}
this.errors = errors;
return Object.keys(errors).length === 0;
},
isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
},
isValidPassword(password) {
const hasMinLength = password.length >= 8;
const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password);
const hasNumbers = /\d/.test(password);
return hasMinLength && hasUpperCase && hasLowerCase && hasNumbers;
},
togglePasswordVisibility() {
this.showPassword = !this.showPassword;
},
async handleSubmit() {
if (!this.validateForm()) {
// 最初のエラーフィールドにフォーカスを移動
this.focusFirstErrorField();
return;
}
this.isSubmitting = true;
try {
// 送信処理のシミュレーション
await new Promise(resolve => setTimeout(resolve, 2000));
// 成功通知
this.$emit('registration-success', this.form);
// フォームリセット
this.resetForm();
} catch (error) {
// エラーハンドリング
this.errors.submit = '登録に失敗しました。もう一度お試しください。';
} finally {
this.isSubmitting = false;
}
},
focusFirstErrorField() {
const errorFields = ['name', 'email', 'password', 'terms'];
for (const field of errorFields) {
if (this.errors[field]) {
const element = document.getElementById(field);
if (element) {
element.focus();
break;
}
}
}
},
resetForm() {
this.form = {
name: '',
email: '',
password: '',
agreedToTerms: false
};
this.errors = {};
this.showPassword = false;
}
}
}
</script>
この改善されたフォームの特徴:
mermaidflowchart TD
A[アクセシブルフォーム] --> B[セマンティック構造]
A --> C[ラベル関連付け]
A --> D[エラーハンドリング]
A --> E[キーボード操作]
B --> B1[form要素使用]
B --> B2[fieldset/legend]
B --> B3[適切なinput type]
C --> C1[label for属性]
C --> C2[aria-describedby]
C --> C3[必須項目表示]
D --> D1[role=alert]
D --> D2[aria-live]
D --> D3[aria-invalid]
E --> E1[Tab順序管理]
E --> E2[フォーカス表示]
E --> E3[エラー時フォーカス移動]
モーダルダイアログの実装例
アクセシブルなモーダルダイアログの完全な実装例です。
モーダルコンポーネント
vue<template>
<teleport to="body">
<div
v-if="isOpen"
class="modal-backdrop"
@click="handleBackdropClick"
@keydown.esc="close"
>
<div
ref="modal"
class="modal"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
:aria-describedby="descriptionId"
@click.stop
>
<!-- モーダルヘッダー -->
<header class="modal-header">
<h2 :id="titleId" class="modal-title">
{{ title }}
</h2>
<button
ref="closeButton"
@click="close"
class="modal-close"
aria-label="ダイアログを閉じる"
type="button"
>
<span aria-hidden="true">×</span>
</button>
</header>
<!-- モーダルコンテンツ -->
<main class="modal-body">
<p
v-if="description"
:id="descriptionId"
class="modal-description"
>
{{ description }}
</p>
<slot name="content"></slot>
</main>
<!-- モーダルフッター -->
<footer class="modal-footer">
<slot name="actions">
<button
@click="close"
type="button"
class="btn btn-secondary"
>
キャンセル
</button>
<button
@click="confirm"
type="button"
class="btn btn-primary"
ref="confirmButton"
>
確認
</button>
</slot>
</footer>
</div>
</div>
</teleport>
</template>
<script>
let modalIdCounter = 0;
export default {
name: 'AccessibleModal',
props: {
isOpen: {
type: Boolean,
default: false
},
title: {
type: String,
required: true
},
description: {
type: String,
default: ''
},
focusOnOpen: {
type: String,
default: 'first', // 'first', 'confirm', 'close'
validator: value => ['first', 'confirm', 'close'].includes(value)
},
closeOnBackdrop: {
type: Boolean,
default: true
}
},
data() {
return {
titleId: `modal-title-${++modalIdCounter}`,
descriptionId: `modal-description-${modalIdCounter}`,
previousActiveElement: null,
focusableElements: []
}
},
watch: {
isOpen: {
handler(newValue) {
if (newValue) {
this.openModal();
} else {
this.closeModal();
}
},
immediate: true
}
},
methods: {
openModal() {
// 現在のフォーカス要素を保存
this.previousActiveElement = document.activeElement;
// ボディのスクロールを無効化
document.body.style.overflow = 'hidden';
this.$nextTick(() => {
this.setupFocusTrap();
this.setInitialFocus();
});
},
closeModal() {
// ボディのスクロールを復元
document.body.style.overflow = '';
// 元の要素にフォーカスを戻す
if (this.previousActiveElement) {
this.previousActiveElement.focus();
}
},
setupFocusTrap() {
if (!this.$refs.modal) return;
// フォーカス可能な要素を取得
this.focusableElements = this.$refs.modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (this.focusableElements.length === 0) return;
// Tab キーのハンドリング
this.$refs.modal.addEventListener('keydown', this.handleTabKey);
},
handleTabKey(event) {
if (event.key !== 'Tab' || this.focusableElements.length === 0) return;
const firstElement = this.focusableElements[0];
const lastElement = this.focusableElements[this.focusableElements.length - 1];
if (event.shiftKey) {
// Shift + Tab (逆方向)
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab (順方向)
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
},
setInitialFocus() {
let targetElement;
switch (this.focusOnOpen) {
case 'confirm':
targetElement = this.$refs.confirmButton;
break;
case 'close':
targetElement = this.$refs.closeButton;
break;
default:
targetElement = this.focusableElements[0];
}
if (targetElement) {
targetElement.focus();
}
},
handleBackdropClick() {
if (this.closeOnBackdrop) {
this.close();
}
},
close() {
this.$emit('close');
},
confirm() {
this.$emit('confirm');
}
},
beforeUnmount() {
// イベントリスナーのクリーンアップ
if (this.$refs.modal) {
this.$refs.modal.removeEventListener('keydown', this.handleTabKey);
}
// ボディスタイルの復元
document.body.style.overflow = '';
}
}
</script>
<style scoped>
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e0e0e0;
}
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 4px;
border-radius: 4px;
}
.modal-close:hover,
.modal-close:focus {
background-color: #f0f0f0;
outline: 2px solid #007bff;
}
.modal-body {
padding: 20px;
}
.modal-description {
margin: 0 0 16px 0;
color: #666;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px;
border-top: 1px solid #e0e0e0;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.btn:focus {
outline: 2px solid #007bff;
outline-offset: 2px;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
}
</style>
モーダル使用例
vue<template>
<div class="modal-demo">
<h1>アクセシブルモーダルの使用例</h1>
<div class="demo-buttons">
<button @click="openConfirmDialog" class="demo-button">
確認ダイアログ
</button>
<button @click="openFormDialog" class="demo-button">
フォームダイアログ
</button>
<button @click="openInfoDialog" class="demo-button">
情報ダイアログ
</button>
</div>
<!-- 確認ダイアログ -->
<AccessibleModal
:is-open="showConfirmDialog"
title="削除の確認"
description="この操作は取り消すことができません。本当に削除しますか?"
focus-on-open="confirm"
@close="showConfirmDialog = false"
@confirm="handleDelete"
/>
<!-- フォームダイアログ -->
<AccessibleModal
:is-open="showFormDialog"
title="プロフィール編集"
focus-on-open="first"
@close="showFormDialog = false"
@confirm="handleFormSubmit"
>
<template #content>
<form @submit.prevent="handleFormSubmit">
<div class="form-group">
<label for="modal-name">名前</label>
<input
id="modal-name"
v-model="formData.name"
type="text"
required
/>
</div>
<div class="form-group">
<label for="modal-email">メールアドレス</label>
<input
id="modal-email"
v-model="formData.email"
type="email"
required
/>
</div>
</form>
</template>
<template #actions>
<button @click="showFormDialog = false" type="button">
キャンセル
</button>
<button @click="handleFormSubmit" type="button">
保存
</button>
</template>
</AccessibleModal>
<!-- 情報ダイアログ -->
<AccessibleModal
:is-open="showInfoDialog"
title="アップデート完了"
description="システムのアップデートが正常に完了しました。"
focus-on-open="close"
@close="showInfoDialog = false"
>
<template #actions>
<button @click="showInfoDialog = false" type="button">
OK
</button>
</template>
</AccessibleModal>
<!-- 結果表示 -->
<div
v-if="resultMessage"
aria-live="polite"
class="result-message"
>
{{ resultMessage }}
</div>
</div>
</template>
<script>
import AccessibleModal from './AccessibleModal.vue'
export default {
name: 'ModalDemo',
components: {
AccessibleModal
},
data() {
return {
showConfirmDialog: false,
showFormDialog: false,
showInfoDialog: false,
formData: {
name: '',
email: ''
},
resultMessage: ''
}
},
methods: {
openConfirmDialog() {
this.showConfirmDialog = true;
},
openFormDialog() {
this.formData = {
name: 'サンプル太郎',
email: 'sample@example.com'
};
this.showFormDialog = true;
},
openInfoDialog() {
this.showInfoDialog = true;
},
handleDelete() {
this.resultMessage = 'アイテムが削除されました';
this.showConfirmDialog = false;
// メッセージをクリア
setTimeout(() => {
this.resultMessage = '';
}, 3000);
},
handleFormSubmit() {
this.resultMessage = `プロフィールが更新されました: ${this.formData.name}`;
this.showFormDialog = false;
// メッセージをクリア
setTimeout(() => {
this.resultMessage = '';
}, 3000);
}
}
}
</script>
データテーブルのアクセシビリティ対応
大量のデータを表示するテーブルのアクセシビリティ実装例です。
アクセシブルデータテーブル
vue<template>
<div class="table-container">
<!-- テーブル情報とフィルター -->
<div class="table-header">
<h2 id="table-title">ユーザー管理</h2>
<div class="table-controls">
<!-- 検索フィルター -->
<div class="search-container">
<label for="table-search" class="sr-only">
ユーザーを検索
</label>
<input
id="table-search"
v-model="searchQuery"
type="text"
placeholder="名前またはメールで検索..."
@input="handleSearch"
/>
</div>
<!-- ソート選択 -->
<div class="sort-container">
<label for="sort-select">並び順</label>
<select
id="sort-select"
v-model="sortBy"
@change="handleSort"
>
<option value="name">名前順</option>
<option value="email">メール順</option>
<option value="role">役割順</option>
<option value="lastLogin">最終ログイン順</option>
</select>
</div>
</div>
</div>
<!-- テーブル情報 -->
<div class="table-info">
<p
id="table-summary"
aria-live="polite"
aria-atomic="true"
>
全{{ totalUsers }}件中 {{ filteredUsers.length }}件を表示
<span v-if="searchQuery">
(「{{ searchQuery }}」での検索結果)
</span>
</p>
</div>
<!-- データテーブル -->
<div class="table-wrapper" role="region" aria-labelledby="table-title">
<table
class="data-table"
:aria-describedby="'table-summary'"
>
<caption class="sr-only">
ユーザー一覧表。名前、メールアドレス、役割、最終ログイン日時、操作ボタンの列があります。
</caption>
<thead>
<tr>
<th
scope="col"
:aria-sort="getSortDirection('name')"
class="sortable-header"
>
<button
@click="toggleSort('name')"
class="sort-button"
:aria-label="`名前で並び替え(現在: ${getSortLabel('name')})`"
>
名前
<span aria-hidden="true" class="sort-icon">
{{ getSortIcon('name') }}
</span>
</button>
</th>
<th
scope="col"
:aria-sort="getSortDirection('email')"
class="sortable-header"
>
<button
@click="toggleSort('email')"
class="sort-button"
:aria-label="`メールアドレスで並び替え(現在: ${getSortLabel('email')})`"
>
メールアドレス
<span aria-hidden="true" class="sort-icon">
{{ getSortIcon('email') }}
</span>
</button>
</th>
<th
scope="col"
:aria-sort="getSortDirection('role')"
class="sortable-header"
>
<button
@click="toggleSort('role')"
class="sort-button"
:aria-label="`役割で並び替え(現在: ${getSortLabel('role')})`"
>
役割
<span aria-hidden="true" class="sort-icon">
{{ getSortIcon('role') }}
</span>
</button>
</th>
<th scope="col">最終ログイン</th>
<th scope="col">操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="user in paginatedUsers"
:key="user.id"
:class="{ 'inactive': !user.isActive }"
>
<th scope="row">
{{ user.name }}
<span v-if="!user.isActive" class="status-indicator">
(無効)
</span>
</th>
<td>
<a :href="`mailto:${user.email}`">
{{ user.email }}
</a>
</td>
<td>
<span
class="role-badge"
:class="`role-${user.role}`"
:aria-label="`役割: ${getRoleLabel(user.role)}`"
>
{{ getRoleLabel(user.role) }}
</span>
</td>
<td>
<time
:datetime="user.lastLogin"
:title="formatDateFull(user.lastLogin)"
>
{{ formatDateRelative(user.lastLogin) }}
</time>
</td>
<td>
<div class="action-buttons">
<button
@click="editUser(user)"
class="btn btn-sm btn-outline"
:aria-label="`${user.name}を編集`"
>
編集
</button>
<button
@click="toggleUserStatus(user)"
class="btn btn-sm"
:class="user.isActive ? 'btn-warning' : 'btn-success'"
:aria-label="`${user.name}を${user.isActive ? '無効化' : '有効化'}`"
>
{{ user.isActive ? '無効化' : '有効化' }}
</button>
<button
@click="deleteUser(user)"
class="btn btn-sm btn-danger"
:aria-label="`${user.name}を削除`"
>
削除
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- ページネーション -->
<nav aria-label="ページネーション" class="pagination-nav">
<div class="pagination-info">
ページ {{ currentPage }} / {{ totalPages }}
</div>
<div class="pagination-controls">
<button
@click="goToPage(currentPage - 1)"
:disabled="currentPage === 1"
class="btn btn-outline"
:aria-label="'前のページへ'"
>
← 前へ
</button>
<div class="page-numbers">
<button
v-for="page in visiblePages"
:key="page"
@click="goToPage(page)"
:class="['btn', page === currentPage ? 'btn-primary' : 'btn-outline']"
:aria-label="`ページ${page}へ移動`"
:aria-current="page === currentPage ? 'page' : undefined"
>
{{ page }}
</button>
</div>
<button
@click="goToPage(currentPage + 1)"
:disabled="currentPage === totalPages"
class="btn btn-outline"
:aria-label="'次のページへ'"
>
次へ →
</button>
</div>
</nav>
<!-- ライブリージョン(操作結果の通知) -->
<div
aria-live="polite"
aria-atomic="true"
class="sr-only"
>
{{ statusMessage }}
</div>
</div>
</template>
<script>
export default {
name: 'AccessibleDataTable',
data() {
return {
users: [
{
id: 1,
name: '田中太郎',
email: 'tanaka@example.com',
role: 'admin',
lastLogin: '2024-01-15T10:30:00Z',
isActive: true
},
{
id: 2,
name: '佐藤花子',
email: 'sato@example.com',
role: 'editor',
lastLogin: '2024-01-14T15:45:00Z',
isActive: true
},
{
id: 3,
name: '山田次郎',
email: 'yamada@example.com',
role: 'viewer',
lastLogin: '2024-01-13T09:15:00Z',
isActive: false
}
],
searchQuery: '',
sortBy: 'name',
sortDirection: 'asc',
currentPage: 1,
itemsPerPage: 10,
statusMessage: ''
}
},
computed: {
totalUsers() {
return this.users.length;
},
filteredUsers() {
if (!this.searchQuery) {
return this.sortedUsers;
}
const query = this.searchQuery.toLowerCase();
return this.sortedUsers.filter(user =>
user.name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
);
},
sortedUsers() {
const sorted = [...this.users].sort((a, b) => {
let aValue = a[this.sortBy];
let bValue = b[this.sortBy];
if (typeof aValue === 'string') {
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}
if (aValue < bValue) {
return this.sortDirection === 'asc' ? -1 : 1;
}
if (aValue > bValue) {
return this.sortDirection === 'asc' ? 1 : -1;
}
return 0;
});
return sorted;
},
totalPages() {
return Math.ceil(this.filteredUsers.length / this.itemsPerPage);
},
paginatedUsers() {
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = start + this.itemsPerPage;
return this.filteredUsers.slice(start, end);
},
visiblePages() {
const pages = [];
const total = this.totalPages;
const current = this.currentPage;
// 表示するページ番号の範囲を計算
let start = Math.max(1, current - 2);
let end = Math.min(total, current + 2);
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
}
},
methods: {
handleSearch() {
this.currentPage = 1;
this.announceSearchResult();
},
announceSearchResult() {
if (this.searchQuery) {
this.statusMessage = `「${this.searchQuery}」で検索しました。${this.filteredUsers.length}件の結果が見つかりました。`;
} else {
this.statusMessage = '検索をクリアしました。';
}
// メッセージをクリア
setTimeout(() => {
this.statusMessage = '';
}, 3000);
},
toggleSort(column) {
if (this.sortBy === column) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortBy = column;
this.sortDirection = 'asc';
}
this.currentPage = 1;
this.announceSortChange(column);
},
announceSortChange(column) {
const columnName = this.getColumnDisplayName(column);
const direction = this.sortDirection === 'asc' ? '昇順' : '降順';
this.statusMessage = `${columnName}で${direction}に並び替えました。`;
setTimeout(() => {
this.statusMessage = '';
}, 3000);
},
getColumnDisplayName(column) {
const names = {
name: '名前',
email: 'メールアドレス',
role: '役割',
lastLogin: '最終ログイン'
};
return names[column] || column;
},
getSortDirection(column) {
if (this.sortBy !== column) return 'none';
return this.sortDirection === 'asc' ? 'ascending' : 'descending';
},
getSortIcon(column) {
if (this.sortBy !== column) return '↕';
return this.sortDirection === 'asc' ? '↑' : '↓';
},
getSortLabel(column) {
if (this.sortBy !== column) return '未ソート';
return this.sortDirection === 'asc' ? '昇順' : '降順';
},
getRoleLabel(role) {
const labels = {
admin: '管理者',
editor: '編集者',
viewer: '閲覧者'
};
return labels[role] || role;
},
formatDateRelative(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffInDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
if (diffInDays === 0) return '今日';
if (diffInDays === 1) return '昨日';
if (diffInDays < 7) return `${diffInDays}日前`;
return date.toLocaleDateString('ja-JP');
},
formatDateFull(dateString) {
return new Date(dateString).toLocaleString('ja-JP');
},
goToPage(page) {
if (page >= 1 && page <= this.totalPages) {
this.currentPage = page;
this.statusMessage = `ページ${page}に移動しました。`;
setTimeout(() => {
this.statusMessage = '';
}, 2000);
}
},
editUser(user) {
this.statusMessage = `${user.name}の編集画面を開きます。`;
// 実際の編集ロジック
console.log('編集:', user);
},
toggleUserStatus(user) {
user.isActive = !user.isActive;
const status = user.isActive ? '有効化' : '無効化';
this.statusMessage = `${user.name}を${status}しました。`;
setTimeout(() => {
this.statusMessage = '';
}, 3000);
},
deleteUser(user) {
if (confirm(`${user.name}を削除しますか?`)) {
const index = this.users.findIndex(u => u.id === user.id);
if (index > -1) {
this.users.splice(index, 1);
this.statusMessage = `${user.name}を削除しました。`;
// ページ調整
if (this.paginatedUsers.length === 0 && this.currentPage > 1) {
this.currentPage--;
}
setTimeout(() => {
this.statusMessage = '';
}, 3000);
}
}
}
}
}
</script>
ナビゲーションメニューの改善
レスポンシブで階層構造を持つナビゲーションメニューの実装例です。
アクセシブルナビゲーション
vue<template>
<nav class="main-navigation" aria-label="メインナビゲーション">
<!-- モバイル用メニューボタン -->
<button
class="menu-toggle"
:class="{ 'is-active': isMobileMenuOpen }"
@click="toggleMobileMenu"
:aria-expanded="isMobileMenuOpen"
aria-controls="main-menu"
aria-label="メニューを開く"
>
<span class="menu-toggle-icon">
<span></span>
<span></span>
<span></span>
</span>
<span class="menu-toggle-text">メニュー</span>
</button>
<!-- メインメニュー -->
<ul
id="main-menu"
class="navigation-menu"
:class="{ 'is-open': isMobileMenuOpen }"
role="menubar"
@keydown="handleMenuKeyDown"
>
<li
v-for="(item, index) in menuItems"
:key="item.id"
class="menu-item"
:class="{ 'has-submenu': item.children }"
role="none"
>
<!-- トップレベルメニュー項目 -->
<template v-if="item.children">
<button
:id="`menu-button-${item.id}`"
class="menu-link menu-button"
:class="{ 'is-active': activeSubmenu === item.id }"
role="menuitem"
:aria-expanded="activeSubmenu === item.id"
:aria-controls="`submenu-${item.id}`"
:aria-haspopup="true"
@click="toggleSubmenu(item.id)"
@keydown="handleSubmenuKeyDown($event, item.id)"
@mouseenter="handleMouseEnter(item.id)"
@mouseleave="handleMouseLeave"
>
{{ item.label }}
<span class="submenu-indicator" aria-hidden="true">
{{ activeSubmenu === item.id ? '▼' : '▶' }}
</span>
</button>
<!-- サブメニュー -->
<ul
:id="`submenu-${item.id}`"
class="submenu"
:class="{ 'is-open': activeSubmenu === item.id }"
role="menu"
:aria-labelledby="`menu-button-${item.id}`"
@keydown="handleSubmenuKeyDown"
>
<li
v-for="child in item.children"
:key="child.id"
role="none"
>
<a
:href="child.url"
class="submenu-link"
role="menuitem"
@click="handleSubmenuClick"
>
{{ child.label }}
</a>
</li>
</ul>
</template>
<!-- 単独リンク -->
<template v-else>
<a
:href="item.url"
class="menu-link"
role="menuitem"
:aria-current="isCurrentPage(item.url) ? 'page' : undefined"
>
{{ item.label }}
</a>
</template>
</li>
</ul>
<!-- ライブリージョン -->
<div aria-live="polite" class="sr-only">
{{ announceMessage }}
</div>
</nav>
</template>
<script>
export default {
name: 'AccessibleNavigation',
data() {
return {
isMobileMenuOpen: false,
activeSubmenu: null,
mouseEnterTimeout: null,
announceMessage: '',
menuItems: [
{
id: 1,
label: 'ホーム',
url: '/'
},
{
id: 2,
label: '製品・サービス',
children: [
{ id: 21, label: 'Webアプリケーション', url: '/products/web' },
{ id: 22, label: 'モバイルアプリ', url: '/products/mobile' },
{ id: 23, label: 'デスクトップアプリ', url: '/products/desktop' },
{ id: 24, label: 'APIサービス', url: '/products/api' }
]
},
{
id: 3,
label: 'ソリューション',
children: [
{ id: 31, label: 'エンタープライズ', url: '/solutions/enterprise' },
{ id: 32, label: 'スタートアップ', url: '/solutions/startup' },
{ id: 33, label: '教育機関', url: '/solutions/education' }
]
},
{
id: 4,
label: 'リソース',
children: [
{ id: 41, label: 'ドキュメント', url: '/resources/docs' },
{ id: 42, label: 'チュートリアル', url: '/resources/tutorials' },
{ id: 43, label: 'ブログ', url: '/resources/blog' },
{ id: 44, label: 'FAQ', url: '/resources/faq' }
]
},
{
id: 5,
label: '会社情報',
url: '/about'
},
{
id: 6,
label: 'お問い合わせ',
url: '/contact'
}
]
}
},
mounted() {
// ページ外クリックでメニューを閉じる
document.addEventListener('click', this.handleOutsideClick);
// ESCキーでメニューを閉じる
document.addEventListener('keydown', this.handleGlobalKeyDown);
},
beforeUnmount() {
document.removeEventListener('click', this.handleOutsideClick);
document.removeEventListener('keydown', this.handleGlobalKeyDown);
if (this.mouseEnterTimeout) {
clearTimeout(this.mouseEnterTimeout);
}
},
methods: {
toggleMobileMenu() {
this.isMobileMenuOpen = !this.isMobileMenuOpen;
if (this.isMobileMenuOpen) {
this.announceMessage = 'メニューを開きました';
// 最初のメニュー項目にフォーカス
this.$nextTick(() => {
const firstMenuItem = this.$el.querySelector('.menu-link');
if (firstMenuItem) {
firstMenuItem.focus();
}
});
} else {
this.announceMessage = 'メニューを閉じました';
this.activeSubmenu = null;
}
// アナウンスメッセージをクリア
setTimeout(() => {
this.announceMessage = '';
}, 1000);
},
toggleSubmenu(menuId) {
if (this.activeSubmenu === menuId) {
this.activeSubmenu = null;
this.announceMessage = 'サブメニューを閉じました';
} else {
this.activeSubmenu = menuId;
const menuItem = this.menuItems.find(item => item.id === menuId);
this.announceMessage = `${menuItem.label}のサブメニューを開きました`;
// 最初のサブメニュー項目にフォーカス
this.$nextTick(() => {
const firstSubmenuItem = this.$el.querySelector(`#submenu-${menuId} .submenu-link`);
if (firstSubmenuItem) {
firstSubmenuItem.focus();
}
});
}
setTimeout(() => {
this.announceMessage = '';
}, 1000);
},
handleMouseEnter(menuId) {
// マウスホバーでのサブメニュー表示(遅延あり)
if (this.mouseEnterTimeout) {
clearTimeout(this.mouseEnterTimeout);
}
this.mouseEnterTimeout = setTimeout(() => {
this.activeSubmenu = menuId;
}, 200);
},
handleMouseLeave() {
if (this.mouseEnterTimeout) {
clearTimeout(this.mouseEnterTimeout);
}
// マウスが離れた後の遅延でサブメニューを閉じる
setTimeout(() => {
this.activeSubmenu = null;
}, 300);
},
handleMenuKeyDown(event) {
const { key } = event;
const menuItems = Array.from(this.$el.querySelectorAll('.menu-link'));
const currentIndex = menuItems.indexOf(document.activeElement);
switch (key) {
case 'ArrowRight':
event.preventDefault();
this.focusNextMenuItem(menuItems, currentIndex);
break;
case 'ArrowLeft':
event.preventDefault();
this.focusPreviousMenuItem(menuItems, currentIndex);
break;
case 'ArrowDown':
event.preventDefault();
this.handleArrowDown(event.target);
break;
case 'Escape':
event.preventDefault();
this.closeAllMenus();
break;
case 'Home':
event.preventDefault();
menuItems[0]?.focus();
break;
case 'End':
event.preventDefault();
menuItems[menuItems.length - 1]?.focus();
break;
}
},
handleSubmenuKeyDown(event, menuId = null) {
const { key } = event;
switch (key) {
case 'ArrowDown':
event.preventDefault();
this.focusNextSubmenuItem(event.target);
break;
case 'ArrowUp':
event.preventDefault();
this.focusPreviousSubmenuItem(event.target);
break;
case 'ArrowLeft':
if (menuId) {
event.preventDefault();
this.activeSubmenu = null;
this.$el.querySelector(`#menu-button-${menuId}`)?.focus();
}
break;
case 'Escape':
event.preventDefault();
if (menuId) {
this.activeSubmenu = null;
this.$el.querySelector(`#menu-button-${menuId}`)?.focus();
} else {
this.closeAllMenus();
}
break;
}
},
focusNextMenuItem(menuItems, currentIndex) {
const nextIndex = (currentIndex + 1) % menuItems.length;
menuItems[nextIndex]?.focus();
},
focusPreviousMenuItem(menuItems, currentIndex) {
const prevIndex = currentIndex === 0 ? menuItems.length - 1 : currentIndex - 1;
menuItems[prevIndex]?.focus();
},
handleArrowDown(target) {
const button = target.closest('.menu-button');
if (button) {
const menuId = parseInt(button.id.split('-')[2]);
this.toggleSubmenu(menuId);
}
},
focusNextSubmenuItem(currentElement) {
const submenuItems = Array.from(
currentElement.closest('.submenu').querySelectorAll('.submenu-link')
);
const currentIndex = submenuItems.indexOf(currentElement);
const nextIndex = (currentIndex + 1) % submenuItems.length;
submenuItems[nextIndex]?.focus();
},
focusPreviousSubmenuItem(currentElement) {
const submenuItems = Array.from(
currentElement.closest('.submenu').querySelectorAll('.submenu-link')
);
const currentIndex = submenuItems.indexOf(currentElement);
const prevIndex = currentIndex === 0 ? submenuItems.length - 1 : currentIndex - 1;
submenuItems[prevIndex]?.focus();
},
handleSubmenuClick() {
// サブメニューアイテムクリック時にメニューを閉じる
this.activeSubmenu = null;
this.isMobileMenuOpen = false;
},
handleOutsideClick(event) {
if (!this.$el.contains(event.target)) {
this.closeAllMenus();
}
},
handleGlobalKeyDown(event) {
if (event.key === 'Escape') {
this.closeAllMenus();
}
},
closeAllMenus() {
this.activeSubmenu = null;
this.isMobileMenuOpen = false;
},
isCurrentPage(url) {
// 現在のページURLと比較
return window.location.pathname === url;
}
}
}
</script>
<style scoped>
.main-navigation {
position: relative;
}
.menu-toggle {
display: none;
flex-direction: column;
align-items: center;
background: none;
border: none;
cursor: pointer;
padding: 10px;
}
.menu-toggle-icon {
display: flex;
flex-direction: column;
width: 24px;
height: 18px;
justify-content: space-between;
}
.menu-toggle-icon span {
display: block;
height: 2px;
width: 100%;
background-color: #333;
transition: all 0.3s ease;
}
.menu-toggle.is-active .menu-toggle-icon span:nth-child(1) {
transform: rotate(45deg) translate(6px, 6px);
}
.menu-toggle.is-active .menu-toggle-icon span:nth-child(2) {
opacity: 0;
}
.menu-toggle.is-active .menu-toggle-icon span:nth-child(3) {
transform: rotate(-45deg) translate(6px, -6px);
}
.navigation-menu {
display: flex;
list-style: none;
margin: 0;
padding: 0;
}
.menu-item {
position: relative;
}
.menu-link,
.menu-button {
display: flex;
align-items: center;
padding: 15px 20px;
text-decoration: none;
color: #333;
background: none;
border: none;
cursor: pointer;
font-size: 16px;
transition: background-color 0.2s ease;
}
.menu-link:hover,
.menu-link:focus,
.menu-button:hover,
.menu-button:focus {
background-color: #f0f0f0;
outline: 2px solid #007bff;
outline-offset: -2px;
}
.menu-link[aria-current="page"] {
background-color: #007bff;
color: white;
}
.submenu-indicator {
margin-left: 8px;
font-size: 12px;
}
.submenu {
position: absolute;
top: 100%;
left: 0;
background-color: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
list-style: none;
margin: 0;
padding: 0;
min-width: 200px;
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all 0.2s ease;
z-index: 1000;
}
.submenu.is-open {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.submenu-link {
display: block;
padding: 12px 20px;
text-decoration: none;
color: #333;
transition: background-color 0.2s ease;
}
.submenu-link:hover,
.submenu-link:focus {
background-color: #f8f9fa;
outline: 2px solid #007bff;
outline-offset: -2px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* モバイル対応 */
@media (max-width: 768px) {
.menu-toggle {
display: flex;
}
.navigation-menu {
display: none;
flex-direction: column;
position: absolute;
top: 100%;
left: 0;
right: 0;
background-color: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.navigation-menu.is-open {
display: flex;
}
.menu-item {
border-bottom: 1px solid #eee;
}
.menu-item:last-child {
border-bottom: none;
}
.submenu {
position: static;
box-shadow: none;
border: none;
border-top: 1px solid #eee;
transform: none;
background-color: #f8f9fa;
}
.submenu.is-open {
opacity: 1;
visibility: visible;
}
}
</style>
図で理解できる要点:
- フォームでの段階的なエラーハンドリング
- モーダルでのフォーカストラップ実装
- データテーブルでのソート・検索のアクセシビリティ
- ナビゲーションでのキーボード操作対応
まとめ
Vue.jsアプリケーションでのアクセシビリティ対応について、基礎知識から実践的な実装方法まで詳しく解説いたしました。アクセシビリティは単なる技術的要件ではなく、すべてのユーザーに平等な体験を提供するための重要な取り組みです。
重要なポイントの振り返り
セマンティックHTMLの活用が最も基本的かつ効果的な対応方法です。適切な要素を使用することで、スクリーンリーダーなどの支援技術に正確な情報を伝達できます。
ARIA属性の適切な実装により、動的なコンテンツや複雑なUIコンポーネントでも、支援技術に必要な情報を提供できるようになります。特にaria-live
、aria-expanded
、aria-describedby
などの属性は、Vue.jsアプリケーションで頻繁に活用されます。
フォーカス管理とキーボードナビゲーションは、キーボードユーザーやスクリーンリーダーユーザーにとって不可欠です。モーダルダイアログでのフォーカストラップや、カスタムコンポーネントでのキーボード操作対応を適切に実装することが重要でした。
スクリーンリーダー対応では、動的なコンテンツ変更を適切に通知するための実装が求められます。ライブリージョンやステータスメッセージの活用により、視覚的な変化を音声で伝達できます。
継続的な改善のために
アクセシビリティ対応は一度実装して終わりではありません。以下の点を継続的に意識することが大切です。
- 自動テストツールの活用: eslint-plugin-vue-a11yやaxe-coreなどのツールを開発フローに組み込む
- 実際のユーザーテスト: 支援技術を使用するユーザーからのフィードバックを得る
- チーム全体でのスキル向上: アクセシビリティに関する知識をチーム内で共有する
- 段階的な改善: 既存のアプリケーションは優先度を付けて段階的に改善していく
Vue.jsの豊富なエコシステムとリアクティブな特性を活かして、誰もが快適に利用できるWebアプリケーションを構築していくことが、私たち開発者の責務といえるでしょう。アクセシビリティを意識した開発により、より多くのユーザーに価値を提供し、インクルーシブなデジタル社会の実現に貢献していきましょう。
関連リンク
公式ドキュメント・ガイドライン
- Vue.js アクセシビリティガイド
- Web Content Accessibility Guidelines (WCAG) 2.1
- WAI-ARIA Authoring Practices Guide
- MDN アクセシビリティガイド
ツール・ライブラリ
学習リソース
- article
Vue.js でのアクセシビリティ対応・改善の実践法
- article
Vue.js プロジェクトのディレクトリ設計ベストプラクティス
- article
Vue.js アプリのユニットテスト入門
- article
Pinia 入門:Vue 3 で最速の状態管理を始めよう
- article
Vue.js と Storybook:UI 開発の生産性爆上げ術
- article
Pinia とは?Vue 公式の次世代状態管理ライブラリを徹底解説
- article
Astro と Tailwind CSS で美しいデザインを最速実現
- article
shadcn/ui のコンポーネント一覧と使い方まとめ
- article
Apollo Client の Reactive Variables - GraphQL でグローバル状態管理
- article
Remix でデータフェッチ最適化:Loader のベストプラクティス
- article
ゼロから始める Preact 開発 - セットアップから初回デプロイまで
- article
Zod で配列・オブジェクトを安全に扱うバリデーションテクニック
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- blog
失敗を称賛する文化はどう作る?アジャイルな組織へ生まれ変わるための第一歩
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来