名前の入力欄を置きたい、アプリの配色に合わせたい、説明の画像を添えたい、といった OS 標準のダイアログ(dlg-001)ではできない表示は、ページの中に HTML の <dialog> 要素で作ります。showModal() で開くと背景が暗くなり、閉じるまでページの他の部分は操作できません。プラグインも Rust も要りませんが、止まるのはページの中だけです。ここでは名前を入力させて結果を Promise で返すダイアログを作り、Rust のメニューから開く方法も示します。
前提条件
プラグインは不要で、権限も core:default のままで動きます。2 章で Rust からの合図を受け取る listen() は core:event:default に含まれ、自分で書いた Rust のコマンドは権限を足さなくても呼べます。OS 標準のダイアログや子ウィンドウとの違い、どれを選ぶかは 操作をブロックするモーダルダイアログにする で比べています。
1. フロントエンドから実装する (TypeScript)
HTML と CSS
フォームには method="dialog" を付けます。付け忘れると、入力欄で Enter を押したときに普通のフォーム送信になり、ページが読み込み直されてアプリの状態が消えます。キャンセルのボタンは type="button" にします。フォームの中の <button> は既定で送信ボタンで、Enter では最初の送信ボタンが押された扱いになるためです。
<button id="rename-button">名前を変更</button>
<dialog id="rename-dialog" class="app-dialog" aria-labelledby="rename-title">
<form method="dialog">
<h2 id="rename-title">名前を変更</h2>
<label for="rename-input">新しい名前</label>
<input id="rename-input" maxlength="100" autocomplete="off" />
<p id="rename-error" class="error" role="alert"></p>
<div class="actions">
<button type="button" data-action="cancel">キャンセル</button>
<button type="submit">変更する</button>
</div>
</form>
</dialog>
<dialog> 自体の padding は 0 にし、余白は中の <form> に付けます。こうしておくと、クリックの対象が <dialog> 自身になるのは枠の外(背景)を押したときだけになり、背景のクリックを簡単に判定できます。ダイアログはウィンドウの外に出せないので、小さいウィンドウでも収まるよう max-height と overflow も指定します。
.app-dialog {
width: min(420px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
overflow: auto;
padding: 0; /* 余白は form に付ける(背景クリックの判定のため) */
border: none;
border-radius: 12px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
}
.app-dialog form {
display: grid;
gap: 8px;
padding: 20px 24px;
}
.app-dialog::backdrop {
background: rgba(0, 0, 0, 0.45);
}
.app-dialog[open] {
animation: dialog-in 0.15s ease-out;
}
.app-dialog .error {
min-height: 1.2em;
margin: 0;
color: #c62828;
}
.app-dialog .actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
@keyframes dialog-in {
from { opacity: 0; transform: translateY(-8px); }
}
結果を Promise で受け取る
開く関数が Promise を返すようにすると、await askName() と書けて OS のダイアログと同じ感覚で使えます。押さえる点は 3 つです。
- 開く前に
returnValueを空にします。Esc で閉じたときはreturnValueが変わらないので、前回の値が返ってしまいます。 - 送信は
submitイベントでpreventDefault()して止め、検証に通ったらclose(値)で閉じます。こうすると、問題をダイアログの中に表示でき、Rust での確認のような時間のかかる検証も待てます。 - リスナーは閉じたときにまとめて外します。開くたびに足すと、2 回目から処理が重複します。
type Validate = (value: string) => string | null | Promise<string | null>;
// 名前を入力させる。キャンセルされたら null を返す
export function askName(current: string, validate?: Validate): Promise<string | null> {
const dialog = document.querySelector<HTMLDialogElement>('#rename-dialog');
const form = dialog?.querySelector('form');
const input = dialog?.querySelector<HTMLInputElement>('#rename-input');
const error = dialog?.querySelector<HTMLElement>('#rename-error');
if (!dialog || !form || !input || !error) return Promise.reject(new Error('rename-dialog が見つかりません'));
if (dialog.open) return Promise.resolve(null); // 開いている間に二重に開かない
const ac = new AbortController(); // 閉じたらリスナーをまとめて外す
const signal = ac.signal;
input.value = current;
error.textContent = '';
dialog.returnValue = ''; // Esc で閉じても前回の値が返らないようにする
form.addEventListener('submit', async (e) => {
e.preventDefault(); // 検証が終わるまで閉じない
const value = input.value.trim();
const problem = value === '' ? '名前を入力してください。' : await validate?.(value);
if (problem) {
error.textContent = problem; // 閉じずに、枠の中で知らせる
input.focus();
return;
}
dialog.close(value); // returnValue に value が入る
}, { signal });
dialog.querySelector('[data-action="cancel"]')?.addEventListener('click', () => dialog.close(), { signal });
// padding が 0 なので、target が dialog 自身なら背景をクリックした
dialog.addEventListener('click', (e) => {
if (e.target === dialog) dialog.close();
}, { signal });
return new Promise((resolve) => {
dialog.addEventListener('close', () => {
ac.abort();
resolve(dialog.returnValue || null);
}, { signal });
dialog.showModal();
input.select();
});
}
// (続き)
const INVALID = /[\\/:*?"<>|]/; // Windows のファイル名に使えない文字
document.querySelector('#rename-button')?.addEventListener('click', async () => {
const name = await askName('memo.txt', (v) => (INVALID.test(v) ? '\\ / : * ? " < > | は使えません。' : null));
console.log(name === null ? 'キャンセル' : `新しい名前: ${name}`);
});
背景のクリックで閉じると入力が消えるので、長いフォームでは背景のクリックの処理を外しておく方が親切です。
2. バックエンドから実装する (Rust)
Rust から HTML のダイアログを直接開く API は無いので、イベントでページに頼みます(Rust からページへの合図の送り方は メニューがクリックされた時の処理を書く と同じです)。例として、ネイティブのメニュー「編集 → 名前を変更...」から開き、入力された名前が使えるかを Rust のコマンドで確かめます。コマンドが Err で返した文字列は、askName() の検証の結果としてダイアログの中に出します。
トレイのメニューや macOS のメニューバーは、ウィンドウが最小化・非表示のままでも選べます。ページの中のダイアログはそのままでは見えないので、合図を送る前にウィンドウを戻して前面に出します。
use tauri::menu::{MenuBuilder, SubmenuBuilder};
use tauri::{Emitter, Manager};
/// 名前が使えるかを確かめる。使えない理由は Err で返し、ダイアログの中に出してもらう
#[tauri::command]
fn check_name(name: String) -> Result<(), String> {
let taken = ["memo.txt", "todo.txt"]; // 実際は保存先のファイル一覧などを調べる
if taken.iter().any(|t| t.eq_ignore_ascii_case(&name)) {
return Err(format!("「{name}」は既にあります。"));
}
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.menu(|app| {
// 入力欄でコピーや貼り付けが効くよう、標準の項目も入れておく
let edit = SubmenuBuilder::new(app, "編集(&E)")
.cut()
.copy()
.paste()
.select_all()
.separator()
.text("edit:rename", "名前を変更...")
.build()?;
MenuBuilder::new(app).item(&edit).build()
})
.on_menu_event(|app, event| {
let id: &str = event.id().as_ref();
if id != "edit:rename" {
return;
}
if let Some(main) = app.get_webview_window("main") {
// 最小化・非表示のままだと、ページの中のダイアログは見えない
let _ = main.unminimize();
let _ = main.show();
let _ = main.set_focus();
let _ = app.emit_to("main", "open-rename-dialog", "memo.txt");
}
})
.invoke_handler(tauri::generate_handler![check_name])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
// (続き)
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
// Rust の確認に通れば null、通らなければ Err の文字列を返す
async function checkOnRust(value: string): Promise<string | null> {
try {
await invoke('check_name', { name: value });
return null;
} catch (e) {
return typeof e === 'string' ? e : '確認できませんでした。';
}
}
// メニューからの合図で開く
await listen<string>('open-rename-dialog', async ({ payload }) => {
const name = await askName(payload, checkOnRust);
if (name !== null) console.log('変更します:', name);
});
動作確認
npm run tauri dev で「名前を変更」ボタンを押すと、背景が暗くなってダイアログが開き、入力欄の文字が選択された状態になります。「a/b」にして Enter を押すと枠の中にエラーが出て、閉じません。正しい名前ならコンソールに「新しい名前: …」と出ます。Esc・キャンセル・背景のクリックでは「キャンセル」です。メニューの「編集 → 名前を変更...」でも同じダイアログが開き、「todo.txt」と入れると Rust の確認で「「todo.txt」は既にあります。」と出ます。
よくあるエラーと対処法
- Enter を押すとアプリが再読み込みされる:
<form>にmethod="dialog"がありません。普通のフォーム送信になり、ページが読み込み直されます。 - Esc で閉じたのに前回の入力が返る: 開く前に
returnValueを空にしていません。 - Enter でキャンセル扱いになる: キャンセルボタンに
type="button"がありません。 - ダイアログの中の余白をクリックしただけで閉じる:
<dialog>自体にpaddingがあります。余白は中の要素に付けます。 - 2 回目から送信の処理が 2 回動く: 開くたびにリスナーを足しています。閉じたときに外します。
- 「Command check_name not found」:
generate_handler!にコマンドを入れ忘れています。
OS ごとの違いと注意点
- 止まるのはページの中だけ:
showModal()の間も、タイトルバーの × や最小化、ネイティブのメニューとそのショートカット、トレイ、他のウィンドウは操作できます。× で入力が失われて困るなら ウィンドウを閉じる前に確認ダイアログを出す で確認し、メニューなども止めるなら dlg-012 の方法を足します。 - ウィンドウの外には出せない: ウィンドウより大きい画面や、並べて見比べたい画面は、親ウィンドウに対してモーダル表示する 子ウィンドウにします。
- WebView の違い: Windows は WebView2、macOS と Linux は OS に入っている WebKit で表示されます。古い macOS や Linux では新しめの CSS が効かないことがあるので、見た目は各 OS で確かめます。
