第25章 バリデーションエラーとフォーム UX
この章のねらい
フォームは、Hotwire でいちばんつまずきやすいところです。第8章で「成功は redirect、失敗は 422 でフォーム再描画」という契約を学びました。この章では、その契約を土台に、フォームの体験(UX)とアクセシビリティを仕上げます。
エラーをその場に出す、送信中を示す、二重送信を防ぐ、そしてスクリーンリーダーにエラーを正しく伝える。実務のフォームに必要な要素を、Relay のタスク作成・編集で組み立てます。
25.1 完成イメージ
入力が不正なら、ページ遷移せずに、フォームのその場にエラーが表示されます。フォーカスはエラーの位置に移り、スクリーンリーダーにもエラーが伝わります。成功すれば、一覧とフラッシュが更新されます。送信中はボタンが押せなくなり、二重送信が防がれます。
25.2 この章の選択
主役はサーバー側のバリデーションです。Rails のモデルが検証し、失敗を 422 で返します。Stimulus は、あくまで補助です。送信中の表示や文字数カウンタなど、体験をよくする役割に徹し、サーバー検証の代わりにはしません。
25.3 失敗時に 422 を返す
第8章・第16章で見たとおり、保存に失敗したら 422 を返します。コードでは status: :unprocessable_entity です。
if @task.save
# 成功時の応答(25.5)
else
# 失敗時: フォームを 422 で返す
end
Turbo は、422 で返ってきた HTML を画面に反映します。Drive の非 GET 送信にリダイレクトなしの 200 HTML を返すと再描画されません。Frame の 200 HTML や Turbo Stream の 200 応答は、この制約とは別です(第8章)。失敗は必ず 422、と徹底します。
25.4 フォームを同じ場所に戻す
失敗時は、入力中のフォームを、エラー付きで同じ場所に戻します。やり方は、これまでに学んだ 2 つのどちらかです。
- インライン編集のように frame の中で完結しているなら、frame に 422 のフォームを返します(第12章)。
- 一覧ページの inline フォームなら、Turbo Streams でフォーム領域を差し替えます(第16章)。
どちらも、ページ遷移せずに、エラーの付いたフォームがその場に現れ、ユーザーは入力をやり直せます。
25.5 成功時の更新
第24章の完成版の stream テンプレートを使います。フォームを空にし、フラッシュを更新し、一覧へ変更通知を渡します。検索・ページ分割のある画面に無条件で prepend すると、条件に合わないタスクまで入るので、完成版では行を直接追加しません。
25.6 Stimulus で補助する
ここから、体験をよくする補助です。
送信中の表示と二重送信の防止は、実は Turbo に組み込みの助けがあります。Turbo はフォーム送信中、送信ボタンを自動で無効にします。さらに、ボタンに data-turbo-submits-with を付けると、送信中だけ文言を差し替えられます。
<%= form.submit "作成", data: { turbo_submits_with: "作成中…" } %>
これだけで、送信中はボタンが「作成中…」になり、押せなくなります。二重送信の多くは、これで防げます。
文字数カウンタのような入力中の補助は、第20章で作った Stimulus のコントローラを使います。これらは入力体験を助けるもので、サーバー検証とは別物です。
25.7 a11y
フォームのアクセシビリティは、この章の主戦場です。エラーを「見える」だけでなく「伝わる」ようにします。
- エラーサマリ。フォームの先頭に、エラーの一覧を出します。
role="alert"を付けると、表示時に読み上げられます。さらに、再描画後にここへフォーカスを移すと、ユーザーはすぐエラーに気づけます。フォーカス移動は、第19章で作ったautofocusコントローラ(connectでthis.element.focus()する)を再利用します。connectは frame の差し替えでも Turbo Streams の差し込みでも呼ばれるので、新たなエラーサマリ要素が接続されたときにフォーカスが移ります。ルート要素を残す morph では connect が再実行されるとは限りません(HTML のautofocus属性は frame/ページの描画後には効きますが、stream で差し込んだ要素には効かないため、connect方式に寄せます)。 - 各フィールドの紐づけ。エラーのあるフィールドに
aria-invalid="true"を付け、aria-describedbyでそのフィールドのエラーメッセージと結びつけます。aria-invalidは、エラーがないときは"false"(妥当である、という意味)になります。これは仕様どおりで問題ありません。
app/views/tasks/_form.html.erb
<% context = local_assigns.fetch(:context, "page") %>
<% scope_id = "#{context}_#{dom_id(task)}" %>
<%= form_with model: task, namespace: scope_id,
data: { turbo: context != "page" } do |form| %>
<%= hidden_field_tag :form_context, context %>
<% if task.errors.any? %>
<div role="alert" tabindex="-1" data-controller="autofocus" class="error-summary">
<%= pluralize(task.errors.count, "件") %>のエラーがあります。
<ul>
<% task.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= form.label :project_id, "プロジェクト" %>
<%= form.collection_select :project_id, Project.order(:name), :id, :name, prompt: "選択してください" %>
</div>
<div>
<%= form.label :title, "タイトル" %>
<%= form.text_field :title,
"aria-invalid": task.errors[:title].any?,
"aria-describedby": ("#{scope_id}_title_error" if task.errors[:title].any?) %>
<% if task.errors[:title].any? %>
<span id="<%= scope_id %>_title_error" class="field-error"><%= task.errors[:title].to_sentence %></span>
<% end %>
</div>
<div data-controller="counter">
<%= form.label :description, "説明" %>
<%= form.text_area :description, data: { counter_target: "field", action: "input->counter#count" } %>
<span data-counter-target="output">0</span> 文字
</div>
<div>
<%= form.label :status, "ステータス" %>
<%= form.select :status, Task.statuses.keys, selected: task.status %>
</div>
<div>
<%= form.label :assignee_id, "担当者" %>
<%= form.collection_select :assignee_id, User.order(:name), :id, :name, prompt: "未割り当て" %>
</div>
<div>
<%= form.label :due_on, "期限" %>
<%= form.date_field :due_on, data: { controller: "datepicker", action: "turbo:before-cache@document->datepicker#teardown" } %>
</div>
<fieldset>
<legend>タグ</legend>
<%= form.collection_check_boxes :tag_ids, Tag.order(:name), :id, :name %>
</fieldset>
<%= form.submit "保存", data: { turbo_submits_with: "送信中…" } %>
<% end %>
目で見るユーザーには色や位置でエラーが分かりますが、スクリーンリーダーのユーザーには、こうした属性がないと伝わりません。
25.8 URL は基本不変にする
フォームのやり直しでは、URL は変えません。失敗しても成功しても、ユーザーは同じ画面で操作を続けます。検索(第23章)のように URL に状態を残す必要はありません。advance のような URL 操作は、フォームには使わないのが基本です。
25.9 テスト
フォームは、次の 3 つを確かめます。
- 無効な入力で、エラーが同じ画面に出る(ページ遷移しない)
- 有効な入力で、成功し、一覧が更新される
- 二重送信が防がれる(送信中にボタンが無効になる)
System Test で、無効入力時にフォームが残りエラーが出ること、有効入力時に一覧へ追加されることを確認します。第16章で書いたテストに、エラー表示の確認(role="alert" の存在など)を足すとよいでしょう。
25.10 アンチパターン
- 失敗時に 422 ではなく 200 で render する。Drive の非 GET 送信では HTML が再描画されません。Frame / Stream でも成功と失敗を区別するため 422 にします(第8章)。最頻出のつまずきです。
- JavaScript の検証だけにする。クライアント側の検証は補助です。サーバー検証を省くと、簡単にすり抜けられます。
- エラー時にフォーカスが迷子になる。再描画後、フォーカスがどこにあるか分からないと、キーボードやスクリーンリーダーのユーザーが迷います。エラーサマリへフォーカスを移します。
第25章では、フォームのエラー表示・送信中・二重送信・a11y を仕上げました。次の第26章では、モーダル・タブ・ドロップダウンを題材に、「サーバーの状態が要るか」で実装が分かれることを学びます。
25.11 同じモデルのフォームを複数置くとき
完成版のフォームは context(page / inline / edit / modal)と dom_id(task) を namespace に使います。これにより一覧の新規フォームとモーダルが同時に存在しても、入力 ID と aria-describedby が重複しません。エラーサマリには件数だけでなく各エラーの本文を表示します。ページ形式の送信はリダイレクトを使うので、25.8 の「URL 不変」は inline / frame / stream の部分更新に限る説明です。
参考資料
- Turbo Drive(Handbook、フォーム送信): https://turbo.hotwired.dev/handbook/drive
- Turbo の属性リファレンス: https://turbo.hotwired.dev/reference/attributes
- MDN: ARIA(aria-invalid / aria-describedby / role=alert): https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA