閉じるまで他の操作を受け付けないダイアログを「モーダル」と呼びます。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-001 | dlg-011 | win-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は止まりません。同じ状態を見て、モーダルの間は処理しないようにします。 - 止めすぎない: 答えが要らない知らせまでモーダルにすると、作業が何度も中断されます。
