T-CREATOR

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

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>

この実装では、articleheadermainsectionfooternavといったセマンティック要素を適切に使用しています。

フォーム要素でのセマンティック実装

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>

この例では、fieldsetlegendでフォームをグループ化し、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">&times;</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-livearia-expandedaria-describedbyなどの属性は、Vue.jsアプリケーションで頻繁に活用されます。

フォーカス管理とキーボードナビゲーションは、キーボードユーザーやスクリーンリーダーユーザーにとって不可欠です。モーダルダイアログでのフォーカストラップや、カスタムコンポーネントでのキーボード操作対応を適切に実装することが重要でした。

スクリーンリーダー対応では、動的なコンテンツ変更を適切に通知するための実装が求められます。ライブリージョンやステータスメッセージの活用により、視覚的な変化を音声で伝達できます。

継続的な改善のために

アクセシビリティ対応は一度実装して終わりではありません。以下の点を継続的に意識することが大切です。

  • 自動テストツールの活用: eslint-plugin-vue-a11yやaxe-coreなどのツールを開発フローに組み込む
  • 実際のユーザーテスト: 支援技術を使用するユーザーからのフィードバックを得る
  • チーム全体でのスキル向上: アクセシビリティに関する知識をチーム内で共有する
  • 段階的な改善: 既存のアプリケーションは優先度を付けて段階的に改善していく

Vue.jsの豊富なエコシステムとリアクティブな特性を活かして、誰もが快適に利用できるWebアプリケーションを構築していくことが、私たち開発者の責務といえるでしょう。アクセシビリティを意識した開発により、より多くのユーザーに価値を提供し、インクルーシブなデジタル社会の実現に貢献していきましょう。

関連リンク

公式ドキュメント・ガイドライン

ツール・ライブラリ

学習リソース