本文へスキップ
UI Library

モーダル

画面の上に重ねて操作を求めるダイアログ。フォーカストラップ・Escでの閉じる・背景スクロールの停止まで含めています。

components/uilib/Modal.tsx

'use client'

import { useCallback, useEffect, useId, useRef, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import s from './Modal.module.scss'

const FOCUSABLE =
  'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'

/**
 * モーダルダイアログ。
 *
 * a11y:
 *   - role="dialog" aria-modal="true" と aria-labelledby で見出しに紐づける
 *   - 開いたら中の最初の操作要素へフォーカスを移し、閉じたら開いた要素に戻す
 *   - フォーカストラップ: Tab / Shift+Tab がダイアログの外へ出ない
 *   - Escape で閉じる
 *   - 背景のスクロールを止める(開いているあいだ body を固定)
 *   - 背景を inert にする。フォーカストラップは keydown にしか効かないため、
 *     支援技術の仮想カーソルやスクリプト経由のフォーカスまでは止められない。
 *     inert なら読み上げ・操作の対象から外れる
 *   - 背景クリックでも閉じる(誤操作を避けるためダイアログ内のクリックは無視)
 *
 * body 直下へポータルで出しているのは、inert を「モーダル以外の兄弟」にだけ
 * 付けるため(ページの中に置いたままだと自分自身も inert になる)。
 */
export function Modal({
  open,
  onClose,
  title,
  children,
}: {
  open: boolean
  onClose: () => void
  title: string
  children: ReactNode
}) {
  const id = useId()
  const overlayRef = useRef<HTMLDivElement>(null)
  const dialogRef = useRef<HTMLDivElement>(null)
  const openerRef = useRef<HTMLElement | null>(null)

  const focusables = useCallback(
    () => Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []),
    [],
  )

  // 背景のスクロールを止め、モーダル以外を inert にする。閉じたら元に戻す。
  //
  // この効果はフォーカス移動より「前」に置く必要がある。Reactは定義順に
  // クリーンアップを走らせるので、逆にすると inert が外れる前に復帰先へ
  // focus() を呼ぶことになり、inert な要素はフォーカスを受け取らないため
  // 閉じたあとフォーカスが body に落ちる。
  useEffect(() => {
    if (!open) return
    const overlay = overlayRef.current
    const previousOverflow = document.body.style.overflow
    document.body.style.overflow = 'hidden'

    const inerted: HTMLElement[] = []
    for (const child of Array.from(document.body.children)) {
      if (child === overlay || !(child instanceof HTMLElement)) continue
      if (child.inert) continue // すでに inert なものは触らない(戻すときに壊さない)
      child.inert = true
      inerted.push(child)
    }

    return () => {
      document.body.style.overflow = previousOverflow
      for (const el of inerted) el.inert = false
    }
  }, [open])

  // 開いたときのフォーカス移動と、閉じたときの復帰
  useEffect(() => {
    if (!open) return
    openerRef.current = document.activeElement as HTMLElement | null
    const first = focusables()[0] ?? dialogRef.current
    first?.focus()
    return () => {
      openerRef.current?.focus()
    }
  }, [open, focusables])

  // Escape と Tab の捕捉
  useEffect(() => {
    if (!open) return
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        e.stopPropagation()
        onClose()
        return
      }
      if (e.key !== 'Tab') return
      const list = focusables()
      if (list.length === 0) {
        e.preventDefault()
        return
      }
      const first = list[0]
      const last = list[list.length - 1]
      const current = document.activeElement
      if (e.shiftKey && (current === first || !dialogRef.current?.contains(current))) {
        e.preventDefault()
        last.focus()
      } else if (!e.shiftKey && current === last) {
        e.preventDefault()
        first.focus()
      }
    }
    document.addEventListener('keydown', onKeyDown, true)
    return () => document.removeEventListener('keydown', onKeyDown, true)
  }, [open, onClose, focusables])

  // open はクライアントの操作でしか true にならないので、ここに来た時点で
  // document は存在する。サーバー描画で開いた状態を渡された場合の保険だけ置く。
  if (!open || typeof document === 'undefined') return null

  return createPortal(
    <div className={s.overlay} ref={overlayRef} onMouseDown={onClose}>
      <div
        className={s.dialog}
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={`${id}-title`}
        tabIndex={-1}
        // 背景クリックで閉じる挙動が、中身のクリックで誤発火しないようにする
        onMouseDown={(e) => e.stopPropagation()}
      >
        <h3 className={s.title} id={`${id}-title`}>
          {title}
        </h3>
        <div className={s.body}>{children}</div>
        <div className={s.actions}>
          <button className={s.close} type="button" onClick={onClose}>
            閉じる
          </button>
        </div>
      </div>
    </div>,
    document.body,
  )
}

このページに表示しているのは、実際にこのサイトで動いているソースそのものです。見本用に書き写したコードではありません。スタイルは同じ階層の Modal.module.scss にあり、色・余白はすべてデザイントークンの CSS カスタムプロパティを参照しています。

States

状態変化もすべて実装済み

押したとき・入力待ちのとき・エラーのとき——実際の運用で必要になる表示は、あとから作り足す必要がないよう最初から含めてお渡しします。

default
hover
focus
active
disabled
loading
error
success