UIをきちんと考えた実装 - モーダルダイアログ (モーダルウィンドウ)

Webサイトやアプリなどのソフトウェア開発において、ユーザーのアクション・操作に対して何らかのレスポンスを返す、という相互的なUIを実装する機会があります。

こうしたUIに対し見た目先行で仕組み・機能への理解が足りないのか、疑問符のつく使われ方を見ることが割とあります。デザイナーであっても開発実装者であっても、**そのUIの原型がどういった目的や仕様で生み出されたものなのかを調べる**などして、適用前に一度問い直してみることは大事かなと思います。

## モーダルダイアログ (モーダルウィンドウ)とは？

なかでもよく誤用されるパーツの例として、今回は「モーダルダイアログ(モーダルウィンドウ)」を取り上げます。以下が大まかなモーダルダイアログの基本仕様です。

1. アクションを実行すると**画面最前面にウィンドウが現れ、選択肢を選ぶ、文字入力する、閉じるなど何かの操作をユーザーにさせる**
2. 別ウィンドウが表示され操作が終わるまでの間、**操作の起点となった画面(背景または親画面)は操作を受け付けない**

「モーダルウィンドウ」での検索ボリュームのほうが大きいのですが「モーダルダイアログ」名称のほうがこのUIの機能や役割がよりつかみやすいため、本記事ではモーダルダイアログとします。
<img alt="モーダルダイアログUIのサンプル画面キャプチャ。ダイアログを開いたところ" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97066/a2ac1f31-bebe-d898-0521-9c344cfc1bc1.png">
モーダルダイアログUIのサンプル画像

## 名称の定義

「モーダル」が正式名称かのような場面に多く出会います。モーダル(**modal**)とは英語で「形式上の～」、**コンピューター用語としては「モードを持つ～」といった意味の形容詞**で、モノ(名詞)のように呼ぶのは略称だとしても変です。

**「ダイアログ」または「ダイアログボックス」とするのが適切**でしょう。ダイアログ(dialog)は「対話」(名詞)を意味し、**システムとユーザーによる対話形式のUI**ととらえるとしっくり来ます。

「ポップアップ(pop-up)」や「ライトボックス(Lightbox)」と呼ぶケースにも遭遇したことがあるのですが、それらよりもダイアログの名称がふさわしいでしょう。

## OOUIにおけるモーダル、モードレス

せっかくモーダルという単語が出ているので、ソフトウェア開発において重要な**OOUI(オブジェクト指向ユーザーインターフェース/オブジェクトベースUIとも)**の考え方と併せて、このUIの定義を考えていきます。

コンピューターアプリケーションのインターフェース設計において、**モードレス(modeless)**と**モーダル(modal)**という2つの対比的な思想が存在します。以下、その意味についてソシオメディアの用語集から引用します。

> モードレス
> modeless
> モードがない状態。ユーザーインターフェースをデザインする際に目指すべき状態。状況に依存した機能制限がなく、自由な手順でタスクを進行することができ、かつ特定の操作がシステムによって常に一定に解釈される状態。

> モーダル
> modal
> モードがある状態。つまり、システムが特定の機能の使用に制限された状態。ユーザーが自由に操作を行えなくなることと、モード別に機能の意味や振る舞いが変化することから、ユーザーインターフェースのデザインでは、できる限りモードを設けないほうがよいとされる。

引用元：[ソシオメディア | モードレス](https://www.sociomedia.co.jp/326)、[ソシオメディア | モーダル](https://www.sociomedia.co.jp/1320)

モードレスとは、コンピューターまたはWebサイト・アプリを操作する際に**操作を限定されない自由な状態**を指し、モーダルはその逆で**システム側から操作を限定された状態**を指します。

まずユーザーが目的(オブジェクト)を選び、次に自由な操作・アクションが選択できることがGUI(グラフィカル・ユーザー・インターフェース)の基本思想であり、ソフトウェアデザインの出発点です。この**オブジェクトベースなUIにおいて、モーダルは極力避けるべき状態**とされています。

私たちはGUIユーザーとして、ときどき運営者都合で操作を制限された状態に遭遇します。それはビジネス側・売る側のロジックという強制力が働いた、不自由で押しつけがましいものになってはいないでしょうか。
<img width="350" alt="とあるWebサイトのトップ画面。表示と同時にダイアログが開かれる" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97066/f837de0b-5faf-3f5e-027e-403dc4140ad2.png">
画面を開いた際に即出現して「ぐぬぅ...」となるモーダルダイアログ例。ユーザーの行動の選択肢を狭めストレスを感じてしまう。disってないよ

## モードレスダイアログという選択肢、あるいは...

モーダルダイアログがこの形態のUIの通名になっている感がありますが、**モードレスダイアログ**も当然存在します。ダイアログが**モーダル(モードが存在して制限されている)か、モードレス(モードが存在せず自由度が高い)かという状態の両軸を表している**だけです。

そして、よほどユーザーの操作を限定する必要がなければモーダルではなく、モードレスダイアログを検討・選択しても良いのかもしれません。ただでさえ不意に画面を遮って現れるのです。操作の自由はなるべくユーザーに委ねるほうがストレスにもつながらないでしょう。

モーダル(操作を絞る)がユーザーにとって有効なケース(必須の確認事項や限定されたプロセスなど)も確かに存在するので、**使い分けやそもそもダイアログを利用しないことを慎重かつ適切に判断していきたい**ところです。

<img width="500" alt="Microsoft Office ExcelのUI画面。「検索と置換」ダイアログボックスを開いているところ" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97066/d9824f15-65f0-7751-119d-8a1aacaca8d9.png">
モードレスダイアログの例
Excelの「検索と置換」ダイアログは開いていても、背景画面の操作が可能。同様の機能はWebでも見ます

## 機能定義と実装

それではここまでの流れを踏まえてモーダルダイアログの機能要件を定義し、Web UIとしての実装に入っていきたいと思います。

### 機能定義

- ボタンアクションで画面最前面にダイアログボックス(疑似的な別ウィンドウ)が現れる
- ダイアログが開いたら自身にフォーカスし、次のタブ操作でダイアログ内1番目のインタラクティブ要素にフォーカスする
- ダイアログ内では選択肢を選ばせるなどの限定的な操作にする
- ダイアログ表示中は、起点となる画面(背景または親画面)に対する操作やフォーカスを受け付けない　**※**
- ダイアログを閉じるボタンを設定する
- ダイアログ外をクリック・タッチした場合、ダイアログを閉じる　**※**
- \[Esc\](Escape)ボタン押下でダイアログを閉じる
- ユーザーが操作を終えたらダイアログを閉じ、何らかの値を返す

**※**マークのついた項目は、モードレスダイアログである場合は不要な機能を表します

### 実装方針

- HTMLの`dialog`要素(`open`属性)とJavaScript APIの**HTMLDialogElement**を用いて実装する
- 上記の技術は現状一部の主要ブラウザ(Chrome、Edge)しか実装対応していないため、**Polyfill(dialog-polyfill)を組み込んで対応外ブラウザ(Firefox、Safari)にも対応**する
- ダイアログが開いた際の背景または親画面は、明度を下げ操作不能を示す。暗い背景レイヤーはCSS `::backdrop`疑似要素で実装

※ サンプルコードでは、`role`属性や`aria-*`属性を用い**支援技術による適切な情報伝達**を意識しています。キーボード操作などで必要な**フォーカス管理をJSで補い**、CSSに構造依存しないUI実装を心がけています

### コード

```HTML:HTML・head内
<script src="https://cdnjs.cloudflare.com/ajax/libs/dialog-polyfill/0.5.0/dialog-polyfill.min.js" defer></script>
<script src="main.js" defer></script>
```
```HTML:HTML・body内
<div id="modal-dialog" class="modal-dialog">
  <h1>モーダルダイアログ</h1>
  <button type="button" id="dialog-open" class="dialog-open">
    モーダルダイアログ<br>
    (モーダルウィンドウ)を開く
  </button>
  <dialog id="dialog-panel" class="dialog-panel" role="dialog" aria-describedby="d-message">
    <p id="d-message" class="dialog-panel__message" role="document">
      モーダルダイアログ(モーダルウィンドウ)<br>
      とはどんなものか知っていますか？
    </p>
    <div class="dialog-panel__buttons">
      <button type="button" id="dialog-yes" class="dialog__button">はい</button>
      <button type="button" id="dialog-no" class="dialog__button">いいえ</button>
    </div>
    <button type="button" id="dialog-close" class="dialog__close" aria-label="このモーダルダイアログを閉じる">
      ×
    </button>
  </dialog>
  <p class="dialog-response">
    ダイアログの返り値：<span id="return-value"></span>
  </p>
</div>
```

```javascript:JavaScript
class ModalDialog {
  constructor() {
    this.wrap = document.getElementById('modal-dialog');
    this.open = document.getElementById('dialog-open');
    this.dialog = document.querySelector("[role='dialog']");
    this.yes = document.getElementById('dialog-yes');
    this.no = document.getElementById('dialog-no');
    this.close = document.getElementById('dialog-close');
    this.returnSpan = document.getElementById('return-value');

    // Polyfillを読み込む関数
    dialogPolyfill.registerDialog(this.dialog);

    this.showDialog();
    this.hideDialog();
    this.respondValue();
  }
  showDialog() {
    this.open.addEventListener('click', () => {
      this.dialog.showModal();
      this.dialog.style.visibility = 'visible';
      this.dialog.classList.remove('is-motioned');
      this.dialog.setAttribute('tabindex', '0');
      this.dialog.focus();
    });
  }
  hideDialog() {
    this.yes.addEventListener('click', () => {
      this.hideProcess('はい');
    });
    this.no.addEventListener('click', () => {
      this.hideProcess('いいえ');
    });
    this.close.addEventListener('click', () => {
      this.hideProcess('きみ、閉じるボタンを押したね...');
    });
    this.dialog.addEventListener('cancel', () => {
      this.hideProcess('Escapeボタン押しました？');
    });
    this.dialog.addEventListener('click', (event) => {
      if (event.target === this.dialog) {
        this.hideProcess('きみ、ウィンドウの外を押したね...');
      }
    });
  }
  hideProcess(resText) {
    this.dialog.close(resText);
    this.dialog.classList.add('is-motioned');
    this.wrap.setAttribute('tabindex', '0');
    this.wrap.focus();
    setTimeout(() => {
      this.dialog.style.visibility = 'hidden';
    }, 250);
  }
  respondValue() {
    this.dialog.addEventListener('close', () => {
      this.returnSpan.innerHTML = this.dialog.returnValue;
    });
  }
}

const modalDialog = new ModalDialog();
```

```css:CSS
/* 最低限のスタイル */
.modal-dialog {
  width: 100%;
  max-width: 64.25rem;
  margin: 2rem auto;
}

.dialog-open {
  display: block;
  width: 300px;
  margin: 4rem auto;
  padding: 1rem;
  border: 1px solid hsla(0, 0, 40%, 1);
  text-align: center;
  border-radius: 5px;
}

.dialog-panel {
  position: absolute;
  width: -moz-fit-content;
  width: -webkit-fit-content;
  width: fit-content;
  height: -moz-fit-content;
  height: -webkit-fit-content;
  height: fit-content;
  top: 15%;
  left: 0;
  right: 0;
  margin: auto;
  padding: 0;
  border: 2px solid hsla(0, 0, 40%, 1);
  background: hsla(0, 0, 100%, 1);
  border-radius: 5px;
}

/* native backdrop */
.dialog-panel::backdrop {
  background-color: rgba(0, 0, 0, .5);
}

/* polyfill backdrop */
.dialog-panel + .backdrop {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  background-color: rgba(0, 0, 0, .5);
}

.dialog-panel__message {
  margin: auto;
  padding: 4rem 3rem 0;
}

.dialog-panel__buttons {
  text-align: center;
  padding: 2rem 0 2.5rem;
}

.dialog__button {
  display: inline-block;
  min-width: 5rem;
  padding-top: .625rem;
  padding-bottom: .525rem;
  border: 1px solid hsla(0, 0, 40%, 1);
  border-radius: 5px;
  text-align: center;
  line-height: 1.5;
}

.dialog__button:not(:first-of-type) {
  margin-left: 1.75rem;
}

.dialog__close {
  position: absolute;
  top: 1rem;
  right: 1rem;
  width: 2.3rem;
  height: 2.3rem;
  background-color: hsla(0, 0, 20%, 1);
  font-size: 2rem;
  color: hsla(0, 0, 100%, 1);
  border-radius: 50%;
}
```

`HTMLDialogElement`Web APIの`show()` `showModal()` `close()`メソッドやプロパティなどのコードや仕様についても細かく解説したかったのですが、それ以外の説明がやや長くなってしまい、力尽きたのでここで終わります。

実装の詳細については上記コードと下記参考URLをご参照ください。

## 参考URL
- [モードレスはどこから来たか – オブジェクト指向UIの起源 –](https://modelessdesign.com/backdrop/335)
- [モーダルと非モーダルダイアログ： 用いるべき場合とそうでない場合 - U-Site](https://u-site.jp/alertbox/modal-nonmodal-dialog)
- [HTML Standard (the-dialog-element)](https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element)
- [HTML Standard 日本語訳 (the-dialog-element)](https://momdo.github.io/html/interactive-elements.html#the-dialog-element)
- [HTMLDialogElement - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/HTMLDialogElement)
- [::backdrop - CSS: カスケーディングスタイルシート | MDN](https://developer.mozilla.org/ja/docs/Web/CSS/::backdrop)