タブ化したフォームで「ボタンが反応しない」バグ — 隠れた required フィールドが submit を止めていた
経緯
サイト編集モーダルは、サイト名・カテゴリー・SSH 接続情報・WordPress インストール場所など項目が増え続け、1 枚の縦長フォームでは編集したい項目まで何度もスクロールする必要が出ていた。整理のため「📋 登録情報 / 🖥 SSH 関連 / 🔵 WordPress 情報」の 3 タブに分割したが、この変更が思わぬ形でフォーム送信そのものを壊した。
タブ化で起きたこと
タブの実装自体は単純だ。タブごとに <div class="site-tab-content" data-tab="..."> で項目を区切り、CSS で表示を切り替える。
.site-tab-content { display: none; }
.site-tab-content.active { display: block; }
アクティブでないタブは display: none で画面から消える。ここまではよくある実装で、見た目上は問題なく動く。
問題が出たのは、非表示タブに required 属性の付いたフィールドがあり、かつユーザーがそのフィールドを埋めないまま別のタブで保存ボタンを押したときだった。ボタンを押しても何も起きない。エラーメッセージも出ない。フォームが固まったように見える。
原因: ブラウザは「見えない」required フィールドにエラーを出せない
HTML5 のフォームバリデーションは、required などの制約を持つフィールドが条件を満たしていないと、ブラウザが自動的に submit イベントの発火をブロックし、該当フィールドにフォーカスしてエラー吹き出し(reportValidity() 相当の標準 UI)を表示する。
補足:
reportValidity()は HTML5 の Constraint Validation API が提供するメソッドで、フォーム要素の入力内容が制約(required・pattern 等)を満たしているか検証し、満たしていなければブラウザ標準のエラー吹き出しを表示する。
ところが、その対象フィールドが display: none で非表示のタブの中にあると、ブラウザはエラー吹き出しを表示する場所を持てない。結果として、ブラウザは「validation に失敗したので submit をブロックする」ところまでは律儀に実行するが、エラーの可視化ができないまま黙って止まる。ユーザーからは「ボタンを押しても反応しない」としか見えない。
タブ化前は全項目が同一画面上にあったため、この問題は表面化しなかった。タブという「見せる範囲を制限する UI」を導入したことで、ブラウザの標準バリデーションが前提としていた「invalid なフィールドは常に見える」という暗黙の条件が崩れた。
対策: novalidate + JS 主導のバリデーション
対策は、ブラウザの自動バリデーションに任せるのをやめ、<form novalidate> を付けて自動ブロックを無効化し、submit イベントを必ず発火させたうえで、JS 側で validation を完全に制御することだった。
async function saveSite(e) {
e.preventDefault();
const form = e.target;
// checkValidity() で失敗したら、最初のエラー要素 (:invalid) が
// 属するタブへ自動切替してから focus + reportValidity() を呼ぶ。
if (!form.checkValidity()) {
const firstInvalid = form.querySelector(':invalid');
if (firstInvalid) {
const tabContent = firstInvalid.closest('.site-tab-content');
if (tabContent && tabContent.getAttribute('data-tab')) {
switchSiteTab(tabContent.getAttribute('data-tab'));
}
// タブ切替直後だとフォーカス・スクロールが効かないことが
// あるので、次フレームで reportValidity を呼ぶ。
requestAnimationFrame(() => {
try { firstInvalid.focus({ preventScroll: false }); } catch (_) {}
firstInvalid.reportValidity();
});
}
return; // バリデーション失敗で送信中止
}
// ...以降、正常な保存処理
}
ポイントは 2 つある。
novalidateは制約自体を無効にするわけではない。requiredやpatternといった属性は引き続き有効で、checkValidity()や:invalid疑似クラスによる判定はそのまま機能する。無効になるのはブラウザの「自動で submit をブロックしてエラー表示する」挙動だけだ。:invalidな最初の要素を探し、そのタブへ強制的に切り替えてからreportValidity()を呼ぶ。タブを切り替えてフィールドを可視化した状態でエラー表示を発火させるため、ブラウザは問題なく吹き出しを描画できる。タブ切替直後は描画が済んでいないことがあるため、requestAnimationFrameで次フレームまで待ってからfocus()とreportValidity()を呼んでいる。
実例: SSH プロファイルの二択必須化
この仕組みが実際に役立った例が、SSH プロファイルの必須化だ。サイト編集モーダルには「SSHを使用しない(ブラウザ更新のみ)」というチェックボックスがあり、これをチェックしなければ SSH プロファイルの選択が必須になる。この二択のどちらも満たさない状態(プロファイル未選択かつチェックも未 ON)を保存させないようにしたい。
function toggleSSHFields() {
const isBrowserOnly = document.getElementById('browser_only_check').checked;
const profileSelect = document.getElementById('server_profile_select');
if (isBrowserOnly) {
// ブラウザ更新のみ選択時はプロファイル不要
profileSelect.value = "";
profileSelect.removeAttribute('required');
} else {
// SSH 利用時はプロファイル必須
profileSelect.setAttribute('required', '');
}
}
チェックボックスの状態に応じて required 属性を動的に付け外しし、あとは前述の saveSite の cross-tab validation 機構に任せる。プロファイル未選択のまま保存しようとすると、「SSH関連」タブが自動的に開き、プロファイルの選択欄にエラー吹き出しが出る。ユーザーは「なぜ保存できないか」を探し回る必要がない。
副次的な効果: ARIA によるアクセシビリティ
タブ化にあわせて role="tablist" / role="tab" / role="tabpanel" と aria-selected / aria-controls / aria-labelledby を付与した。
<div class="site-tabs" role="tablist" aria-label="サイト編集タブ">
<button type="button" class="site-tab-btn active" role="tab"
id="site-tab-btn-info" aria-selected="true"
aria-controls="site-tab-panel-info">登録情報</button>
<!-- SSH関連 / WordPress情報 も同様 -->
</div>
タブ切替の JS でも aria-selected を同期させている。
function switchSiteTab(tabName) {
form.querySelectorAll('.site-tab-btn').forEach(btn => {
const isActive = btn.getAttribute('data-tab-target') === tabName;
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
}
見た目の切り替えだけでなく、スクリーンリーダー利用者にも「今どのタブが選択されているか」が伝わる状態を保っている。
まとめ
フォームをタブに分割するという UI 変更は、見た目の整理としては直感的でも、ブラウザの標準バリデーションが暗黙に前提としている「invalid な要素は常に画面上に見えている」という条件を壊しうる。novalidate で自動ブロックを解除し、checkValidity() / :invalid で判定だけは維持しつつ、エラー箇所を可視化してから reportValidity() を呼ぶ——この一手間を入れることで、タブ UI と標準バリデーションの両立ができる。フォームを複数ビューに分割する設計全般に応用できる、地味だが再現性の高いパターンだ。