JavaScriptにはalert()
やconfirm()
といったメッセージボックスを表示する手段が用意されている。
きちんとモーダルで動作し、余計な処理が挟まれる心配がないという点では優秀なのだが、いかんせんシンプルすぎて融通が利かない。
もう少し見た目重視でコーディング的にも書きやすくできないものだろうかとあれこれ試行錯誤した結果、以下のようなコードで動作するものを作ることができた。
組み込みのものとは異なりどうしても呼び出しにawait
が必要になってしまうが、これはもう言語の性質上やむを得ないだろう。
JavaScriptlet result = await MessageBox.Confirm("本当に良いのですか?"); if (result == MessageBoxResult.Ok) { // OKの場合の処理 } else { // キャンセルの場合の処理 }
これを作るにあたり、絶対に機能として欲しかったのは応答への待機だ。
例えばJavaScriptに元々組み込まれているwindow.alert()
はユーザがボタンを押すまで待機し、ボタンが押されたら続く処理を実行する。
JavaScriptlet result = window.confirm("本当に良いのですか?"); if (result) { // OKの場合の処理 } else { // キャンセルの場合の処理 }
これは処理の流れとしても非常にわかりやすい。
歴史上、こういった処理を自作する場合にはメッセージボックスを呼び出すメソッドに対してコールバック関数を渡すのがセオリーだった。 例えば以下のようなものだ。
JavaScriptMessageBox.Confirm("本当に良いのですか?", function() { /* OKの時の処理 */ }, function() { /* キャンセルの時の処理 */ } );
これでも目的を実現することはできるが、正直に言って処理の流れがわかりにくく可読性が低い。
もっと直感的に読めるコードを実現できないものかと考え、以下のようなコードに落ち着いた。 実際の使い方は冒頭で紹介した通りだ。
これはTypeScriptで書いたものだが、型指定を全て削除すればJavaScriptとしても動作するはずだ。
TypeScriptclass KeyValue { public static readonly Esc = "Escape"; } export class MessageBoxResult { public static readonly Ok = "ok"; public static readonly Cancel = "cancel"; public static readonly Yes = "yes"; public static readonly No = "no"; public static readonly OkCancel = [this.Ok, this.Cancel]; public static readonly YesNo = [this.Yes, this.No]; } class MessageBoxIcon { public static readonly Information = "information"; public static readonly Exclamation = "exclamation"; public static readonly Question = "question"; } export class MessageBox { public static async Info(message: string, title: string = "情報"): Promise<string> { return new MessageBoxElement().Show(message, title, MessageBoxIcon.Information, MessageBoxResult.Ok); } public static async Alert(message: string, title: string = "エラー"): Promise<string> { return new MessageBoxElement().Show(message, title, MessageBoxIcon.Exclamation, MessageBoxResult.Ok); } public static async Confirm(message: string, title: string = "確認"): Promise<string> { return new MessageBoxElement().Show(message, title, MessageBoxIcon.Question, MessageBoxResult.OkCancel); } } export class MessageBoxElement extends HTMLElement { private Body = document.createElement("div"); private Title = document.createElement("div"); private Message = document.createElement("div"); private ButtonContainer = document.createElement("div"); private OkButton: HTMLButtonElement | null = null; private CancelButton: HTMLButtonElement | null = null; private OldActiveElement: HTMLElement | null = null; public constructor() { super(); this.Build(); } private Build(): void { this.Body.classList.add("body"); this.Title.classList.add("title"); this.Message.classList.add("message"); this.ButtonContainer.classList.add("button-container"); this.Body.append( this.Title, this.Message, this.ButtonContainer ); this.append(this.Body); this.addEventListener("keydown", this.OnKeyDown.bind(this)); } public Show(message: string, title: string, icon: string, buttonTypes: string | string[]): Promise<string> { if (!Array.isArray(buttonTypes)) buttonTypes = [buttonTypes]; this.classList.add(icon); this.OldActiveElement = document.activeElement as HTMLElement; this.Title.textContent = title; this.Message.textContent = message; let promise = new Promise<string>(resolve => { for (let type of buttonTypes) { let button = document.createElement("button"); let caption: string; switch (type) { case MessageBoxResult.Ok: caption = "OK"; break; case MessageBoxResult.Cancel: caption = "キャンセル"; break; case MessageBoxResult.Yes: caption = "はい"; break; case MessageBoxResult.No: caption = "いいえ"; break; default: throw new Error("Invalid ButtonType: " + type); } button.classList.add(type, "flat"); button.textContent = caption; button.addEventListener("click", e => { this.Close(); resolve(type); }); this.ButtonContainer.append(button); switch (type) { case MessageBoxResult.Ok: case MessageBoxResult.Yes: this.OkButton = button; break; case MessageBoxResult.Cancel: case MessageBoxResult.No: this.CancelButton = button; break; } } }); document.body.insertAdjacentElement("beforeend", this); this.SetDefaultFocus(); return promise; } private SetDefaultFocus(): void { if (this.CancelButton !== null) this.CancelButton?.focus(); else if (this.OkButton !== null) this.OkButton?.focus(); else this.focus(); } private Close(): void { this.remove(); this.OldActiveElement?.focus(); document.body.classList.remove("message-box-shown"); } private OnKeyDown(e: KeyboardEvent): void { if (e.key === KeyValue.Esc) { e.stopPropagation(); e.preventDefault(); if (this.CancelButton !== null) { this.CancelButton.focus(); this.CancelButton.click(); } else if (this.CancelButton === null && this.OkButton !== null) { this.OkButton.focus(); this.OkButton.click(); } } } } customElements.define("message-box", MessageBoxElement);
以下は装飾用のCSSになる。 なお別途Font Awesome Free 5.15.4を使用しているので、これだけをコピーしてもアイコンが表示されないので注意されたい。
SCSShtml body:has(> message-box) { overflow: hidden; } message-box { display: flex; justify-content: center; align-items: center; flex-direction: column; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.9); z-index: 100000; } message-box > .body { display: grid; grid-template-rows: auto 1fr auto; max-height: 90%; max-width: 600px; } message-box > .body > .title { color: #FFFFFF; font-size: 1.3rem; font-weight: 500; text-align: center; margin-bottom: 0.5rem; } message-box > .body > .title::before { margin-right: 0.5rem; } message-box > .body > .message { background-color: rgba(255, 255, 255, 0.95); overflow: auto; padding: 1rem; white-space: pre-wrap; } message-box > .body > .button-container { display: flex; justify-content: center; align-items: center; flex-direction: row; gap: 1rem; margin-top: 1rem; } message-box > .body > .button-container button.flat { border: none; background-color: #0854c7; border-radius: 5px; color: #FFFFFF; line-height: 2rem; padding: 0 1rem; cursor: pointer; } message-box > .body > .button-container button.flat:hover { background-color: #d82b00; } message-box.information > .body > .title::before { font-family: "Font Awesome 5 Free"; font-weight: 900; content: "\f05a"; } message-box.exclamation > .body > .title::before { font-family: "Font Awesome 5 Free"; font-weight: 900; content: "\f06a"; } message-box.question > .body > .title::before { font-family: "Font Awesome 5 Free"; font-weight: 900; content: "\f059"; }
最大の課題は完全なモーダルではないということだ。
半透明のレイヤーで背後の要素に触れなくなっているように見えるが、実際はTABキーによってフォーカスを移動すれば背後の要素に干渉することができる。 キー入力を徹底的に制御してそれすらさせないことも可能かもしれないが、そこまでする価値はないように思う。
あるいはHTMLの<dialog>
タグを使えばもしかしたらうまく制御できるのかもしれない。
こちらはまだ研究途上なので、もしうまくいくようならいずれ改良していきたい。
まだまだ改良の余地があると感じつつも、比較的使いやすい形にまとまったと思う。
await/async
やPromise
が実装されてからというもの、JavaScriptの可能性がかなり広がった。
また時間があればバージョンアップしていきたいものだ。