モーダルダイアログの未来はdialog要素で幸せになるか


こんにちは、及川です。

今回のテーマはdialog要素です。みなさん、dialog要素はご存知でしょうか?もう現場で使っているでしょうか?

dialog要素はいわゆるダイアログボックスを描画するための実装で、HTML要素の中では比較的新しめの要素です。このdialog要素の仕様を理解し、モーダルダイアログコンポーネントとしてどのように実装するかを学習することが本記事のゴールです。

dialog要素 ってなに?

dialog要素はダイアログボックスを表現するためのHTML要素です。

cf) <dialog>: ダイアログ要素 – HTML: ハイパーテキストマークアップ言語 | MDN

dialog要素は新しめとは言うものの意外と長い歴史をもっていて、2012年あたりから今の形でHTMLの草案に追加されたり削除されたりを繰り返し、全てのモダンブラウザで動くようになったのが2022年のことです。2023年12月現在では caniuse によれば Global Usage は95%を超え、ほとんどのユーザーがdialog要素が動く環境でインターネットブラウジングをしているとされています。

cf) Dialog element | Can I use… Support tables for HTML5, CSS3, etc

従来ダイアログボックスを実装する時は主にUIライブラリに頼るなどしていましたが、現在のところほとんどのUIライブラリはdialog要素を採用していません。ポピュラーなUIライブラリである MUI では Dialog コンポーネントの実装について議論が交わされていました。

cf) [Dialog] Use html dialog element · Issue #32776 · mui/material-ui

この記事では、ダイアログボックスの中でも明確な特徴をもつモーダルダイアログに焦点をあて、これをdialog要素で実装することの是非について考えていきます。

dialog要素が出来る事と出来ない事

dialog要素がモーダルダイアログの実装にどれくらい適しているのか、機能の差分から考えてみましょう。まず、わたしたちがモーダルダイアログUIに触れる時に期待する機能を挙げ、dialogの素の状態の機能と比較してみます。

モーダルダイアログに期待する機能

以下が、わたしたちがモーダルダイアログが「できるべきだ」と考える要件です。

  • バックドロップ(背景)とダイアログボックスで構成されている
  • 最前面のレイヤーに描画される
  • ユーザーの操作で閉じる事ができる
  • 背景ドキュメントへのアクセスをブロックする(クリック・選択・フォーカスはバックドロップを貫通しない)
  • 背景ドキュメントのスクロールをロックする
  • バックドロップをクリックすることで閉じることができる

これらの仕様をdialog要素はどれくらいサポートしてくれるのでしょうか。

dialog要素がサポートしない機能

dialog要素は上で挙げた機能の多くを仕様として備えているので、ここではサポートしない機能を挙げます。

  • 背景ドキュメントのスクロールをロックできない
  • バックドロップをクリックしても閉じることができない

見た目、機能ともにモーダルダイアログに必要なものは概ね揃っているので、dialog要素で実装することには大きなメリットがありそうです。あとは、サポートされないこれらの機能を追加実装してやればよいでしょう。

dialog要素で実装する時に気をつけるべきこと

モーダルダイアログをdialog要素で実装していくにあたって注意するべきポイントがいくつかあります。

開閉は show/showModal(), close() メソッドでおこなうこと

HTMLDialogElementの仕様では、開閉制御に2つの方法が用意されています。

  1. open 属性を切り替えておこなう
  2. show/showModal() で開き、 close() で閉じる

しかし、これらのうち open 属性によるコントロールは推奨されません。 open 属性で制御した場合、イベントやメソッドが正しく機能しなくなるためです。さらに、モーダルダイアログは showModal() メソッドを利用した場合のみ開くことができます。( open 属性でモーダルダイアログを開くことはできません)

open 属性は読み取り専用として割り切るべきでしょう。

React の Portal は不要であること

ダイアログボックスを自前で実装した経験がある方はご存知だと思いますが、 div 等で実装する場合は React.createPortal() を使っておこなう必要があります。

cf) createPortal – React

これは、div で作られたダイアログボックスでは「最前面のレイヤーに描画される」要件の対応が不完全になってしまうためです。具体的には、例えば祖先の要素に overflow: hidden が指定されていた場合にはみ出した部分が表示されずにくり抜かれたような格好になってしまいます。React ではこれを回避するために Portal を使ってダイアログボックスの要素をDOM上の別の場所でレンダーさせるのです。

しかしdialog要素で実装する場合 Portal は不要です。dialog は祖先要素のスタイルがどうあれトップレイヤーでウィンドウ全体に描画されます。これは dialog を使用する上で最も嬉しいポイントでしょう。

dialog要素によるモーダルダイアログコンポーネントの実装

それでは実際にdialog要素でモーダルダイアログのReactコンポーネントを習作してみましょう。上で挙げた課題ごとにステップに分けて解説していきます。

open プロパティで開閉をコントロールする

dialog要素としては open 属性の使用は非推奨ですが、コンポーネントとしては有名どころのUIライブラリに倣って open プロパティで開閉の制御をおこないたいと思います。

interface Props {
  children: ReactNode
  open: boolean
  onClose: () => void
}

インターフェースはこのように定義し、真偽値の open プロパティで外側から表示状態をコントロール出来るようにします。

export function ModalDialog ({ children, open, onClose }: Props): JSX.Element {
  const ref = useRef<HTMLDialogElement | null>(null)

  useEffect(() => {
    if (ref.current === null) return
    if (open) {
      ref.current.showModal()
    } else {
      ref.current.close()
    }
  }, [ref, open])

  return (
    <dialog ref={ref} onClose={onClose}>
      {children}
    </dialog>
  )
}

バックドロップをクリックすると閉じる

dialog要素のバックドロップは ::backdrop 疑似要素として表現されているので直接クリックイベントを拾うのは困難です。この機能の実現は子要素と event.stopPropagation() の組み合わせでイベントバブリングを遮断するのが最もシンプルだと思います。

  return (
    <dialog
      ref={ref}
      style={{ padding: 0 }}
      onClick={onClose}
      onClose={onClose}
    >
      <div
        style={{ padding: 16 }}
        onClick={e => { e.stopPropagation() }}
      >
        {children}
      </div>
    </dialog>
  )

子要素の divclick イベントで event.stopPropagation() を呼ぶことでイベントの伝播を無効化し、ダイアログボックス内をクリックしてもdialog要素が閉じないようにしています。

描画がイメージしやすいようにインラインスタイルで適当に指定しています。実際はCSSライブラリなどでデザインにあったスタイルをあてましょう。

背景コンテンツのスクロールをロックする

スクロールのロックはbody要素に overflow: hidden をあてるのがポピュラーですね。ただ、それだけではスクロールバーが表示されていた場合に、バーの表示・非表示が切り替わってわずかなカクつきが生じてしまいますので、スクロールバーの幅の分だけbody要素に padding-right を指定する事で回避します。

  useEffect(() => {
    if (ref.current === null) return
    if (open) {
      ref.current.showModal()
      Object.assign(document.body.style, {
        overflow: 'hidden',
        paddingRight: `${window.innerWidth - document.body.clientWidth}px`
      })
    } else {
      ref.current.close()
      Object.assign(document.body.style, {
        overflow: 'visible',
        paddingRight: 0
      })
    }
  }, [ref, open])

ファイナルコード

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

interface Props {
  children: ReactNode
  open: boolean
  onClose: () => void
}

export function ModalDialog ({ children, open, onClose }: Props): JSX.Element {
  const ref = useRef<HTMLDialogElement | null>(null)

  useEffect(() => {
    if (ref.current === null) return
    if (open) {
      ref.current.showModal()
      Object.assign(document.body.style, {
        overflow: 'hidden',
        paddingRight: `${window.innerWidth - document.body.clientWidth}px`
      })
    } else {
      ref.current.close()
      Object.assign(document.body.style, {
        overflow: 'visible',
        paddingRight: 0
      })
    }
  }, [ref, open])

  return (
    <dialog
      ref={ref}
      style={{ padding: 0 }}
      onClick={onClose}
      onClose={onClose}
    >
      <div
        style={{ padding: 16 }}
        onClick={e => { e.stopPropagation() }}
      >
        {children}
      </div>
    </dialog>
  )
}

おわりに

現在のdialog要素はダイアログボックスを描画するために実装されている要素で、ダイアログボックスを描画するために必要な物はほとんど揃っています。モーダルダイアログの実現には少してこ入れをしてあげないといけませんが、その手間を加味してもdialog要素を採用するメリットは大きいと言えるでしょう。

UIライブラリでの採用はまだまだですが、今後に期待しましょう。

Gaji-Laboでは、 Next.js 経験が豊富なフロントエンドエンジニアを募集しています

弊社では Next.js の知見で事業作りに貢献したいフロントエンドエンジニアを募集しています。大きな制作会社や事業会社とはひと味もふた味も違う Gaji-Labo を味わいに来ませんか?

Next.js の設計・実装を得意とするフロントエンドエンジニア募集要項

もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください。お仕事お問い合わせや採用への応募、共に大歓迎です!

求人応募してみる!


投稿者 Oikawa Hisashi

フロントエンドエンジニア。モダンなJavaScript開発に関心があります。 デザインからバックエンドまで網羅的にこなすマルチデザイナーとして長く活動してきた経験を活かして、これから関わる様々なものをデザインしていきたいです。チームもコミュニケーションもデザインするもの。ライフワークはピアノと水泳。