
Web 往訪 dialog 之譚
2022年3月から、dialog 要素は各種主要なブラウザで利用できるようになりました。dialog 要素はダイアログを表示する要素です。アクセシビリティを考慮するのは大変なので、ネイティブに使えるのはとても有り難いですね。今回はそのようなdialog 要素に触れていきます。
Dialog について
Dialog について、Dialog (Modal) Patternで下記のように説明されています。簡単に説明すると以下の通りです。
- ダイアログはメインウィンドウまたは他のダイアログ上に重ねて表示されるウィンドウ
- 背後のウィンドウは「不活性(inert)」状態となる
- ユーザーはアクティブなダイアログの外側は操作できない
A dialog is a window overlaid on either the primary window or another dialog window. Windows under a modal dialog are inert. That is, users cannot interact with content outside an active dialog window. Inert content outside an active dialog is typically visually obscured or dimmed so it is difficult to discern, and in some implementations, attempts to interact with the inert content cause the dialog to close.
Like non-modal dialogs, modal dialogs contain their tab sequence. That is, Tab and Shift + Tab do not move focus outside the dialog. However, unlike most non-modal dialogs, modal dialogs do not provide means for moving keyboard focus outside the dialog window without closing the dialog.
https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
dialog 要素について
下記の HTML で、button に対してハンドラーを設定してあげると動作します。 少ない記述量で必要なダイアログが出来上がるのは嬉しいですね。
<html>
<body>
<button id="open">open the dialog</button>
<dialog id="dialog">
<p>dialog was opened</p>
<button id="close">close the dialog</button>
</dialog>
</body>
<script>
const dialog = document.querySelector("#dialog")
const open = document.querySelector("#open")
const close = document.querySelector("#close")
open.addEventListener("click",() => {
dialog.showModal()
})
close.addEventListener("click",() => {
dialog.close()
})
</script>
</html>
show / showModal
対象の dialog 要素を開きます。それぞれ下記のような違いがあります。
show
モードレスで表示します。つまり下記のような制約で表示されます。
- 背景のコンテンツとのインタラクションが可能
- 背後に暗い背景(backdrop)は表示されない
- Escキーで自動的に閉じない
- フォーカスは制限されない
showModal
ダイアログをモーダルとして表示します。つまり下記のような制約で表示されます。
- 背景のコンテンツとのインタラクションをブロック
::backdrop
疑似要素により、背後に暗い背景を表示- Escキーでモーダルを閉じることができる
- 設定によって、Esc キーを無効にできる
- フォーカスがダイアログ内にトラップされる
ブラウザの全幅が ::backdrop
に覆われます。Chrome の場合は下記のようなスタイリングが施されています。
position: fixed;
background-color: rgba(0, 0, 0, 0.1);
inset: 0px;
つまり、top を指定してヘッダーの height だけずらしたり、background-color で色を調整可能です。
::backdrop {
top: 32px;
background-color: red;
}
close / requestClose
対象の dialog 要素を閉じます。close()
メソッドに対して、引数を渡した場合はその値が returnValue として更新されます。
const dialog = document.querySelector("#dialog")
// 何もせずに閉じる
dialog.close()
// returnValue に値を入れる
dialog.close("value")
// returnValue を取得する
console.log(dialog.returnValue)
close()
メソッドは即座にダイアログを閉じますが、requestClose()
メソッドはダイアログを閉じる前に cancel
イベントを発火させます。
そのため、cancel
イベントリスナーで preventDefault()
を呼ぶことで、ダイアログが閉じるのをキャンセルできます。
<html>
<body>
<button id="open">open the dialog</button>
<dialog id="dialog">
<p>dialog was opened</p>
<button id="close">close the dialog</button>
</dialog>
</body>
<script>
const dialog = document.querySelector("#dialog")
const open = document.querySelector("#open")
const close = document.querySelector("#close")
open.addEventListener("click",() => {
dialog.showModal()
})
close.addEventListener("click",() => {
dialog.requestClose("closed")
})
dialog.addEventListener("cancel", (event) => {
// 何かしらの条件
if (true) {
event.preventDefault();
}
})
</script>
</html>
コードサンプルでは閉じるボタンが表示されますが、ボタンをクリックしてもモーダルが閉じなくなります。
open
dialog 要素の開閉状態を制御します。 技術的には、この値の変更でダイアログを制御できますが、推奨されていません。 通常は、show()
、showModal()
、close()
、requestClose()
を利用して閉じてください。
returnValue
close()
、requestClose()
に引数を渡して実行すると、returnValue に値を入れることができます。
closedby
Baseline のステータスが、Limited Available です。一部のブラウザでは利用できません。
dialog 要素を閉じることができる方法を指定します。
- any:ブラウザ、OS の閉じる(ESC キー...)、backdrop のクリック、
close()
メソッドの実行で閉じる - closerequest:ブラウザ、OS の閉じる(ESC キー...)、
close()
メソッド の実行で閉じる - none:
close()
メソッドの実行で閉じる
アクセシビリティ
アクセシビリティで考慮するべき点は下記の通りです。
対応するべき内容自体は少ないですが、実装する際には背景のスクロールだったり色々考慮する箇所があります。
WAI-ARIAの実装
- ダイアログのコンテナ要素に
dialog
を aria-role として付与する - ダイアログのコンテナ要素には aria-modal を
true
として付与する - ダイアログのコンテナ要素が HTML の dialog 要素の場合は上記の aria 属性を付与しない
- ダイアログを操作できる要素は全てダイアログのコンテナ要素の子孫とする
- ダイアログの要素に aria-labelledby で参照されるタイトルか aria-label で指定されるタイトルを保持する
- ダイアログの要素に閉じるための表示されるボタンを用意する
キーボード操作
- Tab:ダイアログ内の次の要素へ移動
- Shift + Tab:ダイアログ内の前の要素へ移動
- Escape:ダイアログを閉じる
実装例:ハンバーガーメニュー
DevelopersIO でもモバイル向け表示でハンバーガーメニューにて採用を検討しました。最終的には独自の button 要素と div 要素で実装しました。closedby
が使えないブラウザもあるというのが理由で、backdrop のクリックで閉じるようにしたかったです。
実装例として、ハンバーガーメニューについて触れていきます。ただ、ダイアログの実装というよりかは、メニューの実装ですね...。キーボード操作や状態の管理について参考になるところはあるのでその意味で記述しています。
また、ダイアログと言われると怪しいので、 aria-*
の指定を最小限にしています。
ARIA がないことは、悪い ARIA より良いと言われるのでそうしています。
実装を解説してみます。
まずはざっくりと全体像から把握しましょう。open
という状態で、モーダルの開閉状態を管理しています。
その値を見て、モーダルの表示とハンバーガーメニューを表示するボタンを管理しています。あとは、メニューのアイテム操作を制御するために、handleKeyDown を渡しているのが全体像です。
'use client'
export const Menu = ({ className }: { className?: string }) => {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLUListElement>(null)
const pathname = usePathname()
const query = useSearchParams()
const handleOpen = useCallback(() => {
// ...
}, [])
const handleClose = useCallback(() => {
// ...
}, [])
const handleKeyDown: KeyboardEventHandler<HTMLUListElement> = (event) => {}
return (
<div className={cn('relative', className)}>
<button
type="button"
aria-label={t('hamburger_menu')}
aria-expanded={open}
className="text-header-text"
onClick={open ? handleClose : handleOpen}
>
{open ? (
<X size={40} className="stroke-1 p-2" />
) : (
<Tally3 size={40} className="mt-0.5 rotate-90 stroke-1 p-2" />
)}
</button>
{open && (
<>
{/** biome-ignore lint/a11y/useKeyWithClickEvents: handle by useEffect */}
{/** biome-ignore lint/a11y/noStaticElementInteractions: backdrop */}
<div
onClick={handleClose}
className={cn(
'inset-x-0 top-12 bottom-0 h-[calc(100vh-48px)] w-dvw bg-background/40',
)}
/>
<ul
ref={ref}
className={cn(
'fixed inset-x-0 top-12 bottom-0 bg-header-menu-background py-0.5 text-center',
'h-fit max-h-[calc(100vh-48px)] overflow-y-auto',
'border-header-menu-border border-t',
)}
onKeyDown={handleKeyDown}
>
{contents.map((content) => (
<li key={content.text}>
<Link href={content.link}>{content.text}</Link>
</li>
))}
</ul>
</>
)}
</div>
)
}
開閉をハンドルする関数はこのようになります。
setOpen で状態を変えつつ、body にスタイリングを施すことでメニューを開いている際に背景がスクロールされないようにしています。handleOpen では、スタイルを追加して、handleClose では空文字を設定することでスタイルをリセットしています。
dialog 要素の標準実装では背景スクロールができることや、様々なサイトでも背景スクロールができる実装もあります。
ただ、モバイル向けに挙動を考えた時に意図せぬスクロールを防ぐ方が良いという結論でブロックしています。
const handleOpen = useCallback(() => {
setOpen(true)
const body = document.body
body.style.touchAction = 'none'
body.style.overflow = 'hidden'
body.style.overscrollBehavior = 'none'
}, [])
const handleClose = useCallback(() => {
setOpen(false)
const body = document.body
body.style.touchAction = ''
body.style.overflow = ''
body.style.overscrollBehavior = ''
}, [])
次に、メニューでのキーボード操作の制御になります。分岐的には下記のような形で調整しています。
- Esc キーの場合はメニューを閉じる
- Tab キーの場合は現在アクティブな要素の次の要素にフォーカスを行う。アクティブな要素がメニューアイテムの最後だった場合は最初の要素にフォーカスを行う(フォーカストラップ)。
- Tab キーと Shift キーを押下した場合は、現在アクティブな要素の前の要素にフォーカスを行う。アクティブな要素がメニューアイテムの最初だった場合は最後の要素にフォーカスを行う(フォーカストラップ)。
- 矢印下キーの場合は Tab キーと同様の操作をする
- 矢印上キーの場合は Shift + Tab キーと同様の操作をする
const handleKeyDown: KeyboardEventHandler<HTMLUListElement> = (event) => {
if (event.key === 'Escape') {
handleClose()
}
const links = ref.current?.querySelectorAll('a')
if (links === undefined || links.length === 0) {
return
}
const firstLink = links[0]
const lastLink = links[links.length - 1]
// biome-ignore lint/complexity/useIndexOf: handle type problem
const currentIndex = Array.from(links).findIndex(
(link) => link === document.activeElement,
)
if (event.key === 'Tab') {
if (event.shiftKey) {
if (document.activeElement === firstLink) {
event.preventDefault()
lastLink?.focus()
return
}
} else {
if (document.activeElement === lastLink) {
event.preventDefault()
firstLink?.focus()
return
}
}
return
}
if (event.key === 'ArrowDown') {
event.preventDefault()
if (currentIndex === -1 || currentIndex === links.length - 1) {
firstLink?.focus()
} else {
links[currentIndex + 1]?.focus()
}
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
if (currentIndex === -1 || currentIndex === 0) {
lastLink?.focus()
} else {
links[currentIndex - 1]?.focus()
}
return
}
}
ハンバーガーメニューがブラウザ幅に応じて表示されるため、表示されなくなるまでリサイズされた時に handleClose
を実行するように Effect を入れています。
ResizeObserver のおかげで、簡単にブラウザ幅の変更を検知できます。
useEffect(() => {
const breakpoint = (() => {
const _breakpoint = getComputedStyle(document.documentElement)
.getPropertyValue('--breakpoint-lg')
.trim()
const breakpoint = Number.parseFloat(_breakpoint)
const fontSize = getComputedStyle(document.documentElement).fontSize
return breakpoint * Number.parseFloat(fontSize)
})()
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentRect.width
if (width >= breakpoint) {
handleClose()
}
}
})
observer.observe(document.body)
return () => {
observer.disconnect()
}
}, [handleClose])
メニュー要素をクリックしたり、メニューを開いたまま戻ったりした際に、メニューを閉じるように Effect を追加します。
useEffect(() => {
handleClose()
}, [pathname, query])
実装してみると考慮する箇所が多く、dialog 要素がいかにシンプルにしてくれるかが分かりますね。
さいごに
ARIA Authoring Practices Guide や、MDN のドキュメントのおかげで日々誰もが使える Web サイトを構築する知見が得られます。
コミュニティやグループの尽力には感謝しかありません。
ぜひダイアログ実装の参考にしてください。
参考資料