LaravelのBladeとAlpine.jsで作るアクセシブルUI実装ガイド

php elephant sticker
Photo by RealToughCandy.com on Pexels.com

LaravelのBladeコンポーネントとAlpine.jsを組み合わせると、モーダル、メニューボタン、タブ、アコーディオンの動作を再利用しやすい形にまとめられます。

ただし、見た目が開閉するだけでは、キーボードやスクリーンリーダーで同じ操作ができるとは限りません。

この記事では、要素の役割、状態、キーボード操作、フォーカス移動を一つの仕様として扱い、実装例と確認手順を整理します。

Alpine.jsを採用する前の比較は、Vue.js、React、Alpine.jsの違いと選び方も参考になります。

この記事で扱う四つのUIパターン

  • モーダルを開いたときの初期フォーカス、フォーカストラップ、閉じた後の戻り先
  • メニューボタンの開閉状態と、矢印キーによる項目移動
  • タブとタブパネルの関連付け、選択状態、フォーカス順
  • アコーディオンの開閉状態と、見出し、ボタン、内容領域の関係

掲載コードは設計の出発点です。

実際の画面では、表示内容、利用するAlpine.jsプラグイン、ブラウザ、支援技術を含めて動作を確認してください。

実装前にそろえる四つの仕様

アクセシブルなインタラクティブUIとは、視覚的な変化だけでなく、要素の意味と現在の状態が支援技術にも伝わり、キーボードでも操作できるUIです。

コンポーネントを作る前に、次の四つを決めておくと、HTMLとJavaScriptの責務が曖昧になりません。

仕様 決める内容 実装例
役割と名前 何の部品で、読み上げ時に何と呼ばれるか role、aria-label、aria-labelledby
状態と関係 開閉や選択の状態、操作する要素と内容領域の対応 aria-expanded、aria-selected、aria-controls
フォーカス 開いた直後、キー移動中、閉じた後にどこへ移るか tabindex、focus()、フォーカストラップ
入力方法 クリック以外に、どのキーで操作できるか Esc、矢印キー、Home、End、Enter、Space

キーボード操作とフォーカス表示の整え方では、Tabキーでたどる順序と見えるフォーカスの確認方法を詳しく解説しています。

Bladeコンポーネントの配置

ARIA属性とキー操作を各画面へコピーすると、修正漏れが起きやすくなります。

共通のBladeコンポーネントにまとめ、呼び出し側はラベルや内容だけを渡す構成にします。

resources/
└─ views/
   └─ components/
      └─ ui/
         ├─ modal.blade.php
         ├─ dropdown.blade.php
         ├─ tabs.blade.php
         └─ accordion.blade.php

Props、Slot、属性の引き継ぎを含む構成は、Laravel Bladeコンポーネントの実践ガイドで確認できます。

モーダルのフォーカスを閉じ込めて戻す

モーダルを開くと、操作対象が背面のページからダイアログへ切り替わります。

開いた直後はダイアログ内へフォーカスを移し、Tabキーによる移動を内部に限定し、閉じた後は開いたボタンへ戻します。

次の例はAlpine.jsのFocusプラグインでx-trapが使える前提です。

モーダルのBladeテンプレート

@props([
  'open' => false,
  'title' => '',
  'id' => 'modal-'.Str::random(6),
])

@php
  $labelId = $id.'-title';
  $descId = $id.'-desc';
@endphp

<div x-data="modalComponent" x-init="setup({{ $open ? 'true' : 'false' }})">
  @if (isset($trigger))
    {{ $trigger }}
  @endif

  <div
    x-cloak
    x-show="open"
    x-transition
    class="fixed inset-0 z-40 bg-black/60"
    aria-hidden="true"
    @click="close()"
  ></div>

  <div
    x-ref="dialog"
    x-cloak
    x-show="open"
    x-transition
    x-trap.inert.noscroll="open"
    role="dialog"
    aria-modal="true"
    aria-labelledby="{{ $labelId }}"
    aria-describedby="{{ $descId }}"
    class="fixed inset-0 z-50 grid place-items-center p-4"
    @keydown.escape.prevent.stop="close()"
  >
    <div class="w-full max-w-lg rounded bg-white shadow-lg ring-1 ring-gray-200">
      <header class="flex items-start gap-4 border-b p-4">
        <h2 id="{{ $labelId }}" class="text-lg font-semibold">{{ $title }}</h2>
        <button type="button" class="ml-auto underline" @click="close()">閉じる</button>
      </header>

      <div id="{{ $descId }}" class="p-4">
        {{ $slot }}
      </div>

      <footer class="flex justify-end gap-3 border-t p-4">
        <button type="button" class="rounded border px-4 py-2" @click="close()">キャンセル</button>
        <button type="button" class="rounded bg-blue-600 px-4 py-2 text-white" @click="$dispatch('modal:confirm')">確定する</button>
      </footer>
    </div>
  </div>
</div>

Alpine.js側の状態管理

document.addEventListener('alpine:init', () => {
  Alpine.data('modalComponent', () => ({
    open: false,
    returnFocusEl: null,

    setup(defaultOpen) {
      this.open = defaultOpen === true
      if (this.open) this.$nextTick(() => this.focusFirst())
    },

    focusables() {
      const selector = [
        'a[href]',
        'button:not([disabled])',
        'input:not([disabled])',
        'select:not([disabled])',
        'textarea:not([disabled])',
        '[tabindex]:not([tabindex="-1"])',
      ].join(',')

      return this.$refs.dialog.querySelectorAll(selector)
    },

    focusFirst() {
      const first = this.$refs.dialog.querySelector('[autofocus]')
        ?? this.focusables()[0]
      first?.focus()
    },

    openModal(trigger) {
      this.returnFocusEl = trigger
      this.open = true
      this.$nextTick(() => this.focusFirst())
    },

    close() {
      if (!this.open) return
      this.open = false
      this.$nextTick(() => this.returnFocusEl?.focus())
    },
  }))
})

呼び出し例

<x-ui.modal id="product-modal" title="商品の詳細">
  <x-slot:trigger>
    <button
      type="button"
      class="rounded border px-3 py-2"
      @click="openModal($el)"
    >詳細を見る</button>
  </x-slot:trigger>

  <p>ここに商品の説明が入ります。</p>
</x-ui.modal>

focusables()はx-ref=dialogの内側だけを検索します。

トリガーまで検索対象に含めると、初期フォーカスがダイアログの外へ戻るためです。

メニューボタンを矢印キーで操作する

複数の操作を一つのボタンから開く場合は、トリガーの開閉状態と、メニュー内での移動方法をそろえます。

メニュー項目は通常のTab順から外し、メニューを開いた後に上下矢印、Home、Endでフォーカスを移します。

メニューボタンのBladeテンプレート

@props([
  'label' => 'メニュー',
  'id' => 'menu-'.Str::random(6),
])

@php
  $buttonId = $id.'-button';
  $menuId = $id.'-items';
@endphp

<div x-data="menuComponent" class="relative inline-block">
  <button
    x-ref="trigger"
    id="{{ $buttonId }}"
    type="button"
    class="rounded border px-3 py-2"
    aria-haspopup="menu"
    :aria-expanded="open.toString()"
    aria-controls="{{ $menuId }}"
    @click="toggle()"
    @keydown.arrow-down.prevent="openAndFocus(0)"
    @keydown.arrow-up.prevent="openAndFocus(lastIndex())"
  >
    {{ $label }} <span aria-hidden="true">▾</span>
  </button>

  <ul
    x-ref="menu"
    x-cloak
    x-show="open"
    id="{{ $menuId }}"
    role="menu"
    aria-labelledby="{{ $buttonId }}"
    class="absolute z-20 mt-1 w-48 rounded bg-white p-1 shadow-lg ring-1 ring-gray-200"
    @keydown.arrow-down.prevent="move(1)"
    @keydown.arrow-up.prevent="move(-1)"
    @keydown.home.prevent="focusIndex(0)"
    @keydown.end.prevent="focusIndex(lastIndex())"
    @keydown.escape.prevent.stop="close()"
    @keydown.tab="close(false)"
    @click.outside="close(false)"
  >
    {{ $slot }}
  </ul>
</div>

Alpine.js側の状態管理

document.addEventListener('alpine:init', () => {
  Alpine.data('menuComponent', () => ({
    open: false,
    index: -1,

    items() {
      return this.$refs.menu.querySelectorAll('[role="menuitem"]')
    },

    lastIndex() {
      return Math.max(0, this.items().length - 1)
    },

    toggle() {
      this.open ? this.close() : this.openAndFocus(0)
    },

    openAndFocus(index) {
      this.open = true
      this.$nextTick(() => this.focusIndex(index))
    },

    move(step) {
      const items = this.items()
      if (!items.length) return
      this.index = (this.index + step + items.length) % items.length
      items[this.index].focus()
    },

    focusIndex(index) {
      const item = this.items()[index]
      if (!item) return
      this.index = index
      item.focus()
    },

    close(returnFocus = true) {
      this.open = false
      this.index = -1
      if (returnFocus) this.$nextTick(() => this.$refs.trigger.focus())
    },
  }))
})

メニュー項目の例

<x-ui.dropdown label="操作">
  <li role="none">
    <a role="menuitem" tabindex="-1" class="block rounded px-3 py-2" href="#">編集する</a>
  </li>
  <li role="none">
    <a role="menuitem" tabindex="-1" class="block rounded px-3 py-2" href="#">複製する</a>
  </li>
  <li role="none">
    <button role="menuitem" tabindex="-1" type="button" class="block w-full rounded px-3 py-2 text-left">削除する</button>
  </li>
</x-ui.dropdown>

外側をクリックしたときやTabキーで離れるときは、閉じる処理だけを行い、フォーカスを強制的にトリガーへ戻しません。

Escで閉じた場合は、操作の続きが分かるようにトリガーへ戻します。

タブとタブパネルを相互に関連付ける

タブでは、選択中のタブだけを通常のTab順に置き、左右矢印で選択を移します。

各タブのaria-controlsは対応するパネルを指し、各パネルのaria-labelledbyは対応するタブを指します。

タブのBladeテンプレート

@props([
  'tabs' => [],
  'active' => 0,
  'id' => 'tabs-'.Str::random(6),
])

<div x-data="tabsComponent({{ json_encode($tabs) }}, {{ $active }})">
  <div
    role="tablist"
    aria-label="コンテンツ切替"
    class="flex gap-2 border-b"
    @keydown.arrow-right.prevent="move(1)"
    @keydown.arrow-left.prevent="move(-1)"
    @keydown.home.prevent="activate(0)"
    @keydown.end.prevent="activate(lastIndex())"
  >
    <template x-for="(tab, index) in tabs" :key="tab.id">
      <button
        :id="'{{ $id }}-tab-' + tab.id"
        role="tab"
        :aria-controls="'{{ $id }}-panel-' + tab.id"
        :aria-selected="(index === current).toString()"
        :tabindex="index === current ? '0' : '-1'"
        class="-mb-px border-b-2 px-3 py-2"
        :class="index === current
          ? 'border-blue-600 text-blue-700 font-semibold'
          : 'border-transparent text-gray-600'"
        @click="activate(index)"
      >
        <span x-text="tab.label"></span>
      </button>
    </template>
  </div>

  <template x-for="(tab, index) in tabs" :key="tab.id">
    <section
      x-cloak
      x-show="index === current"
      :id="'{{ $id }}-panel-' + tab.id"
      role="tabpanel"
      :aria-labelledby="'{{ $id }}-tab-' + tab.id"
      class="pt-4"
    >
      <div x-html="tab.html ?? ''"></div>
    </section>
  </template>
</div>

Alpine.js側の状態管理

document.addEventListener('alpine:init', () => {
  Alpine.data('tabsComponent', (tabs, active) => ({
    tabs,
    current: active ?? 0,

    lastIndex() {
      return this.tabs.length - 1
    },

    move(step) {
      if (!this.tabs.length) return
      const next = (this.current + step + this.tabs.length) % this.tabs.length
      this.activate(next)
    },

    activate(index) {
      if (!this.tabs[index]) return
      this.current = index
      this.$nextTick(() => {
        this.$root.querySelectorAll('[role="tab"]')[index]?.focus()
      })
    },
  }))
})

タブデータの例

@php
$tabs = [
  ['id' => 'overview', 'label' => '概要', 'html' => '<p>概要の内容</p>'],
  ['id' => 'spec', 'label' => '仕様', 'html' => '<p>仕様の内容</p>'],
  ['id' => 'faq', 'label' => 'よくある質問', 'html' => '<p>質問と回答</p>'],
];
@endphp

<x-ui.tabs :tabs="$tabs" :active="0" />

この例の矢印キー操作は、フォーカスの移動と同時にパネルを切り替える方式です。

x-htmlへ渡す内容は、開発側で管理する信頼できるHTMLに限定します。

アコーディオンの開閉状態を伝える

アコーディオンの見出しには通常の<button>を置きます。

これにより、EnterとSpaceによる操作を独自に再実装せず、ボタン本来のキーボード操作を利用できます。

アコーディオンのBladeテンプレート

@props([
  'items' => [],
  'allowMultiple' => true,
])

<div x-data="{ open: {} }" class="space-y-2">
  @foreach ($items as $item)
    @php
      $buttonId = "accordion-button-{$item['id']}";
      $panelId = "accordion-panel-{$item['id']}";
    @endphp

    <section class="rounded border">
      <h3 class="m-0">
        <button
          id="{{ $buttonId }}"
          type="button"
          class="flex w-full items-center justify-between p-3 text-left"
          aria-controls="{{ $panelId }}"
          :aria-expanded="Boolean(open['{{ $item['id'] }}']).toString()"
          @click="
            @if ($allowMultiple)
              open['{{ $item['id'] }}'] = !open['{{ $item['id'] }}']
            @else
              open = { '{{ $item['id'] }}': !open['{{ $item['id'] }}'] }
            @endif
          "
        >
          <span>{{ $item['summary'] }}</span>
          <span aria-hidden="true" x-text="open['{{ $item['id'] }}'] ? '−' : '+'"></span>
        </button>
      </h3>

      <div
        x-cloak
        x-show="open['{{ $item['id'] }}']"
        id="{{ $panelId }}"
        role="region"
        aria-labelledby="{{ $buttonId }}"
        class="border-t p-3"
      >
        {{ $item['content'] }}
      </div>
    </section>
  @endforeach
</div>

allowMultipleがfalseなら、新しい項目を開くとほかの項目は閉じます。

内容を通常の{{ }}で出力しているため、この例ではHTMLではなくテキストを渡します。

四つのパターンを同じ表で確認する

ARIA属性だけを点検しても、フォーカスの移動やキー操作の不具合は見つけきれません。

部品ごとに、状態、キー、フォーカスの三点を同じ形式で確認します。

パターン 主な状態と関係 キーボード操作 フォーカスの確認
モーダル aria-modal、aria-labelledby Tab、Shift+Tab、Esc 開いた直後は内部、閉じた後はトリガー
メニューボタン aria-expanded、aria-controls 上下矢印、Home、End、Esc 開いた後は項目、Esc後はトリガー
タブ aria-selected、aria-controls 左右矢印、Home、End 選択中のタブだけを通常のTab順に置く
アコーディオン aria-expanded、aria-controls Enter、Space 開閉してもボタンに残す

色、フォーカス、動きへの配慮

  • 文字のコントラスト:本文と背景は4.5対1以上を目安にし、実際のテーマ色で確認します。
  • フォーカス表示:outlineを消さず、背景や選択状態が変わっても位置を判別できる見た目にします。
  • タップ領域:ボタンやタブは44×44pxを目安にし、近接する操作との間隔も確認します。
  • 状態の表現:色だけに頼らず、文言、形、下線、アイコンなどを組み合わせます。
  • 動きの軽減:prefers-reduced-motionに応じて、トランジションを無効化するか短くします。

x-cloakを使う場合は、Alpine.jsの初期化前に対象が一瞬表示されないよう、プロジェクトのCSSで非表示規則を用意します。

JavaScriptが動かない場合の情報設計

プログレッシブエンハンスメントは、基本の情報や操作をHTMLで提供し、JavaScriptで利便性を追加する設計です。

  • モーダルの内容へ移動できる通常のリンクを用意する
  • メニュー項目を通常のリンク一覧として読める構造にする
  • タブの各内容を見出しと本文として順に読めるようにする
  • アコーディオンの内容を初期HTMLに含め、情報そのものを失わないようにする

JavaScriptが無効な場合の表示は、各コンポーネントの初期HTMLとCSSを含めて確認します。

DuskとPa11yだけで完了としない

Duskは操作の流れを繰り返し確認する用途に向き、Pa11yはマークアップ上の問題を自動検出する助けになります。

一方、実際のキー操作が自然か、フォーカスが見えるか、読み上げた順序で内容を理解できるかは、手動でも確認する必要があります。

  1. マウスを使わず、Tab、Shift+Tab、矢印キー、Enter、Space、Escで一連の操作を試す
  2. 開閉や選択のたびに、画面上のフォーカス位置を確認する
  3. ブラウザのアクセシビリティツリーまたはスクリーンリーダーで、名前、役割、状態を確認する
  4. Duskで主要な状態遷移を回帰テストにする
  5. Pa11yの結果を確認し、検出されない操作上の問題を手動結果と合わせて管理する

チームへ導入する順序

  1. 各パターンの開閉、選択、キー操作、フォーカス移動を表にする
  2. BladeコンポーネントへARIA属性と状態管理の接点を集約する
  3. 通常、選択中、無効、エラーなどの状態を確認できるサンプルページを作る
  4. Dusk、Pa11y、手動操作の確認項目をPull Requestのテンプレートへ入れる
  5. 不具合を個別画面だけで直さず、共通コンポーネントとテストへ反映する

公開前チェックリスト

  • [ ] 本文内にページタイトルと重複するH1がない
  • [ ] 操作要素にネイティブの<button>または<a>を使っている
  • [ ] 開閉状態と選択状態がARIA属性に反映される
  • [ ] 操作する要素と対象領域のIDが正しく対応している
  • [ ] 開いた直後と閉じた後のフォーカス位置が決まっている
  • [ ] キーボードだけで開始から終了まで操作できる
  • [ ] フォーカス表示をすべての背景色で確認した
  • [ ] 色だけで状態を伝えていない
  • [ ] 動きを減らす設定を反映している
  • [ ] 自動検査の結果を手動操作でも確認した

実装をチームの標準にする

アクセシブルなUIは、ARIA属性を後から足す作業ではありません。

役割、状態、キー操作、フォーカス移動を先に決め、Bladeコンポーネントとテストへ集約することで、画面ごとの差を減らせます。

まずは利用頻度の高い一つのパターンを選び、キーボード操作と読み上げまで確認してから、共通部品として展開してください。

この記事に関連する株式会社greedenの取り組み

操作できる人を増やすUIは、ARIA属性の追加だけで終わらず、設計、実装、検証を一続きで扱う必要があります。株式会社greedenは、Webアクセシビリティチェックと、サイトの使いやすさを補助するUUUを提供しています。

投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)