# メッセージを読み上げないチャットウィジェット

Source: https://easeweb.dev/learn/chat-widgets
Topics: ダイアログとオーバーレイ > チャットウィジェットがメッセージを読み上げる (WCAG 4.1.3)
The fix: パネルを開いたら、メッセージ欄のaria-describedbyで会話のすべてのメッセージを指し、あいさつがメッセージ欄と一緒に読まれるようにします。返信については、メッセージ一覧にrole="log"と名前を付けるか、一覧はそのままにして、各返信をページに最初からあるライブリージョンにも書き込みます。
Test with: screen reader, keyboard
References: https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA23 https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/log_role https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-describedby

よくある問題を再現した学習用のサンプルです。実在のクライアントの診断結果ではありません。コードは出発点となる実装例のため、実際の製品でも確認してください。ここでは支援技術でのテスト結果は報告していません。

## 場面

家具店のBirchwood Homeでは、どのページの隅にも「チャットで相談」ボタンがあります。押すと、その場所に小さなチャットのパネルが開きます。「Birchwoodのチャット」という見出し、閉じるための×、「こんにちは。ご注文についてお気軽にお尋ねください。」というあいさつ、メッセージ欄と「送信」ボタンがあります。スクリーンリーダーの利用者がボタンを押すと、フォーカスがメッセージ欄に移り、スクリーンリーダーは「メッセージ、編集テキスト」と読むだけで、あいさつは読みません。

利用者は「配送先の住所を変更できますか？」と入力してEnterを押します。質問が会話に表示され、数秒後、その下に「はい。発送前であれば『ご注文履歴』から変更できます。」という返信が表示されます。スクリーンリーダーは何も言いません。利用者はしばらく待ち、あいさつの内容と返事があったかを確かめるために、メッセージ欄を離れて会話を読み返します。

## 確認できる問題

スクリプトは、各メッセージを新しい段落として、ただの `div` の中に追加します。支援技術が変化を読み上げるのは、一般にライブリージョンの中だけで、この `div` はライブリージョンではないため、返信は表示されても読み上げられません。返信は、利用者が今した操作についての知らせで、フォーカスを動かさずに届きます。これは達成基準4.1.3が扱う場面で、W3Cが `role="log"` を使うこの基準の達成方法の例にも、チャットの履歴が使われています。スクリーンリーダーの利用者も読み返せば返信を見つけられますが、いつ確かめればよいかは何も伝えられません。

あいさつが読まれない理由は別にあります。あいさつはパネルが表示された時点ですでにあり、フォーカスをメッセージ欄に移しても、読まれるのはメッセージ欄の名前だけで、周りの内容は読まれません。あいさつは読み進めれば見つかるため、これだけでは達成基準を満たさないことにはなりません。しかし利用者は、あいさつに何が書かれているかを知らないままチャットを始めることになります。あいさつには、返信までの時間や、用意しておくものなど、最初に知りたいことが書かれている場合もあります。

## 利用者への影響

パネルを見ている人には、この問題は見えません。開いたときにあいさつが、届いたときに返信が見えるからです。影響を受けるのは、読み上げられる内容に頼っている人です。

## 自動チェックとAIの限界

ページを走査するチェックには、ボタン、ラベルのある入力欄、文字の入った `div` が見えるだけで、問題は見つかりません。この問題は時間の中にしかありません。返信が届くにはメッセージを送る必要があり、実際のサイトで診断がメッセージを送ることは決してあってはなりません。本物のサポート担当者に届くからです。多くのチャットウィジェットはサービス提供元のスクリプトで、提供元のドメインから配信されるフレームの中に描かれることも多く、サイトのチームの誰もそのコードを読んでいません。チャットウィジェットを頼まれたAIアシスタントも、この形を書くことがあります。吹き出しが表示され、パネルがスクロールし、新しいメッセージが見える位置に保たれるという、一つ一つは完成して見える部品の組み合わせだからです。

## 修正前：あいさつも返信も読まないチャット

```html
<button type="button" class="chat-launcher">チャットで相談</button>
<section id="chat" class="chat-panel" aria-labelledby="chat-title" hidden>
  <div class="chat-header">
    <h2 id="chat-title">Birchwoodのチャット</h2>
    <button type="button" class="chat-close" aria-label="チャットを閉じる">×</button>
  </div>
  <div class="chat-messages">
    <p class="message from-us"><span class="visually-hidden">Birchwood：</span>こんにちは。ご注文についてお気軽にお尋ねください。</p>
  </div>
  <form class="chat-form">
    <label for="chat-input" class="visually-hidden">メッセージ</label>
    <input id="chat-input" autocomplete="off">
    <button>送信</button>
  </form>
</section>
```

```js
const launcher = document.querySelector('.chat-launcher');
const panel = document.getElementById('chat');
const messages = panel.querySelector('.chat-messages');
const input = document.getElementById('chat-input');
const socket = new WebSocket('wss://chat.example/birchwood');

// パネルは起動ボタンの代わりに開きます。
function openChat() {
  launcher.hidden = true;
  panel.hidden = false;
  input.focus();
}
function closeChat() {
  panel.hidden = true;
  launcher.hidden = false;
  launcher.focus();
}
function addMessage(text, from) {
  const bubble = document.createElement('p');
  const sender = document.createElement('span');
  bubble.className = `message from-${from}`;
  sender.className = 'visually-hidden';
  sender.textContent = from === 'us' ? 'Birchwood：' : 'あなた：';
  bubble.append(sender, text);
  messages.append(bubble);
}

launcher.addEventListener('click', openChat);
panel.querySelector('.chat-close').addEventListener('click', closeChat);
panel.addEventListener('keydown', (event) => {
  // IMEは変換の取り消しにEscapeを使うので、そのキーには反応しない。
  if (event.isComposing || event.keyCode === 229) return;
  if (event.key === 'Escape') closeChat();
});
panel.querySelector('.chat-form').addEventListener('submit', (event) => {
  event.preventDefault();
  socket.send(input.value);
  addMessage(input.value, 'you');
  input.value = '';
});
socket.addEventListener('message', (event) => addMessage(event.data, 'us'));
```

パネルを開くとフォーカスがメッセージ欄に移り、ラベルの「メッセージ」だけが読まれるため、あいさつは読まれません。会話はただの `div` なので、返信を追加しても変わるのは画面だけです。パネルのほかの部分は動きます。起動ボタンと閉じるボタンは名前のあるボタンで、Escapeでもパネルが閉じ、フォーカスはパネルに入り、閉じると起動ボタンに戻ります。

## 修正後：メッセージのログと、開いたときの読み上げ

```html
<button type="button" class="chat-launcher">チャットで相談</button>
<section id="chat" class="chat-panel" aria-labelledby="chat-title" hidden>
  <div class="chat-header">
    <h2 id="chat-title">Birchwoodのチャット</h2>
    <button type="button" class="chat-close" aria-label="チャットを閉じる">×</button>
  </div>
  <div class="chat-messages" role="log" aria-label="メッセージ">
    <p id="message-0" class="message from-us"><span class="visually-hidden">Birchwood：</span>こんにちは。ご注文についてお気軽にお尋ねください。</p>
  </div>
  <form class="chat-form">
    <label for="chat-input" class="visually-hidden">メッセージ</label>
    <input id="chat-input" autocomplete="off">
    <button>送信</button>
  </form>
</section>
```

```js
const launcher = document.querySelector('.chat-launcher');
const panel = document.getElementById('chat');
const messages = panel.querySelector('.chat-messages');
const input = document.getElementById('chat-input');
const socket = new WebSocket('wss://chat.example/birchwood');
let count = 0;

// パネルは起動ボタンの代わりに開き、メッセージ欄はそれまでのすべてのメッセージと一緒に読まれます。
function openChat() {
  launcher.hidden = true;
  panel.hidden = false;
  const ids = [...messages.children].map((message) => message.id);
  input.setAttribute('aria-describedby', ids.join(' '));
  // 読むのは1回だけ。メッセージ欄にフォーカスが戻るたびには読まない。
  input.addEventListener('blur', () => input.removeAttribute('aria-describedby'), { once: true });
  input.focus();
}
function closeChat() {
  panel.hidden = true;
  launcher.hidden = false;
  launcher.focus();
}
function addMessage(text, from) {
  const bubble = document.createElement('p');
  const sender = document.createElement('span');
  bubble.id = `message-${++count}`;
  bubble.className = `message from-${from}`;
  sender.className = 'visually-hidden';
  sender.textContent = from === 'us' ? 'Birchwood：' : 'あなた：';
  bubble.append(sender, text);
  messages.append(bubble);
}

launcher.addEventListener('click', openChat);
panel.querySelector('.chat-close').addEventListener('click', closeChat);
panel.addEventListener('keydown', (event) => {
  // IMEは変換の取り消しにEscapeを使うので、そのキーには反応しない。
  if (event.isComposing || event.keyCode === 229) return;
  if (event.key === 'Escape') closeChat();
});
panel.querySelector('.chat-form').addEventListener('submit', (event) => {
  event.preventDefault();
  socket.send(input.value);
  addMessage(input.value, 'you');
  input.value = '';
});
socket.addEventListener('message', (event) => addMessage(event.data, 'us'));
```

パネルを開くと、メッセージ欄の `aria-describedby` にすべてのメッセージのidが入るため、メッセージ欄はそれまでの会話と一緒に「メッセージ、編集テキスト。Birchwood：こんにちは。ご注文についてお気軽にお尋ねください。」と読まれます。後でもう一度開くと、最初からのすべてのメッセージが読まれます。idで指すのはログではなく各メッセージです。ログを指すと、説明が中身ではなく、ログの名前の「メッセージ」になることがあるからです。説明はフォーカスが最初にメッセージ欄を離れたときに外れるため、「送信」へ移って戻っても、会話がもう一度読まれることはありません。

`role="log"` は、会話をライブリージョンにします。初期値がpoliteなので、スクリーンリーダーは読み上げ中の内容を終えてから、末尾に追加された各メッセージを読み、フォーカスは動きません。ログは返信が届く前からページのHTMLにあり、そのためにブラウザーは各返信を変化として捉えられます。スクリーンリーダーが何の領域かを伝えられるよう、ログには名前を付けます。ここでは「メッセージ」です。各メッセージには送り手が視覚的に隠したテキストで入っているため、返信は「Birchwood：はい。発送前であれば『ご注文履歴』から変更できます。」と読まれます。

## 修正後：返信を伝える別の読み上げ領域と、開いたときの読み上げ

```html
<button type="button" class="chat-launcher">チャットで相談</button>
<section id="chat" class="chat-panel" aria-labelledby="chat-title" hidden>
  <div class="chat-header">
    <h2 id="chat-title">Birchwoodのチャット</h2>
    <button type="button" class="chat-close" aria-label="チャットを閉じる">×</button>
  </div>
  <div class="chat-messages">
    <p id="message-0" class="message from-us"><span class="visually-hidden">Birchwood：</span>こんにちは。ご注文についてお気軽にお尋ねください。</p>
  </div>
  <div id="chat-announcer" class="visually-hidden" aria-live="polite"></div>
  <form class="chat-form">
    <label for="chat-input" class="visually-hidden">メッセージ</label>
    <input id="chat-input" autocomplete="off">
    <button>送信</button>
  </form>
</section>
```

```js
const launcher = document.querySelector('.chat-launcher');
const panel = document.getElementById('chat');
const messages = panel.querySelector('.chat-messages');
const announcer = document.getElementById('chat-announcer');
const input = document.getElementById('chat-input');
const socket = new WebSocket('wss://chat.example/birchwood');
let count = 0;

// パネルは起動ボタンの代わりに開き、メッセージ欄はそれまでのすべてのメッセージと一緒に読まれます。
function openChat() {
  launcher.hidden = true;
  panel.hidden = false;
  const ids = [...messages.children].map((message) => message.id);
  input.setAttribute('aria-describedby', ids.join(' '));
  // 読むのは1回だけ。メッセージ欄にフォーカスが戻るたびには読まない。
  input.addEventListener('blur', () => input.removeAttribute('aria-describedby'), { once: true });
  input.focus();
}
function closeChat() {
  panel.hidden = true;
  launcher.hidden = false;
  launcher.focus();
}
function addMessage(text, from) {
  const bubble = document.createElement('p');
  const sender = document.createElement('span');
  bubble.id = `message-${++count}`;
  bubble.className = `message from-${from}`;
  sender.className = 'visually-hidden';
  sender.textContent = from === 'us' ? 'Birchwood：' : 'あなた：';
  bubble.append(sender, text);
  messages.append(bubble);
}
// 読み上げるのは返信だけです。1件ずつ新しい行にするため、続けて届いた返信も両方読まれます。
function announce(text) {
  const line = document.createElement('p');
  line.textContent = `Birchwood：${text}`;
  announcer.append(line);
  if (announcer.childElementCount > 5) announcer.firstElementChild.remove();
}

launcher.addEventListener('click', openChat);
panel.querySelector('.chat-close').addEventListener('click', closeChat);
panel.addEventListener('keydown', (event) => {
  // IMEは変換の取り消しにEscapeを使うので、そのキーには反応しない。
  if (event.isComposing || event.keyCode === 229) return;
  if (event.key === 'Escape') closeChat();
});
panel.querySelector('.chat-form').addEventListener('submit', (event) => {
  event.preventDefault();
  socket.send(input.value);
  addMessage(input.value, 'you');
  input.value = '';
});
socket.addEventListener('message', (event) => {
  addMessage(event.data, 'us');
  announce(event.data);
});
```

会話はそのままにし、その横の視覚的に隠したライブリージョンが、各返信を1回ずつ伝えます。「Birchwood：はい。発送前であれば『ご注文履歴』から変更できます。」と読まれます。この領域は最初から空の状態でHTMLにあるため、新しい行の追加はブラウザーが読み上げる変化になります。読まれるのはスクリプトが書き込んだ内容だけなので、利用者自身のメッセージは読み返されず、会話のほかの動き（過去のメッセージの読み込みや、一覧の描き直し）も読まれません。古い行の削除は読み上げられません。パネルを開いたときに会話がメッセージ欄と一緒に読まれることは、一つ目の修正と同じです。

## どの方法を選ぶか

どちらも、パネルを開いたときに会話を読み、フォーカスを動かさずにすべての返信を伝えます。ログはコードが少なく、W3Cがチャットの履歴について示している方法です。会話が名前のある一つの領域になり、二つの内容をそろえておく必要もありません。ただし、追加されたものはすべて読むため、送った瞬間の利用者自身のメッセージも読まれます。送信の確認として役立つと感じる人もいれば、うるさく感じる人もいます。会話が末尾にメッセージを足す以外のことをする場合は、別の読み上げ領域を選んでください。さかのぼってスクロールすると過去のメッセージを先頭に読み込む、メッセージのたびに一覧全体を描き直す、入力中の表示や既読の表示を一覧の中に入れる、といった場合です。ログはそれらもすべて読みます。別の領域は書き込んだ内容だけを伝えますが、その代わり、各返信の二つ目の写しを一つ目とそろえて管理する必要があります。

## チャットが外部サービスのものである場合

多くのチャットウィジェットは、サービス提供元のスクリプトです。起動ボタンもパネルも、提供元のドメインから配信されるフレームの中に描かれることが多く、その場合は自社のCSSやスクリプトから中には一切手が届きません。できることは次のとおりです。

- 導入する前に、「修正の確認」の手順でウィジェットを試す。提供元のデモページか、自社のテスト用ページで行います。
- ウィジェットの設定を確認する。数秒後に自動で開くパネルや、ページの上に飛び出すあいさつの吹き出しは無効にし、設定で選べるなら、起動ボタンにはアイコンだけでなく文字のラベルを表示します。
- 提供元にアクセシビリティ適合報告書（ACR）を求める。そのうえで、パネルを開いたときにあいさつやそれまでのメッセージが読まれるか、新しいメッセージがログかライブリージョンに入るか、起動ボタンと閉じる操作が名前のあるボタンか、Escapeでパネルが閉じて起動ボタンにフォーカスが戻るか、どのスクリーンリーダーで試したかを、具体的に尋ねます。
- 提供元のスクリプトが新着メッセージのイベントを提供している場合は、二つ目の修正のように、ページ側に用意した自前の読み上げ領域へ各返信を書き込めます。その前に、ウィジェット自身が読み上げていないかを確認してください。読み上げていれば、返信が2回ずつ聞こえます。
- うまくいかない点を、再現手順を添えて提供元に報告する。直るまでは、[アクセシビリティ方針](/ja/learn/accessibility-statement)に既知の問題として記載し、チャットの横に、メールアドレスなど別の問い合わせ方法を示します。

提供元のスクリプトは、自社のリリースと関係なく変わります。提供元の更新やウィジェットの設定の変更のたびに、確認を繰り返してください。

## 実装の判断

**領域を先に置く。** ログや読み上げ領域は、最初の返信より前にページにある必要があります。コンポーネントのフレームワークでは、パネルと一緒に描画し、最初のメッセージと一緒に現れる条件の中には決して入れません。メッセージと一緒に追加された領域は読み上げられません。仕組みは[画面に出ても読み上げられないステータスメッセージ](/ja/learn/status-messages)で説明しています。領域を含むパネルの一部に `aria-hidden="true"` を付けた場合も同じです。

**後から届くあいさつ。** 多くのウィジェットは、パネルを開くと入力中の表示を出し、少し後にあいさつを追加します。ログなら、そのあいさつは返信と同じように読み上げられます。別の読み上げ領域を使う場合は、あいさつもその領域に書き込みます。どちらの場合も、そのあいさつはまだメッセージ欄の説明に入っていないため、2回ではなく1回だけ読まれます。

**長い会話。** 履歴の長いパネルを開くと、メッセージ欄と一緒にそのすべてが読まれます。スクリーンリーダーの利用者はCtrlキーで読み上げを止め、読みたいところから読み進められます。会話が長くなりがちなら、メッセージ欄の説明には、前回パネルを開いてからのメッセージか、最後の数件だけを入れます。

**誰の発言かを伝える。** 左右に分かれた2色の吹き出しは、誰が書いたかを晴眼の利用者に伝えますが、ほかには何も伝えません。スニペットのように、各メッセージに送り手を視覚的に隠したテキストで入れるか、吹き出しの上に名前を表示します。

**雑音を入れない。** 入力中の表示を数秒ごとにログへ書き込むと、数秒ごとに読まれます。ログの外に表示するか、1回だけ伝えます。返信に `aria-live="assertive"` や `role="alert"` は使いません。利用者が読んでいる内容に割り込むからです。

**閉じている間に届いた返信。** パネルを閉じた後に届いた返信は、隠れた領域に入るため読み上げられません。代わりに起動ボタンで伝えます。ボタンの文字を「チャットで相談、新着メッセージ1件」のように変えます。返信を見せるために、パネルを自動で開くことはしません。

**新しいメッセージにフォーカスを移さない。** そうすれば読まれはしますが、利用者が次の質問を入力している途中で、カーソルがメッセージ欄から外れてしまいます。

**モーダルにするかどうか。** パソコンではパネルがページを操作不能にしないため、利用者はチャットしながらページを読めます。Tabでパネルを閉じずに外へ出てもかまいません。ただし、フォーカスが離れたときに閉じることもしません。それで何が起きるかは[Tabで閉じてフォーカスを見失う検索パネル](/ja/learn/panel-dismissal)を参照してください。パネルが画面全体を覆うことが多いスマートフォンでは、モーダルとして振る舞います。[背後にフォーカスが残るモーダル](/ja/learn/modal-focus-management)のように、`showModal()` でダイアログとして開きます。

**閉じるボタン。** ×は名前のある本物の `button` にします。クリック処理を付けた `span` で描くと、Tabでは届かず、パネルが起動ボタンの代わりに開くため、キーボードの利用者にはチャットを閉じる方法がなくなります。[Tabキーで到達できないdivのボタン](/ja/learn/native-buttons)と同じ問題です。

**起動ボタン。** `div` で描いた起動ボタンも同じように問題になり、アイコンだけの起動ボタンには、[名前がないアイコンだけのボタン](/ja/learn/icon-buttons)のように名前が必要です。隅に浮いている間は、フォーカス中の操作要素を覆うこともあります。[フォーカス中のボタンを一部隠すチャットボタン](/ja/learn/focus-not-covered)を参照してください。拡大表示では、起動ボタンや開いたパネルが、ほかの浮いた要素とともに画面のずっと大きな部分を占めます。[拡大するとページを覆う、浮いた購入バー](/ja/learn/floating-buttons)を参照してください。パネル自体はページの最後、フッターの後に追加されることがよくあります。ここでの修正は開いたときにフォーカスをパネルへ移しますが、パネルを起動ボタンの次に置く方法は[フォーカス順の最後に回る、フローティングボタンとパネル](/ja/learn/floating-order)で扱っています。

## 修正の確認

1. 何も送る前にDOMを調べる。メッセージ一覧が `role="log"` と名前を持つか、その横に空の `aria-live="polite"` の領域があり、パネルが開いている間、どちらも `aria-hidden="true"` や `display: none` の中になく、各メッセージにidがあることを確認する。
2. スクリーンリーダーを起動し、マウスを使わずに、Tabで起動ボタンへ移動してEnterを押す。フォーカスがメッセージ欄にあり、「メッセージ、編集テキスト」に続いてあいさつが読まれることを確認する。
3. メッセージを送って待つ。返信が送り手とともに1回だけ読まれ、カーソルがメッセージ欄に残ることを確認する。チャットを閉じてもう一度開き、メッセージ欄がそれまでのすべてのメッセージと一緒に読まれることを確認する。
4. 返信が続けて2件届く質問や、入力中の表示が出る質問を送る。各返信が1回ずつ読まれ、入力中の表示が何度も読まれないことを確認する。
5. Tabで閉じるボタンへ移動し、「チャットを閉じる、ボタン」と読まれることを確かめてEnterを押す。パネルが閉じ、フォーカスが起動ボタンにあることを確認する。もう一度開いてEscapeを押し、同じになることを確認する。

OS、ブラウザーと支援技術のバージョン、ビルド、実施日、各手順の実際の結果を記録してください。チャットウィジェット、その提供元のスクリプト、または設定を変更した際は、再度確認します。

## 制限

このサンプルは、ページに重なるチャットのパネルのための出発点であり、どのウィジェットでも同じ結果になることを保証するものではありません。ライブリージョンの動きは、スクリーンリーダーとブラウザーによって異なります。特に、隠してから表示する領域や、フレームの中の領域では差が出るため、利用者が使うものでテストしてください。入力欄の説明がいつ読まれるかも、スクリーンリーダーとその設定によって異なります。多くは入力欄の名前の後に読みますが、少し間を置いてから読むものや、ヒントの設定が有効なときだけ読むものもあります。チャットの各返信がステータスメッセージに当たるかは、診断する人によって判断が分かれます。4.1.3の解説書は `role="log"` を処理の進み具合を伝える場面に挙げており、その達成方法はチャットを例にしています。どの基準で数えるとしても、読み上げられない返信は、スクリーンリーダーの利用者を待たせ続けます。Enterで送信するメッセージ欄には、別の問題もあります。IMEの変換を確定するEnterで、言葉が確定する前にメッセージが送られることがあります。[IMEの変換確定のEnterで送信される入力欄](/ja/learn/ime-composition)を参照してください。IMEは変換の取り消しにEscapeも使うため、上のEscapeの処理は、変換中に押されたキーには反応しないようにしています。

---

このガイドをコーディングエージェントで使う: MCP https://easeweb.dev/mcp（設定は https://easeweb.dev/ja/services/developers）
