👩‍🔧

dialog 要素の closedby 属性のフォールバックを自力で書いてみた(非モーダルのダイアログからは目を反らしつつ)

に公開
2

こなさんみんばんわ。
まずは何でも自力で書いてみようのコーナーです。

最近の HTML の <dialog> 要素には closedby 属性というのが追加されており、ここに指定する値を変えることで

  • any にするとダイアログの外側クリックで閉じられるようになる(いわゆる light dismiss)
  • none にすると逆にうっかりタップなどの誤作動で大事なダイアログが閉じられないようにできる

といったことを HTML だけで(追加スクリプトなしで)実現できます。べんり〜
なのですが、この機能まだ Safari では実装されていません(´・_・`)

とはいえ、現時点でも多くのブラウザでは使える訳ですし、せっかくの便利(楽できる、ともいう)機能なので使いたいじゃないですか。なので、これからの HTML はもうこの方法で書いておくことにして、非対応ブラウザ向けには JavaScript によるフォールバックを加えておく、というのがいいんじゃないかなー🤔

…と思いまして、それを実現するために色々やってみました! というのが本日のネタです。

前提

  • 普通の静的 HTML に <script> タグで JavaScript を追加する、というケースを想定しています。これは単に自分の普段制作するものがほぼそんな感じだからです(React などの JS フレームワークでの動作は想定していませんし試してもないです、多分動作しない気がします)
  • とりあえず今回は非モーダルなダイアログのことは考えないこととします。理由は単に「個人的に <dialog> 要素で作ったダイアログを非モーダルで開きたい場面が少ないというかほぼない」からです😅

HTML

index.html
<!-- body の中身のみ -->
<p><button command="show-modal" commandfor="testDialog" id="openBtn" type="button">ダイアログを開く</button></p>
<dialog id="testDialog" closedby="any"><!-- もしくは `none` `closerequest` -->
  <p>このダイアログはモーダルです</p>
  <p><button command="close" commandfor="testDialog" id="closeBtn" type="button">ダイアログを閉じる</button></p>
</dialog>

ベースとなる HTML コードはこんな感じ。button 要素についてる command とか commandfor とかいった属性は Invoker Commands API という、これも最近ブラウザで実装が進んでいる API のものです。これについては別の記事で(そのフォールバックも含めて)解説をしております。

https://zenn.dev/jforg/articles/59ec64ba34d9e6

…忙しくて読んでる暇がないという方は、とりあえず「そういうスクリプトはないけど、ボタンクリックでダイアログは開閉するようになっている」という体で読み進めてください🤣

JavaScript

HTML の準備ができたので次は JavaScript を書いていきます。ファイル名は closedby.js とでもしておきましょう。HTML に適用する方法はみなさんのお好きな方法ですればいいと思いますが、最近の僕はもう <script type="module"> にして head 内でロードすることが多いです。理由は前述の別記事の方に書きました。

index.html
<!-- head の中に -->
<script type="module" src="closedby.js"></script>

<dialog> 要素の取得

document.getElementById で取得するような手法は汎用性に欠けるし、文書内に複数の <dialog> がある場合にも対処できるよう、querySelectorAll でまとめて取得して、繰り返して処理するようにします。ここで forEach() を使わない理由は後述。

closedby.js
const dialogs = document.querySelectorAll('dialog[closedby]')
for (const dialog of dialogs) {
  // do something...
}

対応ブラウザ・非対応ブラウザ分岐

対応ブラウザにこのスクリプトを適用する意味はないので、その場合(HTMLDialogElement.closedBy プロパティが値を返さない)は break して終了するだけにします。forEach() ではなく for…or ループを使っているのはこれが理由です。

closedby.js
for (const dialog of dialogs) {
  if (typeof dialog.closedBy !== 'undefined') break
  // do something more...
}

10/5追記: 初版では勢いでうっかり !===== と書いてしまってました😭 コメントでの指摘を受け気が付き修正しました(最後のまとめも同様です)。

closedby 属性の値に応じて何かする(何かって何)

ここから非対応ブラウザ向けの処理です。closedBy プロパティはなくても closedby 属性の値は getAttribute() で取れますので、その値に応じて何かやるという枠組みを switch 文で作っておきます。

closedby.js
// for…of ループ内
  const closedby = dialog.getAttribute('closedby')
  switch (closedby) {
    case 'any':
      // do something...
    case 'closerequest'
      // do something...
    case 'none':
      // do something...
    default:
      console.log(`Error: ${closedby} is invalid. It will be treated as the default.`)
  }

最後の行は無効な値が指定された時の対応で、いちおう「この値は不正だけどデフォルトとして扱われるよ」というメッセージをログに出していますが、追加で何かする必要はないので省略しても問題ないです。[1]

closerequest の場合 → 何もしない(既定の挙動と同じ)

まずは簡単なところから。closedby="closerequest" の場合、そのダイアログは開発者が実装した方法(<button> クリックで close() メソッドを実行など)およびプラットフォーム固有の動作(ESC キーの押下など)で閉じることができる、とありますが、これは showModal() メソッドで開いたモーダルダイアログにおいては closedby 属性を指定しなかった場合の既定の挙動と同一です。つまり何もする必要がありません。という訳で break しておしまいです。

closedby.js
// switch 文内
    case 'closerequest':
      // 非モーダルの場合にはやるべきことがあるのだけど、とりあえず今回は
      // do nothing
      break

none の場合 → cancel イベントの既定動作を無効に

次は closedby="none" の場合です。この場合ダイアログは開発者が実装した方法でしか閉じられないので、プラットフォーム固有の動作では閉じられなくする必要があります。これも実装は簡単で、cancel イベントが発生した場合に既定の動作を preventDefault() で無効にするだけです。

closedby.js
// switch 文内
    case 'none':
      dialog.addEventListener('cancel', e => e.preventDefault())
      break

any の場合 → ダイアログの外側クリックで閉じる処理を追加

最後は closedby="any" の場合です。この場合は closerequest の動作に加えて、ダイアログの外側クリックで閉じられる処理を追加する必要があります。その実装方法としては、おおむね次の2つに集約されると思います。

  1. ダイアログの寸法を getBoundingClientRect() で調べて、クリック位置がその範囲外だったら閉じる
  2. ダイアログ内にラッパー要素を配置して、クリックされた要素の祖先がそのラッパー要素でなければ閉じる

ですが、今回やっているのは単に「ダイアログの外側クリックで閉じる機能の追加」ではなく、あくまでも「closedby 属性非対応ブラウザ向けのフォールバック」です。非対応ブラウザであっても対応ブラウザとまったく同じマークアップで同じ機能を提供できないといけません。

となると、対応ブラウザではラッパー要素なんてなくても外側クリックで閉じられる訳ですから、追加の要素を加える必要がある 2. の方法を取ることは、少なくとも今回の題材においては適切ではないでしょう。という訳で 1. の方法を採用します。

closedby.js
// switch 文内
    case 'any':
      dialog.addEventListener('click', e => {
        const range = dialog.getBoundingClientRect()
        if (
          e.clientX < range.left
          || range.right < e.clientX
          || e.clientY < range.top
          || range.bottom < e.clientY
        ) dialog.close()
      break

完成!

以上をまとめると、次のようになります。コード量のバランスを考えて case 節の順序は少し入れ替えました。

closedby.js
const dialogs = document.querySelectorAll('dialog[closedby]')
for (const dialog of dialogs) {
  if (typeof dialog.closedBy !== 'undefined') break
  const closedby = dialog.getAttribute('closedby')
  switch (closedby) {
    case 'any':
      dialog.addEventListener('click', e => {
        const range = dialog.getBoundingClientRect()
        if (
          e.clientX < range.left
          || range.right < e.clientX
          || e.clientY < range.top
          || range.bottom < e.clientY
        ) dialog.close()
      break
    case 'none':
      dialog.addEventListener('cancel', e => e.preventDefault())
      break
    case 'closerequest'
      break
    default:
      console.log(`Error: ${closedby} is invalid. It will be treated as the default.`)
  }
}

非モーダルダイアログも考慮する場合の追加要件

今回は話を単純にするためにモーダルダイアログのみ考慮しましたが、非モーダルダイアログも考慮する場合にはもう少し追加が必要になります。でもそうなると、割と大変なんですよね…

closerequest の場合

非モーダルダイアログのデフォルトは none と同じ(開発者の実装方法でしか閉じられない)なので、ESC キーが押された場合に閉じられる処理を入れる必要がありますが、その場合

  • ダイアログが非モーダルで開かれていること(モーダルならわざわざ入れる必要がない)
  • ダイアログの中にテキスト入力欄があった場合、そこに日本語入力中でないこと(かな漢字変換がうまく行かずに ESC 押したらダイアログが閉じた…は困る)

あたりを考慮に入れないといけませんし、またモバイル端末などは ESC キー以外の方法でキャンセル処理ができるようですから、その辺も考える必要があります。

any の場合

非モーダルのダイアログには ::backdrop 擬似要素がないので「ダイアログの外側がクリックされた」という判断をするのが難しいです。なんせ「ダイアログの外側をクリックした」時には本当にダイアログの外側をクリックしてるので、ダイアログそのものにはクリックイベントが発生しませんから、それをダイアログ自体のイベントリスナーで感知することは不可能です…かなりの難題です。

いちおうそういったあたりも考慮して実装した(そして動作はしている様子の)コードが手元にあることはあるのですが、まだまだ掘り下げないといけない課題もありそうなので、とりあえず今回は保留にしております。機会があればまた公開するかもしれませんが、その前に Safari にサポートが入りそうな気がしないでもないです。涙

そんな訳で

dialog 要素の closedby 属性を指定するだけで、ダイアログを閉じやすくしたり閉じられなくしたりを HTML だけで宣言できるようになって便利になったけど Safari が非対応なのでフォールバックのスクリプトを書いてみたよ、というお話でした。

脚注
  1. たまにうっかり any と間違えて closedby="auto" とかやっちゃって、ダイアログ外側クリックしても何も起こらずにあああっ…ってなる時があるので、そういう時にはこれが入ってると助かりますよね。いやそれオレだけやろ。涙 ↩︎

Discussion

junerjuner

対応ブラウザにこのスクリプトを適用する意味はないので、その場合(HTMLDialogElement.closedBy プロパティが値を返さない)は break して終了するだけにします。

実装されている場合
const dialog = document.createElement("dialog");
console.log(typeof dialog.closedBy);
// -> "string"

なので

closedby.js
for (const dialog of dialogs) {
  if (typeof dialog.closedBy !== 'undefined') break
  // do something more...
}

では……?

Jeffrey FrancescoJeffrey Francesco

ご指摘ありがとうございます。

はい! まったくその通りでございます。涙
手元のスクリプトではちゃんとなってるのに、コピペせずに勢いで書いてしまったらミスってた模様です😭

という訳で、修正いたしました。