コンテンツへスキップ

タブ化したフォームで「ボタンが反応しない」バグ — 隠れた required フィールドが submit を止めていた

タブ化したフォームで「ボタンが反応しない」バグ — 隠れた 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 つある。

  1. novalidate は制約自体を無効にするわけではないrequiredpattern といった属性は引き続き有効で、checkValidity():invalid 疑似クラスによる判定はそのまま機能する。無効になるのはブラウザの「自動で submit をブロックしてエラー表示する」挙動だけだ。
  2. :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 と標準バリデーションの両立ができる。フォームを複数ビューに分割する設計全般に応用できる、地味だが再現性の高いパターンだ。