0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

mablのJavaScriptスニペットでタブオーダーを自動検証する

0
Posted at

はじめに

Webアプリのフォームで、Tabキーを押したら思わぬ場所にフォーカスが飛んだ、という経験はありませんか?

これは タブオーダーの乱れ が原因です。キーボードだけで操作するユーザー(アクセシビリティが必要なユーザーや、マウスを使わないパワーユーザー)にとって、タブオーダーの崩れは致命的な使い勝手の低下につながります。

にもかかわらず、タブオーダーのテストはなかなか自動化されていません。「目視で確認している」「リリース前に手動で一周している」という現場も多いのではないでしょうか。

本記事では、mablの JavaScriptスニペット を使って、フォームのタブオーダーが「左上から右下方向」になっているかを自動検証する方法を紹介します。

タブオーダーが崩れる典型的なパターン

フォームのリリース後に項目が追加されることはよくあります。このとき、既存の tabindex 値を振り直さず、新しいフィールドに大きな番号(例: 18, 19, 20...)を付けてしまうと、見た目は正しくてもタブ移動の順序がバラバラになります。

例えば、次のような「部署」フィールドの後追い追加を考えてみましょう。

sample.html
<!-- 担当者名と部署が横並びのはずなのに... -->
<input type="text" id="name" tabindex="2" placeholder="山田 太郎">
<input type="text" id="department" tabindex="18" placeholder="営業部">
<!-- 画面上は隣り合っていても、タブ移動順では全く別の場所 -->

視覚的には「担当者名」の隣に「部署」が並んでいるのに、Tabキーを押すと「担当者名 → メール → 電話 → 業種 → お問い合わせ内容 → クリアボタン → ... → 部署」という奇妙な順序になります。

テスト対象ページの構成

今回の検証には、以下の2ページを用意しました。

  • ordered.html: タブオーダーが正しく設定されたフォーム(tabindex 1~17が連番)
  • disordered.html: 後から項目追加が重なり、タブオーダーが崩れたフォーム

どちらも同じ「顧客情報入力フォーム」で、見た目はほぼ同じ です。フォーカス時のアウトライン色(青 vs 赤)だけが違います。これは、目視では発見が難しい問題を意図的に再現しています。
テスト対象ページ(ordered.html)

disordered.htmlの乱れの例

正しい順序 → 実際のタブ移動順
会社名(1) → 会社名(1)
担当者名(2) → 担当者名(2)
部署(3) → メール(3) ← 部署をスキップ!
メール(4) → 電話(4)
電話(5) → 業種(5) ← 住所フィールドをスキップ!
郵便番号(6) → お問い合わせ(6)
都道府県(7) → クリアボタン(7) ← ボタンがここに!
... → 製品A(8), 製品C(9) ← 製品Bは24番

mablのJavaScriptスニペット

スニペットの紹介

こちらがタブオーダーを検証するmablのJavaScriptスニペットです(checkTabOrder.js)。

checkTabOrder.js
function mablJavaScriptStep(mablInputs, callback) {
    // 1. フォーカス可能な要素のセレクター
    const selector = 'a, button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])';
    let elements = Array.from(document.querySelectorAll(selector))
        .filter(el => {
            // 非表示要素・親が非表示の要素は除外
            const style = window.getComputedStyle(el);
            return el.offsetWidth > 0 && el.offsetHeight > 0
                && style.visibility !== 'hidden'
                && style.display !== 'none';
        });

    // 2. tabindex値でソート(ブラウザのフォーカス順を再現)
    elements.sort((a, b) => {
        const aIndex = parseInt(a.getAttribute('tabindex')) || 0;
        const bIndex = parseInt(b.getAttribute('tabindex')) || 0;

        // tabindex > 0 の要素が最優先
        if (aIndex > 0 && bIndex > 0) return aIndex - bIndex;
        if (aIndex > 0) return -1;
        if (bIndex > 0) return 1;

        // tabindex = 0 または未指定はDOM順
        return 0;
    });

    // 要素の識別名を取得するヘルパー関数
    function getElementLabel(el) {
        if (el.innerText && el.innerText.trim()) return el.innerText.trim().substring(0, 30);
        if (el.placeholder) return el.placeholder.substring(0, 30);
        if (el.value) return el.value.substring(0, 30);
        if (el.id) return `#${el.id}`;
        if (el.name) return `[name="${el.name}"]`;
        if (el.id) {
            const label = document.querySelector(`label[for="${el.id}"]`);
            if (label && label.innerText) return label.innerText.trim();
        }
        return el.tagName.toLowerCase();
    }

    let errors = [];
    const threshold = 5; // ピクセル誤差の許容値

    for (let i = 1; i < elements.length; i++) {
        const prev = elements[i - 1].getBoundingClientRect();
        const curr = elements[i].getBoundingClientRect();

        // 上方向への逆戻りを検出
        const isHigher = curr.top < (prev.top - threshold);
        // 同一行での左への逆戻りを検出
        const isSameRowAndLeft = Math.abs(curr.top - prev.top) <= threshold
            && curr.left < (prev.left - threshold);

        if (isHigher || isSameRowAndLeft) {
            const prevLabel = getElementLabel(elements[i - 1]);
            const currLabel = getElementLabel(elements[i]);
            const prevTabindex = elements[i - 1].getAttribute('tabindex') || 'none';
            const currTabindex = elements[i].getAttribute('tabindex') || 'none';

            errors.push(
                `Order issue: "${prevLabel}" (tabindex=${prevTabindex}) -> "${currLabel}" (tabindex=${currTabindex})`
            );
        }
    }

    if (errors.length > 0) {
        console.error("Focus order issues found:", errors);
        callback(false); // テスト失敗
    } else {
        console.log("Focus order is correct!");
        callback(true); // テスト成功
    }
}

スニペットのポイント解説

1. フォーカス可能な要素の取得と非表示フィルタリング

a, button, input, textarea, select, details, [tabindex]:not([tabindex="-1"]) で標準的にフォーカス可能な要素を網羅し、offsetWidth/offsetHeightcomputedStyleで非表示要素を除外します。tabindex="-1" は意図的にキーボードフォーカスから外した要素なので除外対象です。

2. ブラウザのフォーカス順を再現するソート

ブラウザは tabindex > 0 の要素を数値昇順で先にフォーカスし、その後、tabindex = 0 または未指定の要素をDOM順で処理します。このロジックをJavaScriptで再現することで、実際のTabキー移動順序を取得しています。

3. 「左上から右下」のチェックロジック

  • isHigher → 次の要素が前の要素より上にある(上方向への逆走)
  • isSameRowAndLeft → 同じ行で次の要素が左にある(右→左への逆走)

5ピクセルの閾値(threshold)を設けることで、サブピクセルレンダリングの誤差や微妙な高さの違いによる誤検知を防ぎます。

4. エラーメッセージの分かりやすさ

getElementLabel() が各要素の識別名を innterTextplaceholdervalueidnamelabel の優先順で取得します。エラーログで「どの要素からどの要素への順序がおかしいか」をすぐに特定できます。

mablでの設定手順

  1. mablトレーナーを開き、テスト対象のページに移動するステップを追加します
  2. ステップ追加メニュー(+記号)から 「JavaScript」 を選択します
    JavaScriptステップの挿入
  3. [新規]ボタンをクリックするとスニペットエディターが起動するので、先ほど紹介したスニペットを貼り付け、
    適切なスニペットの名前を付けたのち、[新しい再利用可能なスニペットとして保存]で保存します
    JavaScriptスニペットエディター

mablのJavaScriptスニペットでは、callback(true) または callback(false) によってステップの成否を制御できます。callback(false)を呼ぶとステップ失敗となり、テスト全体が失敗扱いになります。

実行結果

実行結果

ordered.html (タブオーダー整列)

Focus order is correct!
コンソールに成功ログが出力され、テストはtrueでパスします。

disordered.html (タブオーダー不整列)

Focus order issues found: [
  "Order issue: \"担当者名\" (tabindex=2) -> \"メールアドレス\" (tabindex=3)",
  "Order issue: \"電話番号\" (tabindex=4) -> \"業種\" (tabindex=5)",
  "Order issue: \"クリア\" (tabindex=7) -> \"製品A(基本プラン)\" (tabindex=8)",
  ...
]

どの要素とどの要素の間で順序が崩れているかが一目でわかります。テストはfalseで失敗します。

実際のユースケース

このスニペットが特に有効なのは、以下のシナリオです。

  • フォームへの項目追加後の回帰テスト: 新しいフィールドを追加するたびにタブオーダーが崩れていないかを自動確認
  • アクセシビリティ監査の一環: WCAG 2.1の達成基準 2.4.3 (フォーカス順序) への適合確認
  • リリース前チェックの自動化: 手動目視確認をCI/CDパイプラインのmablテストに置き換え

まとめ

mablのJavaScriptスニペットを使うと、「フォームのタブオーダーが左上から右下方向になっているか」というアクセシビリティ検証を、わずか数十行のコードで自動化できます。

ポイントをまとめると、

  • querySelectorAll で全フォーカス可能要素を取得し、非表示要素を除外
  • tabindex値に基づいてブラウザのフォーカス順を忠実に再現
  • getBoundingClientRect()で各要素の座標を取得し、上方向・左方向への逆走を検出
  • エラー時はcallback(false)でテスト失敗、正常時はcallback(true)でテスト成功

「目で確認している」タブオーダーチェックを、ぜひmablで自動化してみてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?