操作をブロックするモーダルダイアログにする

ネイティブのダイアログ・HTML の dialog 要素・子ウィンドウの 3 つを、止まる操作の範囲と手間で比べて選ぶ。HTML のダイアログの間も動く × やメニューを Rust で止める方法も示す。

ダイアログ・通知 対象: Tauri 2.x 更新日: 読了目安: 約9分 dlg-012
目次
  1. 前提条件
  2. 3 つの方法の違い
  3. 選び方
  4. 1. フロントエンドから実装する (TypeScript)
  5. どれも JS の処理は止めない
  6. 同じ形で待てるようにそろえる
  7. 2. バックエンドから実装する (Rust)
  8. Rust から出すネイティブのダイアログ
  9. HTML のダイアログの間は × とメニューを止める
  10. 動作確認
  11. よくあるエラーと対処法
  12. OS ごとの違いと注意点
  13. 関連レシピ

閉じるまで他の操作を受け付けないダイアログを「モーダル」と呼びます。Tauri でモーダルにする方法は、OS 標準のダイアログ、HTML の <dialog> 要素、親子関係を付けた子ウィンドウの 3 つで、止められる範囲・見た目の自由さ・手間がそれぞれ違います。このレシピでは 3 つを比べて選び方を示し、作り方は個別のレシピに任せます。HTML のダイアログでは止まらない × やメニューを Rust で止める方法も説明します。

前提条件

ネイティブのダイアログには dialog プラグインを使います。

npm run tauri add dialog

ask() や message() の権限は dialog:allow-message で、core:default には含まれません。HTML の <dialog> と 2 章の自作のコマンドには、追加の権限は要りません。子ウィンドウに要る権限(ウィンドウの作成や setEnabled など)は 親ウィンドウに対してモーダル表示する を見てください。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "dialog:allow-message"
  ]
}

3 つの方法の違い

ネイティブのダイアログHTML の <dialog>子ウィンドウ
作り方dlg-001dlg-011win-018
止まる範囲呼び出し元のウィンドウページの中だけsetEnabled(false) にしたウィンドウ
置けるもの文章とボタン(3 つまで)入力欄・画像など自由自由(別のページ)
大きさOS が決めるウィンドウの中に収まる範囲自由
結果の受け取りawait の戻り値Promise を自分で作る閉じる前にイベントで親へ送る
手間少ない中くらい多い

選び方

  • ボタンで答えさせるだけ(OK、はい / いいえ、3 択): ネイティブのダイアログにします。1 行で出せて、呼び出し元のウィンドウごと止まります(「はい・いいえ」の確認ダイアログを出す)。
  • 入力欄やアプリの見た目が要る: HTML の <dialog> にします(HTML/CSS で自作のダイアログを作る)。× やメニューも止めたいなら 2 章を足します。
  • ウィンドウより大きい、または独立したページとして作りたい: 子ウィンドウにします。親を止めて、閉じたら戻す後始末まで自分で書きます。

1. フロントエンドから実装する (TypeScript)

どれも JS の処理は止めない

3 つとも止めるのはユーザーの操作で、JS の処理ではありません。await で閉じるまで待つだけで、その間もタイマーや Rust からのイベントの処理は動きます。閉じた後にやることは、必ず await の後に書きます。dialog プラグインを入れると window.alert() はネイティブのダイアログに置き換わりますが、閉じるのを待たずに戻ります(メッセージダイアログ・警告ダイアログを表示する)。

同じ形で待てるようにそろえる

ネイティブは await ask() で答えが返ります。HTML の <dialog> は close イベントを Promise で包むと同じ形で待てます。子ウィンドウも、win-018 の openModal() が閉じたときに解決する Promise を返します。

import { ask } from '@tauri-apps/plugin-dialog';

// ネイティブ: 呼び出し元のウィンドウごと止まり、答えが boolean で返る
export function confirmNative(text: string): Promise<boolean> {
  return ask(text, { title: '確認', kind: 'warning' });
}

// HTML の dialog: ページの中だけを止め、閉じたときの returnValue を返す
export function showHtmlModal(dialog: HTMLDialogElement): Promise<string> {
  dialog.returnValue = ''; // Esc で閉じたときに前回の値が残らないように
  return new Promise((resolve) => {
    dialog.addEventListener('close', () => resolve(dialog.returnValue), { once: true });
    dialog.showModal();
  });
}

// どちらも閉じるまで await で待てる
const ok = await confirmNative('一覧を読み込み直しますか?');
const settings = document.querySelector<HTMLDialogElement>('#settings-dialog');
if (ok && settings) {
  const result = await showHtmlModal(settings);
  console.log('設定ダイアログ:', result || '(キャンセル)');
}

2. バックエンドから実装する (Rust)

Rust から出すネイティブのダイアログ

JS から出したダイアログは呼び出し元のウィンドウに自動で結び付きますが、Rust の .dialog().message() は .parent(&window) を付けないとどのウィンドウにも結び付かず、ウィンドウの後ろに隠れたり、ウィンドウを操作できたりすることがあります。書き方は dlg-001 の 2 章にあります。

HTML のダイアログの間は × とメニューを止める

<dialog> はページの外を止められないので、開いている間だけ Rust に知らせ、Rust の側で × とメニューを無視させます。× は CloseRequested で prevent_close() し、メニューは on_menu_event で何もせずに戻ります。トレイのメニューも同じ on_menu_event に届くので、一緒に止まります。

開いている数はウィンドウごとに数え、ページが読み込み直されたらそのウィンドウの数を消します。消さないと、開発中の再読み込みでダイアログが消えても数が残り、× で閉じられなくなります。JS の onCloseRequested() でも × は止められますが(ウィンドウを閉じる前に確認ダイアログを出す)、core:window:allow-destroy の権限が要り、メニューは別に止める必要があります。

use std::collections::HashMap;
use std::sync::Mutex;
use tauri::webview::PageLoadEvent;
use tauri::{Manager, WindowEvent};

/// ウィンドウごとに、開いている HTML のモーダルの数を数える
#[derive(Default)]
struct OpenModals(Mutex<HashMap<String, usize>>);

impl OpenModals {
    fn count(&self, label: &str) -> usize {
        self.0.lock().unwrap().get(label).copied().unwrap_or(0)
    }
    fn any(&self) -> bool {
        self.0.lock().unwrap().values().any(|n| *n > 0)
    }
}

#[tauri::command]
fn modal_opened(window: tauri::WebviewWindow, modals: tauri::State<'_, OpenModals>) {
    *modals.0.lock().unwrap().entry(window.label().to_string()).or_insert(0) += 1;
}

#[tauri::command]
fn modal_closed(window: tauri::WebviewWindow, modals: tauri::State<'_, OpenModals>) {
    if let Some(n) = modals.0.lock().unwrap().get_mut(window.label()) {
        *n = n.saturating_sub(1);
    }
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_dialog::init())
        .manage(OpenModals::default())
        .on_page_load(|webview, payload| {
            // 読み込み直したページにダイアログは残っていないので、数を消す
            if matches!(payload.event(), PageLoadEvent::Started) {
                webview.state::<OpenModals>().0.lock().unwrap().remove(webview.label());
            }
        })
        .on_window_event(|window, event| {
            if let WindowEvent::CloseRequested { api, .. } = event {
                if window.state::<OpenModals>().count(window.label()) > 0 {
                    api.prevent_close(); // ダイアログを開いている間は × で閉じない
                }
            }
        })
        .on_menu_event(|app, event| {
            if app.state::<OpenModals>().any() {
                return; // モーダルの間は、メニューもトレイのメニューも無視する
            }
            let id: &str = event.id().as_ref();
            println!("menu: {id}");
        })
        .invoke_handler(tauri::generate_handler![modal_opened, modal_closed])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

JS 側は、開く前と閉じた後にコマンドを呼びます。閉じた後の呼び出しは finally に書き、途中で例外が起きても必ず数を戻します。

import { invoke } from '@tauri-apps/api/core';

// HTML のモーダルを開いている間だけ、Rust に × とメニューを止めてもらう
export async function showGuardedModal(dialog: HTMLDialogElement): Promise<string> {
  await invoke('modal_opened');
  try {
    dialog.returnValue = '';
    dialog.showModal();
    return await new Promise<string>((resolve) => {
      dialog.addEventListener('close', () => resolve(dialog.returnValue), { once: true });
    });
  } finally {
    await invoke('modal_closed');
  }
}

動作確認

npm run tauri dev で起動し、次の関数をボタンから呼びます。

import { ask } from '@tauri-apps/plugin-dialog';

// モーダルの間も JS が動いていることを確かめる
export async function checkWhileModal() {
  const timer = window.setInterval(() => console.log('tick', new Date().toLocaleTimeString()), 1000);
  const ok = await ask('開いている間もコンソールの tick は止まりません。', { title: '確認' });
  window.clearInterval(timer);
  console.log('answer:', ok);
}

ダイアログが出ている間、元のウィンドウをクリックしても反応しませんが、コンソールには 1 秒ごとに tick が出続けます。次に showHtmlModal() で HTML のダイアログを開くと、ページはクリックできなくなる一方、タイトルバーの × で閉じられ、メニューも選べます。showGuardedModal() に替えると、× を押しても閉じず、メニューを選んでも何も起きません。ダイアログを閉じると元に戻ります。

よくあるエラーと対処法

  • 「dialog.message not allowed. Permissions associated with this command: dialog:allow-ask, dialog:allow-confirm, dialog:allow-message, dialog:default」: ネイティブのダイアログの権限がありません。ask() や confirm() でもこの名前で出ます。リリースビルドでは「Command plugin:dialog|message not allowed by ACL」です。
  • HTML のダイアログを開いているのに × で閉じられる、メニューが動く: <dialog> が止めるのはページの中だけです。2 章の方法で Rust の側でも止めます。
  • × を押しても閉じなくなった: modal_closed が呼ばれていません。finally で呼んでいるか、on_page_load で数を消しているかを確かめます。
  • Rust から出したダイアログの裏でウィンドウを操作できる、または後ろに隠れる: .parent(&window) がありません。
  • 閉じる前に次の処理が動く: await を付け忘れているか、window.alert() を使っています。

OS ごとの違いと注意点

  • ネイティブのダイアログ: 見た目やボタンの並びは OS ごとに違い、アプリからは変えられません。Rust の .parent() はデスクトップ専用です。
  • 子ウィンドウ: parent の意味は OS ごとに違い、Windows ではオーナー、macOS では子ウィンドウ、Linux では一時的なウィンドウになります。作成時の parent はデスクトップ専用なので、iOS / Android ではネイティブか HTML のダイアログを使います。
  • 2 章で止まらないもの: グローバルショートカットや、JS で作ったメニューの action は止まりません。同じ状態を見て、モーダルの間は処理しないようにします。
  • 止めすぎない: 答えが要らない知らせまでモーダルにすると、作業が何度も中断されます。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する