代表的な面接トピック

フロントエンド面接:アクセシブルなモーダルダイアログをどのように構築しますか?

フロントエンド普通
Offer.cc 編集チーム公開日 更新日

質問

ボタンから開き、背後のページを視覚的かつ動作的にブロックし、有用なアクセシブルな名前を公開し、アクティブなダイアログ内にキーボードフォーカスを維持し、明示的なコントロールとEscapeキーで閉じ、フォーカスを正しく復元する、再利用可能でアクセシブルなモーダルダイアログを構築してください。初期フォーカスポリシー、ネイティブ実装とカスタム実装の比較、破壊的操作の確認、ダイアログのスタック、フレームワークのライフサイクルリスク、テストについて説明してください。

プロンプトと適用されるコンテキスト

フロントエンドUIコーディング面接向けの再利用可能なモーダルダイアログを構築してください。呼び出し側はタイトル、オプションの短い説明、本文コンテンツ、開閉状態、確認および閉じるコールバックを提供します。ダイアログはマウス、タッチ、キーボード、および支援技術のユーザーをサポートする必要があります。視覚的な中央配置と暗くした背景(backdrop)が必要ですが、主な課題は動作にあります。アクティブなダイアログの外側のコンテンツが利用不可であること、ダイアログに有用な名前が付いていること、フォーカスがダイアログに入って内部に留まること、すべてのユーザーに明示的な閉じる手段があること、そして事後にフォーカスが論理的な場所に戻ることです。

現在のエバーグリーンブラウザがネイティブのdialog APIをサポートしていると仮定します。また、面接官がネイティブ要素を明示的に禁止した場合、埋め込みWebviewがサポートを欠いている場合、または既存のコンポーネントライブラリがすでにテスト済みのカスタムプリミティブを所有している場合に、規約がどのように実装されるかも説明してください。フォーム、長い情報ダイアログ、不可逆な削除確認ではそれぞれ異なる選択が必要になるため、再利用可能なユニットは単一の初期フォーカスターゲットをハードコードしてはなりません。

この実装には、アプリケーション固有のデータ取得、アニメーション設計、完全なモーダルマネージャーは含まれません。これらはフォローアップの対象となります。合否基準は観察可能な動作です。キーボードによるアクティベーション、フォーカスの配置、フォーカスの閉じ込め(containment)、アクセシブルな名前付け、閉じる動作、フォーカスの復元、レイヤリング、そして再レンダリングやアンマウントを通じた安定した動作です。

面接官が評価するポイント

第一のシグナルは、候補者が「モーダル」を動作の規約として定義しているかどうかです。スタッキング値(z-index)の高い中央配置されたパネルは、単なるオーバーレイにすぎません。真のモーダルはドキュメントの残りの部分をinert(不活性)にし、タブシーケンスを閉じ込め、ダイアログセマンティクスを公開し、完全なフォーカスライフサイクルを管理します。外側のコンテンツを利用不可にすることなくARIA属性を追加すると、アクセシビリティツリーに誤解を招く状態が生まれます。

第二のシグナルは、プラットフォームに関する判断力です。showModal()で開かれたネイティブのdialog要素は、ブラウザのトップレイヤーに入り、backdropを作成し、同じドキュメント内の他の要素をinertにし、モーダルなキーボード動作を提供します。show()で開くか、そのopen属性を設定すると、非モーダルになります。優れた回答は、製品のブラウザ規約で許可されている場合はネイティブプリミティブを使用しつつ、カスタム実装が再現しなければならないすべての不変条件を正確に述べることができます。

第三のシグナルは、暗記された「最初のボタンをフォーカスする」ルールではなく、フォーカスポリシーを理解していることです。短いフォームでは、最初の無効なフィールドまたは主要なフィールドにフォーカスする場合があります。ドキュメントのような長いダイアログでは、先頭とセマンティック構造を維持できるように、プログラムによるフォーカスターゲットを持つ静的見出しにフォーカスすることが多くなります。不可逆な確認では、最初に最も破壊的でないアクションにフォーカスすべきです。閉じるときは通常、フォーカスはトリガー要素に戻りますが、その要素が削除されていた場合、呼び出し側は次の論理的な作業項目を選択しなければなりません。

第四のシグナルは、フレームワークライフサイクルの正確性です。ブラウザの命令的状態と宣言的UI状態が乖離してはなりません。showModal()を2回呼び出す、状態を同期する前に開いているダイアログを削除する、親を更新せずに閉じる、または古いノードにフォーカスを復元すると、実際の実装バグが発生します。イベントリスナーにはクリーンアップが必要であり、一意のラベルは安定していなければならず、サーバーレンダリングではレンダリング中にブラウザAPIを呼び出してはなりません。

最後のシグナルは、検証の品質です。自動アクセシビリティチェックは名前の欠落や無効な属性を検出できますが、フォーカスがこのワークフローに適した要素に当たるか、TabおよびShift+Tabが閉じ込められたままであるか、Escapeが製品ポリシーに従っているか、トリガーが消えた後にフォーカスが論理的な場所に戻るかを証明することはできません。これらにはキーボードとスクリーンリーダーによる検証が必要です。

回答前に確認すべき質問

  • これは本当にモーダルですか? ユーザーがページと対話し続ける必要がある場合は、非モーダルダイアログ、ポップオーバー、またはインラインパネルを使用します。単にコンテンツの上に浮いているという理由だけでモーダルとラベル付けしないでください。
  • ネイティブのdialog要素を使用してもよいですか? はいの場合はshowModal()を使用し、デフォルトの動作を保持します。いいえの場合は、セマンティクス、外部のinert化、フォーカスの閉じ込め、Escape、および復元を明示的に実装します。
  • 内部にどのようなコンテンツが表示されますか? 単純なフォーム、長い構造化ドキュメント、アラートでは、異なるアクセシブルな説明と初期フォーカスの選択肢が必要になります。
  • 操作は元に戻せますか? 削除や支払いの場合、キャンセルまたは別の最も破壊的でないコントロールにフォーカスします。日常的な継続ダイアログの場合は、次の可能性が高いアクションが適切な場合があります。
  • どのように閉じることができますか? 常に視覚的な閉じるまたはキャンセルコントロールを提供してください。Escape、背景クリック、送信成功、および未保存の入力に確認手順が必要かどうかを明確にします。
  • 閉じた後に何がフォーカスを受け取るべきですか? 通常はトリガー要素です。作成によってそのノードが削除または置換される場合は、コンポーネントを作成する前に安定した論理的後継要素を特定します。
  • ダイアログをスタック(重ねて表示)できますか? アクティブなモーダルは1つに保つのが望ましいです。スタックが必要な場合は、最前面のダイアログのみが破棄を処理し、それを閉じるとその下のダイアログ内のフォーカスが復元されます。
  • どのブラウザと支援技術が対象範囲ですか? これにより、ネイティブ要素で十分か、互換性レイヤーが必要か、テスト済みの確立されたプリミティブに置き換える必要があるかが決まります。

30秒の回答フレームワーク

「アクセシブルな名前、inertな背景、意図的な初期フォーカス、閉じ込められたタブシーケンス、明示的およびEscapeによる破棄、そして論理的なフォーカス復元という6つの不変条件を定義します。現在のブラウザでは、トップレイヤー、backdrop、inert化、およびコアフォーカス動作のためにshowModal()を伴うネイティブのdialog要素を使用します。初期フォーカスはタスクに応じます。長いコンテンツには見出し、フォームには該当フィールド、不可逆なアクションにはキャンセルを選択します。Reactでは、保護された開閉エフェクトで制御された状態を同期し、cancelを直接リッスンし、キーボードによるオープン、両方向のTab移動、破棄、スクリーンリーダーの名前付け、フォーカス復元、再レンダリング、スタックされたダイアログを検証します。」

ステップバイステップの詳細解説

React、CSS、ネイティブ要素に依存しない不変条件から始めます。

text
OPEN
  exactly one active modal owns interaction
  outside content is visually obscured and behaviorally inert
  dialog has an accessible name from a visible title
  focus is inside the dialog on a purposefully selected target

WHILE OPEN
  Tab and Shift+Tab cannot enter the background document
  visible close or cancel control is reachable
  only the top modal handles a close request

CLOSE
  parent open state and browser dialog state agree
  focus returns to the opener if it exists
  otherwise focus moves to a predefined logical successor

次にプリミティブを選択します。ネイティブダイアログは、現在のブラウザ規約におけるデフォルトです。showModal()を呼び出すと、トップレイヤーに配置され、backdropが提供され、ドキュメント内の他のコンテンツがinertになります。これにより、すべてのフォーカス可能要素を手動でトラバースしたり、アプリケーションルート全体にaria-hiddenを設定したり、スタッキングコンテキストと格闘したりすることを回避できます。show()を呼び出すかopenを設定すると非モーダルダイアログが生成されるため、これらは置き換え可能なショートカットではありません。

面接でネイティブ要素が禁止されている場合や、製品がすでに成熟したコンポーネントプリミティブを使用している場合は、カスタムダイアログも有効です。ダイアログセマンティクスとアクセシブルな名前を持つコンテナをレンダリングし、外部のインタラクションが実際にブロックされている場合にのみモーダルセマンティクスを設定し、すべての背景ルートをinertにし、フォーカスを閉じ込め、Escapeを処理し、フォーカスを復元し、アプリケーションのスタッキングコンテキストの上にレンダリングする必要があります。パネルをdocument bodyにポータル(portal)することはクリッピングとスタッキングに役立ちますが、ポータル単独ではこれらのアクセシビリティ動作のいずれも提供しません。

初期フォーカスの決定表を使用します。

  • 短いフォームの場合、ユーザーが対応すべき最初のフィールド、特にバリデーション後の無効なフィールドにフォーカスします。
  • 長いテキスト、リスト、テーブルの場合、先頭にあるタイトルまたはプログラムによるフォーカスターゲットを持つ他の静的要素にフォーカスします。リッチな構造を1つの長いアクセシブルな説明に平坦化しないでください。
  • 不可逆な作業の場合、キャンセルまたは最も破壊的でないアクションにフォーカスします。
  • 単純な確認や継続の場合、偶発的な害を引き起こす可能性がないときに限り、最も可能性の高いアクションにフォーカスします。

アクセシブルな名前は、通常、視覚的なタイトルを参照する必要があります。短いプレーンテキストの説明は個別に参照できます。支援技術のユーザーがその構造をナビゲートできるように、複数の段落、リスト、テーブルを含むコンテンツに対する単一の説明参照は省略します。閉じるアイコンにもアクセシブルな名前が必要であり、Escapeがサポートされている場合でも、視覚的な閉じるまたはキャンセルコントロールが存在しなければなりません。

次のReactの例では、ネイティブブラウザの状態を制御されたアプリケーション状態と同期させます。descriptionプロパティは意図的に短い文字列になっています。複雑なコンテンツはchildrenに入り、1つの平坦化された説明としては割り当てられません。

tsx
'use client'

import { useEffect, useId, useRef, type ReactNode } from 'react'

interface AccessibleModalProps {
  open: boolean
  title: string
  description?: string
  initialFocus: 'heading' | 'cancel' | 'confirm'
  children: ReactNode
  onConfirm: () => void
  onOpenChange: (open: boolean) => void
}

export function AccessibleModal({
  open,
  title,
  description,
  initialFocus,
  children,
  onConfirm,
  onOpenChange,
}: AccessibleModalProps) {
  const dialogRef = useRef<HTMLDialogElement>(null)
  const headingRef = useRef<HTMLHeadingElement>(null)
  const cancelRef = useRef<HTMLButtonElement>(null)
  const confirmRef = useRef<HTMLButtonElement>(null)
  const titleId = useId()
  const descriptionId = useId()

  useEffect(() => {
    const dialog = dialogRef.current
    if (!dialog) return

    if (open && !dialog.open) {
      dialog.showModal()
      const target =
        initialFocus === 'confirm'
          ? confirmRef.current
          : initialFocus === 'cancel'
            ? cancelRef.current
            : headingRef.current
      target?.focus()
    } else if (!open && dialog.open) {
      dialog.close()
    }
  }, [initialFocus, open])

  useEffect(() => {
    const dialog = dialogRef.current
    if (!dialog) return

    const handleCancel = (event: Event) => {
      event.preventDefault()
      onOpenChange(false)
    }
    const handleClose = () => {
      if (open) onOpenChange(false)
    }

    dialog.addEventListener('cancel', handleCancel)
    dialog.addEventListener('close', handleClose)
    return () => {
      dialog.removeEventListener('cancel', handleCancel)
      dialog.removeEventListener('close', handleClose)
    }
  }, [onOpenChange, open])

  return (
    <dialog
      ref={dialogRef}
      aria-labelledby={titleId}
      aria-describedby={description ? descriptionId : undefined}
    >
      <h2 ref={headingRef} id={titleId} tabIndex={-1}>
        {title}
      </h2>
      {description ? <p id={descriptionId}>{description}</p> : null}
      {children}
      <div>
        <button ref={cancelRef} type="button" onClick={() => onOpenChange(false)}>
          Cancel
        </button>
        <button ref={confirmRef} type="button" onClick={onConfirm}>
          Confirm
        </button>
      </div>
    </dialog>
  )
}

dialog.openの周囲のガードは、再レンダリング時の重複した命令的呼び出しを防ぎます。イベントがキャンセル可能でバブリングしないため、直接のcancelリスナーが重要になります。デフォルトの閉じる動作を防ぐことで、制御された状態を先に変更でき、次のエフェクトでネイティブダイアログを閉じます。また、closeリスナーは、別のネイティブの閉じるパスが実行された場合に状態を修復します。ブラウザAPIはエフェクト内に留まるため、サーバーレンダリングはマークアップのみを出力します。

破棄ポリシーを明確にしておきます。背景のクリックは、自動的にキャンセルと同等になるわけではありません。未保存の作業があるフォームは背景クリックを無視する場合があり、軽量なピッカーは受け入れる場合があり、不可逆な確認は偶発的なポインターイベントによって消えてはなりません。製品が背景の破棄を受け入れる場合は、検証済みの背景領域ヒットテストを使用し、同じ制御された閉じるパスを介してルーティングします。イベントターゲットのチェックだけでは、ダイアログのパディング上のクリックを背景クリックと誤認する可能性があります。ダイアログ向けのクリックを奪うような、画面全体の非表示の閉じるターゲットを作成してはなりません。

ネイティブのフォーカス復元は、通常、呼び出し元の要素に戻ります。アプリケーションフローは、理由がある場合にのみこれをオーバーライドできます。ダイアログが新しい行を作成し、「行を追加」ボタンを削除した場合、新しい行の最初のセルが論理的な移動先になります。再利用可能なダイアログは周囲のワークフローで何が変更されたかを推測できないため、このポリシーは呼び出し側で捕捉します。

スタックされたダイアログの場合、1つのダイアログ内でコンテンツを変更することを推奨します。2つ目のモーダルが避けられない場合は、スタックを維持します。最上位のエントリのみがEscapeまたは背景から閉じることができ、背景のダイアログはinertのままであり、最上位のエントリを閉じると、前のダイアログ内でそれを開いたコントロールにフォーカスが復元されます。グローバルなBooleanではその関係を表現できません。

レンダリングされた属性だけでなく、動作をテストします。

text
1. Open with Enter and Space; verify focus enters the intended target.
2. Tab from the last control and Shift+Tab from the first; verify background is unreachable.
3. Press Escape; verify one top dialog closes and controlled state becomes false.
4. Use the visible close and Cancel controls with keyboard, pointer, and touch.
5. Close normally; verify focus returns to the opener.
6. Remove the opener during completion; verify focus moves to the chosen logical successor.
7. Read with a screen reader; verify one useful title and no flattened rich description.
8. Open a destructive confirmation; verify initial focus is on the least destructive action.
9. Rerender repeatedly while open; verify no duplicate-open exception or focus reset.
10. Unmount during navigation; verify no listener leak or focus jump to the document body.
11. At 200% zoom and a small viewport, verify title, controls, and scrollable content remain reachable.
12. Run automated accessibility checks, then repeat the manual focus workflow they cannot prove.

高品質な回答例

「モーダルを、単にスタッキング値が大きいパネルとしてではなく、フォーカスとインタラクションのステートマシンとして扱います。ダイアログが開くとドキュメントの残りの部分がinertになり、ダイアログには視覚的なタイトルに関連付けられた名前が必要で、フォーカスはタスクから選択されたターゲットに移動する必要があります。開いている間、キーボードナビゲーションは内部に留まり、視覚的な閉じるまたはキャンセルコントロールに常に到達可能です。閉じるとき、ワークフローによって置き換えられていない限りフォーカスはトリガーに戻り、置き換えられた場合は呼び出し側が論理的な後継要素を提供します。

現在のブラウザでは、ネイティブのdialog要素を使用してshowModal()を呼び出します。これにより、トップレイヤー、backdrop、背景のinert化、コアとなるモーダルフォーカス動作が得られます。openを設定したりshow()を呼び出したりすると非モーダルな動作になるため、どちらも代用品としては使用しません。課題でネイティブ要素が禁止されている場合は、新しいアドホックなフォーカストラップコードではなく、既存のテスト済みプリミティブを使用して、dialogロール、アクセシブルな名前、実際の外部inert化、フォーカスの閉じ込め、Escape処理、ポータル、復元によって同じ不変条件を再現します。

初期フォーカスはコンテンツに依存します。短いフォームには該当フィールド、長い構造化コンテンツには静的見出し、不可逆なアクションにはキャンセルにフォーカスします。短い説明は1つのアナウンスとして理解できる場合にのみ参照し、リストや複数の段落は構造としてナビゲート可能なままにします。

Reactでは、エフェクト内で制御された状態をshowModal()およびclose()と同期させ、重複呼び出しを防止し、バブリングしないcancelイベントを直接リッスンし、クリーンアップ時にリスナーを削除します。背景の破棄は明示的な製品ポリシーとします。キーボードによるオープン、両方向のTab移動、Escape、明示的な閉じる、スクリーンリーダーの名前付け、トリガーが存在する場合または消えた場合のフォーカス復元、繰り返しの再レンダリング、スタックされたダイアログ、ズーム、小さなビューポートを検証します。自動チェックはそのワークフローを補完しますが、代替するものではありません。」

よくある間違い

  • 中央配置のオーバーレイをスタイル付けしてモーダルと呼ぶ → 背景のコントロールがキーボードや支援技術から到達可能なままになる → inert化とフォーカスライフサイクルを定義してテストする。
  • 外部のインタラクションをブロックせずにモーダルセマンティクスを追加する → アクセシビリティツリーが、晴眼ポインターユーザーが体験しない状態を約束してしまう → 動作が純粋にモーダルである場合にのみモーダルセマンティクスを設定する。
  • ネイティブのopen属性を切り替える → showModal()の動作なしでダイアログが表示される → 適切なモーダルメソッドを使用し、アプリケーション状態と同期させる。
  • 常に最初のコントロールをフォーカスする → 長いコンテンツが画面外から始まる可能性があり、破壊的な作業で危険なアクションにフォーカスしてしまう場合がある → コンテンツの構造と影響から初期フォーカスを選択する。
  • 複数の段落を1つのアクセシブルな説明に入れる → スクリーンリーダーが構造化されていないブロックを読み上げる → 短い説明のみを参照し、リッチコンテンツはナビゲート可能なままにしておく。
  • Escapeのみに依存する → タッチおよびスイッチユーザーに明確な破棄パスがない場合がある → 視覚的で名前の付いた閉じるまたはキャンセルコントロールを含める。
  • すべての背景クリックで閉じる → 偶発的なポインター入力によって作業が破棄される → ライトディスミス(軽微な破棄)を意図的でテスト可能な製品ポリシーにする。
  • プラットフォームやライブラリを確認する前に手動フォーカストラップを作成する → 無効なコントロール、DOMの変更、ポータル、ネストされたダイアログの周囲でエッジケースが増殖する → ネイティブ要素または成熟したテスト済みプリミティブを優先する。
  • レンダリング中またはすべてのエフェクト実行時にshowModal()を呼び出す → サーバーレンダリングが失敗するか、ブラウザがスローしてフォーカスがリセットされる → 保護されたエフェクト内で命令的メソッドを呼び出す。
  • 削除されたトリガーにフォーカスを復元する → フォーカスがdocument bodyに落ち、キーボードコンテキストが失われる → ワークフローによってページが変更された場合、呼び出し側に論理的な後継要素を特定させる。
  • 自動スキャンを完全な証明として扱う → フォーカス順序やワークフローの意図を判断できない → キーボードとスクリーンリーダーによる完全な手動シーケンスを実行する。

フォローアップの質問と回答

フォローアップ1:ネイティブのdialog要素が禁止されている場合は何が変わりますか?

視覚的なタイトルの参照とモーダルセマンティクスを持つdialogロールのコンテナを含むポータルをレンダリングします。単に視覚的に暗くするだけでなく、すべての背景アプリケーションルートをinertにします。トリガーをキャプチャし、初期フォーカスを選択して設定し、フォーカス可能な子要素が変更された場合でも両方向のTab移動を閉じ込め、アクティブなダイアログでEscapeを処理し、フォーカスを復元し、すべての変更とリスナーをクリーンアップします。製品ダイアログごとにこのクロスブラウザ動作を再実装するよりも、メンテナンスされているプリミティブの方が安全である理由を説明してください。

フォローアップ2:バリデーションエラーのあるフォームをどのように処理しますか?

ダイアログを開いたままにします。最初の無効なフィールドまたは無効なフィールドにリンクするエラーサマリーにフォーカスを移動し、各フィールドのアクセシブルな説明を介して各メッセージを公開し、入力された値を保持します。すべてのフィールドエラーを1つのダイアログの説明としてアナウンスしないでください。送信が成功した後は、アプリケーションの結果が確定したときにのみ閉じ、結果を表す要素にフォーカスを移動します。

フォローアップ3:背景をクリックしたときにダイアログを閉じるべきですか?

損失リスクに基づいて判断します。軽量なピッカーでは許可される場合がありますが、長いフォームや破壊的な確認では通常、明示的な決定が必要になります。有効にする場合は、ポインターシーケンスが背景領域で開始および終了した場合にのみ破棄し、キャンセルと同じ状態遷移を使用し、ドラッグが意図と誤認されないように、内部でのポインターダウンに続く外部でのポインターアップをテストします。

フォローアップ4:フォーカス復元を壊さずに閉じるアニメーションをどのように実装しますか?

「閉じる要求」と「DOMからの削除」を分離します。ダイアログをクローズ中としてマークし、新しいアクションを停止し、終了トランジションを再生してから、ネイティブのクローズパスを呼び出し、アンマウント前に状態を更新します。視覚効果を減らす(prefers-reduced-motion)設定を尊重し、制限時間付きの完了フォールバックを提供します。フォーカス復元は、不透明度が最初に変化したときではなく、実際の閉じる境界で行われます。

フォローアップ5:1つ目のダイアログから2つ目のダイアログが開いた場合はどうなりますか?

1つのダイアログ内のマルチステップフローの方が明確な場合は、それを回避します。必要な場合は、エントリごとにトリガーを持つ順序付きスタックを保持します。最上位のエントリのみがEscapeまたは背景に応答し、下位のダイアログはブロックされたままになります。子ダイアログを閉じると親ダイアログ内のトリガーにフォーカスが復元され、親ダイアログを閉じるとページのトリガーにフォーカスが復元されます。

フォローアップ6:bodyのスクロールとレイアウトシフトをどのように防ぎますか?

スクロールロックを、セマンティックなinert化とは別の視覚および入力ポリシーとして扱います。現在のスクロール位置を記録し、最初のモーダルが開いている間に1元化されたロックを適用し、必要に応じて非表示になるスクロールバーの幅を補正し、最後のモーダルが閉じた後にのみ解放します。モバイル仮想キーボード、ネストされたスクロール領域、ズーム、ルート変更をテストします。参照カウントによる所有権管理により、あるダイアログが別のダイアログの下にあるページのロックを解除してしまうのを防ぎます。

フォローアップ7:これをCIでどのようにテストしますか?

制御された開閉トランジション、ラベル、初期フォーカスの分岐、キャンセル処理、クリーンアップに対してコンポーネントテストを使用します。実際のトリガーをアクティブにし、フォーカスを前後に移動し、Escapeを押し、トリガーを削除し、スタックされたダイアログを動作させるブラウザテストを追加します。構造のリグレッションに対して自動アクセシビリティルールを実行し、CIのアサーションではすべてのアナウンスやワークフローの選択を判断できないため、代表的なスクリーンリーダー、キーボード、ズーム、タッチ、ハイコントラストの構成にわたる手動マトリクスを維持します。

公開情報ソース

関連する質問