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