# 読み上げられないお礼の文に差し替わるフォーム

Source: https://easeweb.dev/learn/thank-you-focus
Topics: ライブ更新 > フォーム送信の結果が読み上げられる (WCAG 4.1.3)
The fix: お礼の文がフォームの場所に入ったらすぐ、そこへフォーカスを移します。スクリプトからフォーカスを移せるよう、お礼の文には tabindex="-1" を付けます。またはフォームをページに残し、お礼の文をその下のステータス行に書き込みます。
Test with: screen reader, keyboard
References: https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F103 https://www.w3.org/WAI/tutorials/forms/notifications/ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/tabindex https://html.spec.whatwg.org/multipage/infrastructure.html#node-remove-focus-fixup

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

## 場面

書店のサイトのフッターに、メールマガジンの登録欄があります。「メールマガジンに登録」の見出し、「メールアドレス」の欄、「登録する」ボタンです。送信は裏側で処理され、失敗したときは下のステータス行で知らせる作りになっています。成功したときの見せ方は違います。欄とボタンが消え、その場所に「ありがとうございます。確認メールをお送りしました。」と表示されます。すっきりしていて、手続きが終わったことも伝わります。ところがスクリーンリーダーの利用者は、「登録する」を押しても何も聞こえません。操作していたボタンはもうなく、フォーカスの行き場がありません。読み進めようとすると、ページの別の場所に出てしまうこともあり、登録できたかどうかは分からないままです。

## 確認できる問題

「ありがとうございます。確認メールをお送りしました。」は、フォーカスを受けない限りステータスメッセージです。達成基準4.1.3は、ステータスメッセージを役割やプロパティで支援技術に伝え、フォーカスを動かさなくても読み上げられるようにすることを求めています。フォーカスを受ける内容は、この基準の対象外です。フォーカスを受けることはコンテキストの変化にあたり、内容もフォーカスが届いたときに読まれるからです。つまり、フォーカスを移すのも基準を満たす方法の一つです。この例のお礼の文は、どちらの方法もとっていません。役割のない新しい `p` で、ライブリージョンにも入っておらず、フォーカスも移されていません。これが失敗例F103です。しかも、フォーカスのあった「登録する」ボタンはフォームと一緒に削除されます。するとHTML標準のfocus fixupの手順が働き、フォーカスはページのbodyへ移ります。スクリーンリーダーには、そこで新しく読むものがありません。

## 利用者への影響

お礼の文はフォームのあった場所に表示されるため、フッターを見ている人には見えます。見落とすのは、音声や点字で伝わる内容に頼っている人です。操作していたボタンまで、フォームと一緒に失います。

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

スキャンは、ラベルのある欄、名前のあるボタン、ステータス行を見て、フォームを合格にします。本番のサイトで送信すれば実際に誰かが登録されてしまうため、フォームは送信しません。そのため、フォームがお礼の文に差し替わるところを見ることはありません。EaseWebの監査は、フォームがあり、ライブリージョンがまったくないページを手がかりとして報告しますが、このページには失敗用のステータス行があるため、この手がかりは出ません。フォームが差し替わることを、マークアップから判断できるチェックもありません。お礼の文を読み上げさせるよう頼まれたAIアシスタントは、新しい段落を作るときに `role="status"` を付けることがあります。これでは領域が文言と一緒に現れ、読まれないことがあります。フォームをライブリージョンで包む例もありますが、お礼の文は読まれても、フォーカスはページのbodyに残ります。スクリーンリーダーを動かしたまま実際に送信して、初めて何が読まれ、フォーカスがどこへ行くか分かります。

## 修正前：フォームと入れ替わるお礼の文

```html
<section aria-labelledby="signup-title">
  <h2 id="signup-title">メールマガジンに登録</h2>
  <form id="signup">
    <label for="signup-email">メールアドレス</label>
    <input id="signup-email" name="email" type="email" autocomplete="email" required>
    <button>登録する</button>
  </form>
  <p id="signup-message" role="status"></p>
</section>
```

```js
const signup = document.getElementById('signup');
const message = document.getElementById('signup-message');

// 行を空にしてから少し待って書き込む。同じ文言を2回書き込んでも、変化として伝わるように。
function announce(text) {
  message.textContent = '';
  setTimeout(() => (message.textContent = text), 100);
}

signup.addEventListener('submit', async (event) => {
  event.preventDefault();
  try {
    await subscribe(signup.email.value);
    const thanks = document.createElement('p');
    thanks.textContent = 'ありがとうございます。確認メールをお送りしました。';
    signup.replaceWith(thanks);
  } catch {
    announce('登録できませんでした。もう一度お試しください。');
  }
});
```

失敗は伝わります。ステータス行に書き込まれ、フォームもフォーカスのあるボタンも残るからです。抜けているのは成功のときです。新しい段落には役割がなく、フォーカスも移されないため、読まれません。さらに `replaceWith()` がフォーカスのあった「登録する」ボタンを削除するため、フォーカスはページのbodyに落ちます。

## 修正後：フォーカスを受けるお礼の文

```html
<section aria-labelledby="signup-title">
  <h2 id="signup-title">メールマガジンに登録</h2>
  <form id="signup">
    <label for="signup-email">メールアドレス</label>
    <input id="signup-email" name="email" type="email" autocomplete="email" required>
    <button>登録する</button>
  </form>
  <p id="signup-message" role="status"></p>
</section>
```

```js
const signup = document.getElementById('signup');
const message = document.getElementById('signup-message');

// 行を空にしてから少し待って書き込む。同じ文言を2回書き込んでも、変化として伝わるように。
function announce(text) {
  message.textContent = '';
  setTimeout(() => (message.textContent = text), 100);
}

signup.addEventListener('submit', async (event) => {
  event.preventDefault();
  try {
    await subscribe(signup.email.value);
    const thanks = document.createElement('p');
    thanks.tabIndex = -1;
    thanks.textContent = 'ありがとうございます。確認メールをお送りしました。';
    signup.replaceWith(thanks);
    // フォーカスのあったボタンは消えたので、代わりに現れたものへ渡す。
    thanks.focus();
  } catch {
    announce('登録できませんでした。もう一度お試しください。');
  }
});
```

デザインはそのままです。お礼の文には `tabindex="-1"` を付けます。Tabの順序には加えずに、スクリプトからフォーカスを移せるようにするためです。ページに入れた直後にフォーカスを移すので、スクリーンリーダーはフォーカスが届いたときに読み上げ、次のTabもそこから、フォームの後ろへ続きます。位置を見失う人はいません。失敗のときは変わりません。フォームが残り、フォーカスは「登録する」に残り、何が起きたかはステータス行が伝えます。

## 修正後：フォームを残し、お礼の文をステータス行に書き込む

```html
<section aria-labelledby="signup-title">
  <h2 id="signup-title">メールマガジンに登録</h2>
  <form id="signup">
    <label for="signup-email">メールアドレス</label>
    <input id="signup-email" name="email" type="email" autocomplete="email" required>
    <button>登録する</button>
  </form>
  <p id="signup-message" role="status"></p>
</section>
```

```js
const signup = document.getElementById('signup');
const message = document.getElementById('signup-message');

// 行を空にしてから少し待って書き込む。同じ文言を2回書き込んでも、変化として伝わるように。
function announce(text) {
  message.textContent = '';
  setTimeout(() => (message.textContent = text), 100);
}

signup.addEventListener('submit', async (event) => {
  event.preventDefault();
  try {
    await subscribe(signup.email.value);
    announce('ありがとうございます。確認メールをお送りしました。');
  } catch {
    announce('登録できませんでした。もう一度お試しください。');
  }
});
```

フォームはもう差し替えません。お礼の文は、失敗を伝えているのと同じステータス行に入るため、スクリーンリーダーは読み上げ中の内容を終えてから読み、フォーカスは「登録する」から動きません。デザインは変わります。登録後も欄とボタンが画面に残り、その下にお礼の文が出ます。その代わり、どのアドレスで登録したかが見え、直したり、別のアドレスを登録したりできます。これは[読み上げられないフォームの送信結果](/ja/learn/form-results)の修正と同じです。

## どの方法を選ぶか

どちらもお礼の文を読み上げさせ、消えたボタンにフォーカスを置き去りにしません。デザイン上どうしてもフォームを差し替えるなら、お礼の文へフォーカスを移します。スクリプト2行で済み、見た目は描かれたとおりです。デザインを変えられるなら、フォームを残します。フォーカスは動かず、一つのステータス行で成功も失敗も伝えられ、アドレスも画面に残って確かめられます。どちらが良いかはサイトによります。フォームを消す必要があるかどうかで決まります。

## 実装の判断

**メッセージ全体にフォーカスを移します。** アイコン、「完了」とだけ書いた見出し、ページの先頭ではなく、結果を文で伝えている要素にフォーカスを移します。お礼の文は一度で読み切れる短さにします。見出しが必要なら、「登録が完了しました」のように見出しで結果を伝え、その見出しにフォーカスを移します。

**新しいマークアップの描画後に。** コンポーネントのフレームワークでは、成功の分岐でフォームを `{#if}` の中で入れ替えたり、フッター全体を描き直したりすることがよくあります。これも `replaceWith()` と同じく、フォーカスのあるボタンを削除します。新しい内容がページに入ってから（Svelteの `tick()`、Reactのエフェクト、Vueの `nextTick()` のあとで）フォーカスを移し、あとの描き直しでフォーカスした要素が置き換わらないことも確かめます。

**フォーカスの枠。** キーボードで操作したあとは、フォーカスを受けた段落の周りに、たいていのブラウザーがフォーカスの枠を描きます。消さないでください。画面を見ながらキーボードで操作する人には、この枠がいまの位置の目印になります。マウスでクリックしたあとは、通常は表示されません。

**失敗のときはそのまま。** 送信に失敗したらフォームを差し替えてはいけません。もう一度送るためにフォームが必要だからです。どちらの修正でも、フォーカスは「登録する」に残し、失敗はステータス行に書き込みます。この行と、メッセージの前に行を空にする理由は、[読み上げられないフォームの送信結果](/ja/learn/form-results)で説明しています。

**埋め込みのフォーム。** メール配信サービスやマーケティングサービスのフォームには、フォームを独自のお礼の文に差し替えるスクリプトが付いてくることがあります。自分のページに埋め込んだ状態で確認してください。スクリプトを変えられない場合は、お礼のページへ移動させる設定がないか確かめます。新しいページなら、スクリーンリーダーが新しいページとして伝えます。

**修正にならないもの。** 新しい段落を作るときに `role="status"` や `aria-live` を付けると、領域が文言と一緒に現れることになり、読まれないことがよくあります。この失敗は[画面に出ても読み上げられないステータスメッセージ](/ja/learn/status-messages)で扱っています。フォームをライブリージョンで包んでその中で差し替えると、お礼の文は読まれますが、フォーカスはページのbodyに落ちたままです。

## 修正の確認

1. スクリーンリーダーを有効にし、正しいアドレスで送信する。お礼の文が一度読まれる。フォーカスが届いて読まれるか、フォーカスは「登録する」に残ったままステータス行から読まれる。
2. Tabを押す。フォーカスはフォームの次の操作要素か、「登録する」の次の操作要素へ進み、ページの先頭には戻らない。キーボードで操作したとき、フォーカスがお礼の文か「登録する」に表示される。
3. 開発者ツールでオフラインにして送信する。フォームは残り、失敗がステータス行から読まれ、フォーカスは「登録する」に残る。
4. 実際のページで送信し、コンソールで `document.activeElement` を確認する。お礼の文か「登録する」ボタンで、`body` にはならない。

OS、ブラウザーと支援技術のバージョン、ビルド、実施日、各手順の実際の結果を記録してください。フォームのコンポーネント、スクリプト、埋め込みを変更した際は、再度確認します。

## 制限

フォーカスを受けた段落で何を読むか、削除された要素のあとどこから続けるかは、スクリーンリーダーやブラウザーによって異なります。このパターンは、成功するとその場で差し替わる短いフォーム一つを扱っています。ページに残って結果を伝えるフォームは[読み上げられないフォームの送信結果](/ja/learn/form-results)で、新しいページや画面へ移るフォームは[読み上げられないSPAの画面遷移](/ja/learn/route-changes)で扱っています。埋め込みのフォームも含め、実際のフォームを、利用者が使うスクリーンリーダーで確認してください。

---

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