マルチステップフォーム
multi-step-form は公認のウィザードです: 現在のステップと、入力
されたすべてのドラフトをサーバが所有します。クライアントは 1 つの
領域(ステップ全体の outerHTML スワップ)、ステップごとに 1 つの
フォーム、そしてレスポンスのたびにサーバが再描画する
hc-stepper です —
クライアント側のステップ状態なし、ステップをまたいで蓄積する隠し
フィールドなし、htmx 以外の JavaScript なし。
マークアップのバージョニングポリシーの
下で安定しています。
別名: ウィザード、ステップフォーム。
下のウィザードを操作してみてください — Next はステップを
バリデートし(ネイティブの required に加え、サーバが権威として
422 + field-errors フラグメントで応答)、Back は空欄があっても
決してバリデートしません。最終ステップは確認ページへリダイレクト
します(戻るリンク付き)。エンドポイントはサーバレスポンス契約を
実装した名前空間付きデモで、api/recipes/multi-step-form/ 配下に
あります。
デモだけの自由が 1 つあります: ステートレスなドキュメント用
エンドポイントは、各ステップで hidden input を通じてドラフトを
引き回します — 実際のサーバはドラフトをサーバ側(セッション、
ドラフト行、署名付き Cookie)に保持します。冒頭の「hidden
フィールドがステップをまたいで蓄積しない」ルールが指しているのは
そちらです。
マークアップ(中間ステップ)
Section titled “マークアップ(中間ステップ)”<section id="wizard"> <ol class="hc-stepper"> <li class="hc-stepper__step" data-state="complete"> <span class="hc-stepper__marker" aria-hidden="true">✓</span> <span class="hc-stepper__label">Account <span class="hc-sr-only">(completed)</span></span> </li> <li class="hc-stepper__step" aria-current="step"> <span class="hc-stepper__marker" aria-hidden="true">2</span> <span class="hc-stepper__label">Profile</span> </li> <li class="hc-stepper__step"> <span class="hc-stepper__marker" aria-hidden="true">3</span> <span class="hc-stepper__label">Review</span> </li> </ol>
<form method="post" action="/signup/2" data-hx-post="/signup/2" data-hx-target="#wizard" data-hx-swap="outerHTML" data-hx-disabled-elt="find button[type=submit]"> <div id="wizard-errors"></div> …step 2 fields, pre-filled from the draft… <button class="hc-button" type="submit" name="nav" value="back" formnovalidate>Back</button> <button class="hc-button" data-variant="primary" type="submit" name="nav" value="next">Next</button> </form></section>ナビの両方向は同じフォームの名前付き送信(name="nav")です。
そのため htmx とネイティブの非 JS パスは同一のボディを送り、現在の
ステップの入力は常にナビゲーションと一緒に運ばれます。
サーバレスポンス契約
Section titled “サーバレスポンス契約”| ケース | レスポンス |
|---|---|
nav=next、有効 | 200 — 次のステップの完全な #wizard フラグメント: ステッパー再描画(このステップは ✓ に)、フィールドはドラフトからプリフィル |
nav=back | 200 — 前のステップのフラグメント。POST されたフィールドはバリデーションなしでドラフトへマージ済み |
nav=next、無効 | 422 + HX-Retarget: #wizard-errors + HX-Reswap: innerHTML に field-errors フラグメント — ステップは再描画されず、入力途中の値はそのまま |
最終の nav=next | mutating-form の完了: 204 + HX-Redirect(htmx)/ 303 Location(非 JS) |
422 には、mutating form
の標準の 1 行 beforeSwap 許可が必要です。
ステップの URL は本物です(/signup/1、/signup/2、…): リロードや
ディープリンクはサーバが考える現在のステップに着地し、JavaScript
なしではウィザードは単に古典的なページ群になります。
アクセシビリティ
Section titled “アクセシビリティ”ステッパーは状態を構造的に伝えます(<ol> + aria-current="step" +
sr-only の (completed))。formnovalidate がキーボードユーザーを
閉じ込めから守ります。422 パスは field-errors のフォーカス管理を
保ちます。スワップ後に読み上げ順が自然に再開するよう、各フラグメントの
先頭にステップ見出しを置いてください。
プログレッシブエンハンスメント
Section titled “プログレッシブエンハンスメント”本物のステップ URL(/signup/1、/signup/2、…)+ method/action +
formnovalidate により、ウィザードはクラシックなページ群へ
デグレードします:
- JavaScript なしでは、各名前付き送信(
nav=next/nav=back)が 同じボディをネイティブに POST し、サーバは次の(エラー時は同じ) ステップのフルページで応答します — htmx レイヤーはその ページ読み込みを領域スワップに変えるだけです。 - Back は依然としてバリデートしません:
formnovalidateは htmx では なくネイティブの仕組みです。 - リフレッシュもディープリンクも、どちらの経路でもサーバが考える 現在ステップに着地します。ステップは URL に、ドラフトはサーバに あるからです。
- CSRF: htmx 側は meta 規約で、 ネイティブ側にはフレームワークの隠しフィールドが必要です — mutating-form と 同様です。
- ドラフトの保存先(セッション、ドラフト行)はフレームワークの 関心事です — 契約が要求するのは、無損失な 戻る→次へ の往復と、 バリデートされないドラフトだけです。
- file input はドラフトを往復しません — ファイルは別途 アップロードし(file-upload)、 保存済みアップロードへの参照をドラフトに持たせてください。
- フローが再訪を許すなら、完了済みステップはそのステップ URL への リンクにできます。
ここでの主張 — ステッパーの再描画、無損失なドラフト往復、無効な
ステップからの「戻る」脱出、値を保つ 422、完了 — は、実 htmx の
ブラウザテスト(test-browser/multi-step-form.spec.mjs)で固定されて
います。
- ステッパー — ゼロ JS のインジケーター。
- ミューテーションフォームレシピ — 完了時の分岐と
422の許可。 - フィールドエラーレシピ — バリデーションフラグメント。