キーワード検索48の記事がヒットしました。

鳥に生まれることができなかった人へ

dialog要素を使用して検索モーダルを実装する

当ブログはキーワード検索機能を実装しているんですが、そのUIがこれまではこーんな

image01

感じだったのですが、「もうちょっとちゃんとした物にしたい」ということで

image02

こうなりました。

前バージョンではdiv要素を使い、表示/非表示の切り替えはuseStateを使用して行っていましたが、dialog要素を用いればもっとシンプルに実装できそうだったので改修を行いました。

※ 以下、この検索機能のことを検索モーダルと呼ぶことにします。

今回の記事は、当ブログでどのように検索モーダルを実装したのか、そしてdialog要素について解説します。

dialog要素

今回の話の主役であるdialog要素ですが、MDNによると、

HTML の <dialog> 要素は、ダイアログボックスや、消すことができるアラート、インスペクター、サブウィンドウ等のような対話的コンポーネントを表します。

とのことです。ユーザーが興味のあるキーワードを入力し、その結果が出力される、ということで、検索モーダルも対話的コンポーネントと言え、dialog要素を使うのにぴったりですね。

open属性で表示される

dialog要素は設置するだけでは表示されません。dialog要素にopen属性が付与されている必要があります。

app.tsx
<body>
  <dialog open>
    Dialog area
  </dialog>
</body>

これでモーダルっぽくないモーダルが表示されます。

image03

showModalメソッドを利用する

実際には、モーダルは最初から表示されていることはなく、ユーザー操作によって表示されるようになることがほとんどです。

これを実現するために、dialog要素のshowModalメソッドを使用します。showModalメソッドを使用すればdialog要素にopen属性を付与することができます。showModalメソッドを呼び出すにはJavaScriptの力を利用する必要があります。

では早速実装していきます。Reactを使用しているという前提のもと、dialog要素の取得にはuseRefを使用します。また、各要素にはクラス名を当てておきます(記事の後半でCSSを絡めた解説を行うため)。

App.tsx
import { useRef } from "react";

const App = () => {
  const ref = useRef<HTMLDialogElement | null>(null);

  return (
    <dialog
      ref={ref}
      className="dialog"
    >
      Dialog area
    </dialog>
  )
}

export default App;

続いて、dialog要素の外にbutton要素を設置し、クリック時にshowModal関数を実行するようにします。

App.tsx
const App = () => {
  const ref = useRef<HTMLDialogElement | null>(null);

  // showModal関数を追加
  const showModal = () => {};

  return (
    <>
      {/* クリックするとshowModal関数が呼ばれる */}
      <button onClick={showModal}>Open</button>

      <dialog
        ref={ref}
        className="dialog"
      >
        Dialog area
      </dialog>
    </>
  )
}

showModal関数内では以下のようにしてrefからshowModalメソッドを呼び出します。

  // showModal関数を追加
  const showModal = () => {
    ref.current?.showModal();
  };

これでbutton要素をクリックすればモーダルが表示されるようになりました。

backdrop疑似要素が付与される

さて、showModalメソッドを利用してモーダルを表示させた場合、恐らくモーダル以外の画面全体が薄い灰色になったと思います。これはbackdrop疑似要素と呼ばれるものです。ブラウザーのデベロッパーツールなどでHTML構造を確認してみましょう。

image05

backdrop疑似要素の効果はモーダル以外を灰色にするだけではなく、最上位レイヤーに置かれ、その配下の要素を隠しアクセスできなくする効果も持ちます。

試しに、モーダルを開くために設置したbutton要素をクリックしてみてください。クリックできないようになっているはずです。また、Tabキーを何度か押してカーソルを移動させても、button要素には移動しないようになっています。外部ライブラリーに頼ることなく、HTMLの標準機能を利用するだけでここまでリッチなUIを実現できるのはとても嬉しいですね。

これをshowModalメソッドを利用せず、素のJavaScriptで(setAttributeメソッドなどで)ゴニョゴニョっとopen属性を付与した場合はどうなるでしょうか。モーダル自体は表示されますが、backdrop疑似要素は付与されずその恩恵を受けることはできません。

closeメソッドでモーダルを閉じる

モーダルを閉じたいときは、dialog要素のcloseメソッドを使用します。dialog要素の中にcloseModal関数を呼び出すbutton要素を追加します。

App.tsx
const App = () => {
  const ref = useRef<HTMLDialogElement | null>(null)

  const showModal = () => {
    ref.current?.showModal();
  };

  // closeModal関数を追加
  const closeModal = () => {
    ref.current.close();
  };

  return (
    <main>
      {/* クリックするとshowModal関数を呼び出す */}
      <button
        onClick={showModal}
        className="openButton"
      >
        Open
      </button>

      <dialog
        className="dialog"
        ref={ref}
      >
        Dialog area

        {/* クリックするとcloseModal関数を呼び出す */}
        <button
          onClick={closeModal}
          className="closeButton"
        >
          Close
        </button>
      </dialog>
    </main>
  )
}

closeModal関数は以下のように定義し、closeメソッドを呼び出します。

App.tsx
// closeModal関数を追加
const closeModal = () => {
  ref?.current?.close();
};

これでモーダルを閉じるボタンを実装できました。動作するかどうか確認してみてください。

モーダルが閉じることはもちろん、backdrop疑似要素も消え、後ろに隠れていたbutton要素にもアクセス出来るようになっていることに注目です。

image07

このように、dialog要素のshowModalメソッドとcloseメソッドを利用するだけで自然なモーダルを構築できることが分かりました。一般的なモーダルの実装に十分な機能を有していると言え、是非とも利用したい要素ですね。

モーダルを閉じる範囲を拡大する

ここまででCloseボタンを押すことでモーダルを閉じるように実装することはできましたが、モーダル以外の部分(backdrop疑似要素)をクリックした時にもモーダルを閉じれるようにしてみましょう。

ひとまず、dialog要素自身にもonClick={closeModal}を追加してみましょう。

App.tsx
<dialog
  className="dialog"
  ref={ref}
  // 追加
  onClick={closeModal}
>
  Dialog area

  <button
    onClick={closeModal}
    className="closeButton"
  >
    Close
  </button>
</dialog>

しかしこの実装では、モーダル自体をクリックしてもモーダルが閉じてしまいます。

これはよろしくない動作ですね。これを解消するために、以下の記事を参考にしました。

React/TypeScript】Dialogタグを使ってコンポーネントを作ってみる ver.2022.09

Dialog要素の背景をクリックした時のイベントを取得する - Qiita

React アプリのモーダルを dialog 要素で実装する - 30歳からのプログラミング

dialog要素の直下にdiv要素を置き、onClick={stopPropagation}を付与します。

App.tsx
<dialog
  className="dialog"
  ref={ref}
  onClick={closeModal}
>
  <div onClick={stopPropagation}>
    Dialog area

    {/* クリックするとcloseModal関数を呼び出す */}
    <button
      onClick={closeModal}
      className="closeButton"
    >
      Close
    </button>
  </div>
</dialog>

stopPropagation関数は以下のように実装します。

App.tsx
const stopPropagation = (e: React.MouseEvent<HTMLDivElement>) => {
  e.stopPropagation();
};

これでクリックイベントの伝搬をdev要素でストップさせ、モーダルをクリックしてもモーダルが閉じないように実装できました。

CSSでスタイルを調整する

以上でモーダルの基本機能は実装できました。ここからはCSSでスタイルを適用していきます。

backdrop疑似要素のスタイル

backdrop疑似要素はデフォルトで透過性のある灰色になっていますが(正確にはブラウザー依存)、CSS側でスタイルを変更することができます。例えば、以下のようにすれば薄い青色に変更することができます。

App.css
.dialog::backdrop {
  background-color: rgba(0, 0, 255, 0.1);
}

image08

アニメーションを適用する

モーダルとbackdrop疑似要素にCSSでアニメーションを適用させてみます。まずはモーダルから。以下のようにすれば、下の方からふわっとモーダルが浮き上がってくるようになります。

app.css
.dialog {
  opacity: 0;
  animation-name: dialog-animation;
  animation-duration: 0.75s;
  animation-delay: 0.05s;
  animation-fill-mode: forwards;
}

@keyframes dialog-animation {
  0% {
    transform: translateY(10px);
  } 
  100% {
    opacity: 1;
    transform: translateY(0);
  }
}

一例ですが、backdrop疑似要素は以下のようにスタイリングします(サンプルを分かりやすくするため、背景色は濃く、アニメーションに2秒かかるようにしています)。

app.css
.dialog::backdrop {
  background-color: rgba(0, 0, 255, 0.5);
  animation-name: backdrop-animation;
  animation-duration: 2s;
}

@keyframes backdrop-animation {
  0% {
    opacity: 0;
  } 
  100% {
    opacity: 1;
  }
}

モーダル上でのスクロールがバックグラウンドに伝搬するのを防ぐ

次に、モーダル上でスクロールが発生している状況を考えます。また、ページはスクロールできるほど十分に長いとします。おおよそ、以下の画像のようなモーダルです。

image09

App.tsx
return (
  <>
    <button
      onClick={showModal}
      className="openButton"
    >
      Open
    </button>

    <dialog
      className="dialog"
      ref={ref}
      onClick={closeModal}
    >
      <div onClick={stopPropagation}>
        Dialog area

        <button
          onClick={closeModal}
          className="closeButton"
        >
          Close
        </button>
      </div>

      {/* スクロールされるリスト */}
      <ul className="list">
        <li>ダミー</li>
        <li>ダミー</li>
        <li>ダミー</li>
        <li>ダミー</li>
        <li>ダミー</li>
        <li>ダミー</li>
        <li>ダミー</li>
        <li>ダミー</li>
        <li>ダミー</li>
        <li>ダミー</li>
        <li>ダミー</li>
      </ul>
    </dialog>

    {/* スクロールできるほど長いページ */}
    <div style={{ "height": "400vh" }}>
      {/* ダミーテキストを挿入 */} 
    </div>
  </>
)
app.css
.list {
  height: 200px;
  overflow-y: scroll;
}

この状態でモーダルのリストをスクロールした時、スクロールがバックグラウンドに伝わります。もっと正確に言うならば、モーダルのリストのスクロールが最上部ないし最下部に達している状態でさらにスクロールを行った場合、バックグラウンドもスクロールされる、ということになります。以下のアニメーションのような動作です。

モーダル上のリストをスクロールしているのに、それがバックグラウンドにまで伝わってしまうのはユーザーの予期せぬ動作であり、避けたいところです。これはモーダル上のスクロール可能な要素のcssにoverscroll-behaviorを設定すれば防げます。

app.css
.list {
  height: 180px;
  overflow-y: scroll;
  overscroll-behavior: none;
}

このoverscroll-behaviorの動作については、ICS MEDIA様の以下の記事が詳しいです。

overscroll-behaviorがお手軽!モーダルUI等のスクロール連鎖を防ぐ待望のCSS


以上、駆け足になりましたが、dialog要素についてざっと眺めました。そのうち、もう少し個々の要素についてピックアップした記事も作成したいと思います。

参考

<dialog>: ダイアログ要素 | MDN

::backdrop | MDN