UI: modals are native <dialog>, opened with showModal()
The overlay, the Escape key and the click-outside check were hand-rolled
across nine call sites. A <dialog> in the top layer does all of it, and
adds two things the old markup never had: a focus trap, and inertness for
the page behind.
One <Modal> component replaces the repeated shell:
<div class="modal-overlay" on:click=close>
<div class="modal card" on:click=stop_propagation>
DialogView's window keydown listener is gone. The browser's own close
request handles Escape and fires `close`, which tells the owner to clear
its state. Unmounting an open dialog does not fire `close`, so the close
path the owner drives cannot loop back.
The shell stays two elements on purpose. <dialog> carries no padding and
no background, and the .modal card inside carries both, so a backdrop
click is exactly `target == dialog`. Putting the padding on the dialog
would close it when the padding is clicked. A coordinate test instead of
a target test would misfire on keyboard-triggered clicks, which report
clientX and clientY of 0.
CSS: .modal-overlay becomes .modal-host, a reset that strips the UA box,
plus ::backdrop for the dim. The z-index goes; the top layer orders these
now. .modal and its variants are untouched.
Escape now closes UserForm, ShareDialog and the delete-all confirmation.
None of them were wired into DialogView's listener, so Escape did nothing
on them before. A backdrop click already closed them, so this is the same
contract, not a new way to lose a half-filled form.
Verified in Chromium against the release build. All seven variants report
:modal, so showModal ran and they are in the top layer. Backdrop is
rgba(0, 0, 0, 0.45) as before, centring is unchanged, and the widths hold
at 440, 640 for the picker, 480 for the share dialog and 520 for the user
form. Focus lands inside, on the autofocus input. Escape closes, a
backdrop click closes, a click on the card's padding does not. The picker
opened from inside the user form stacks correctly: both are :modal,
Escape closes only the topmost, and the form stays up.
Also: the browser.rs comment described a bubble listener that closed the
dialog. There is no such listener now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>Mweb/Cargo.toml
@@ -31,6 +31,7 @@ web-sys = { version = "0.3", features = [
"Headers",
"HtmlAnchorElement",
"HtmlCollection",
"HtmlDialogElement",
"HtmlDivElement",
"HtmlElement",
"HtmlInputElement",
@@ -40,6 +41,7 @@ web-sys = { version = "0.3", features = [
"MessageEvent",
"MouseEvent",
"Navigator",
"Node",
"Request",
"RequestInit",
"RequestMode",
Mweb/app.css
@@ -877,14 +877,24 @@ button:disabled {
/* ---- modals ---- */
.modal-overlay {
position: fixed;
inset: 0;
/* The shell is a native <dialog> opened with showModal(): the browser puts
it in the top layer and paints ::backdrop, so there is no overlay element
to position and no z-index to keep in order. The reset below strips the
UA's own box so the .modal card inside is the only thing that shows. */
.modal-host {
padding: 0;
border: none;
background: none;
/* The UA caps an open dialog's size; the card sets its own. */
max-width: none;
max-height: none;
/* Centre it in the inset-0 box, as the old flex overlay did. */
margin: auto;
overflow: visible;
}
.modal-host::backdrop {
background: rgb(0 0 0 / 45%);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
Mweb/src/components/mod.rs
@@ -1,3 +1,4 @@
pub mod icon;
pub mod logo;
pub mod modal;
pub mod toast;
Aweb/src/components/modal.rs
@@ -0,0 +1,56 @@
//! The one modal shell, a native `<dialog>`.
//!
//! Opened with `showModal()`, so the browser supplies the backdrop
//! (`::backdrop`), the focus trap, inertness for the rest of the page, the
//! top layer, and Escape-to-close. None of that is hand-rolled here.
use leptos::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::MouseEvent;
#[component]
pub fn Modal(
/// Classes for the card, appended to `modal` (e.g. `"card picker"`).
#[prop(into)]
class: String,
/// Runs when the dialog asks to close: Escape, or a backdrop click.
/// The owner clears its own state, which unmounts the dialog.
on_close: Callback<()>,
children: Children,
) -> impl IntoView {
let dialog: NodeRef<leptos::html::Dialog> = NodeRef::new();
Effect::new(move |_| {
if let Some(el) = dialog.get() {
// `show_modal`, never `show`: only the modal form gets the top
// layer, the backdrop and the focus trap.
let _ = el.show_modal();
}
});
view! {
<dialog
node_ref=dialog
class="modal-host"
// Fires once the browser has closed the dialog, which Escape
// does on its own. Tell the owner so its state clears too.
// Unmounting an open dialog does not fire this, so the close
// path the owner drives cannot loop back here.
on:close=move |_| on_close.run(())
// A click on the backdrop targets the <dialog> itself; a click
// anywhere inside targets that element instead. The card below
// carries the padding, so the dialog box is never a click
// target except on the backdrop.
on:click=move |ev: MouseEvent| {
let Some(el) = dialog.get() else { return };
let on_backdrop = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::Node>().ok())
.is_some_and(|t| el.is_same_node(Some(&t)));
if on_backdrop {
on_close.run(());
}
}
>
<div class=format!("modal {class}")>{children()}</div>
</dialog>
}
}
Mweb/src/views/admin.rs
@@ -9,6 +9,7 @@ use wasm_bindgen_futures::spawn_local;
use crate::api::{self, AdminUser, Me, Mode};
use crate::components::icon::Icon;
use crate::components::modal::Modal;
use crate::components::toast::{ToastMsg, show, show_error};
use crate::icons::IconName;
use crate::views::dialogs::{Dialog, admin_root};
@@ -665,11 +666,7 @@ fn UserForm(
};
view! {
<div class="modal-overlay" on:click=move |_| close.run(())>
<div
class="modal user-form"
on:click=move |ev: web_sys::MouseEvent| ev.stop_propagation()
>
<Modal class="user-form" on_close=close>
<h2 class="modal-title">{move || {
if is_new {
i18n::t(i18n::k::NEW_USER).to_string()
@@ -853,7 +850,6 @@ fn UserForm(
}}
</button>
</div>
</div>
</div>
</Modal>
}
}
Mweb/src/views/browser.rs
@@ -133,10 +133,10 @@ pub fn Browser(
});
// Escape clears the selection — but only when nothing that also reacts
// to Escape is open: the context menu (it has its own Escape handler),
// a modal dialog (Esc closes it; the selection must survive, same as
// for the file view), or a full-page file view (the shell closes it on
// the same keypress). Registered in the capture phase so it sees the
// dialog / file view *before* their bubble listeners close them; and it
// a modal dialog (the <dialog> closes itself; the selection must
// survive, same as for the file view), or a full-page file view (the
// shell closes it on the same keypress). Registered in the capture
// phase so it sees the dialog / file view *before* they close; and it
// blurs the focused row, because the keypress would otherwise leave the
// row's :focus-visible outline behind.
crate::util::owned_window_listener_capture(
Mweb/src/views/dialogs.rs
@@ -5,10 +5,10 @@ use std::sync::Arc;
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use web_sys::MouseEvent;
use crate::api::{self, Entry, RootInfo};
use crate::components::icon::Icon;
use crate::components::modal::Modal;
use crate::components::toast::{ToastMsg, show, show_error};
use crate::i18n;
use crate::icons::IconName;
@@ -130,19 +130,7 @@ pub fn DialogView(
dialog: ReadSignal<Option<Dialog>>,
set_dialog: WriteSignal<Option<Dialog>>,
) -> impl IntoView {
// Escape closes the active dialog.
{
let d = dialog;
let set_d = set_dialog;
crate::util::owned_window_listener(
leptos::ev::keydown,
move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" && d.get().is_some() {
set_d.set(None);
}
},
);
}
// Escape is the <dialog>'s own; see `Modal`.
view! {
{move || {
let Some(d) = dialog.get() else {
@@ -372,8 +360,7 @@ fn PromptOpDialog(
});
};
view! {
<div class="modal-overlay" on:click=move |_| on_close.run(())>
<div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
<Modal class="card" on_close=on_close>
<h2 class="modal-title">{title}</h2>
<label class="modal-label" for=input_id>{label}</label>
<input
@@ -409,8 +396,7 @@ fn PromptOpDialog(
{submit}
</button>
</div>
</div>
</div>
</Modal>
}
}
@@ -428,8 +414,7 @@ fn ConfirmDialog(
on_close: Callback<()>,
) -> impl IntoView {
view! {
<div class="modal-overlay" on:click=move |_| on_close.run(())>
<div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
<Modal class="card" on_close=on_close>
<h2 class="modal-title">{title}</h2>
<p class="modal-message">{message}</p>
<div class="modal-actions">
@@ -452,8 +437,7 @@ fn ConfirmDialog(
{submit}
</button>
</div>
</div>
</div>
</Modal>
}
}
@@ -471,8 +455,7 @@ fn ConflictDialog(
let shown: Vec<String> = files.iter().take(20).cloned().collect();
let extra = files.len().saturating_sub(shown.len());
view! {
<div class="modal-overlay" on:click=move |_| on_close.run(())>
<div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
<Modal class="card" on_close=on_close>
<h2 class="modal-title">{title}</h2>
<p class="modal-message">
{i18n::tr(i18n::k::OVERWRITE_QUESTION)}
@@ -506,8 +489,7 @@ fn ConflictDialog(
{i18n::tr(i18n::k::OVERWRITE)}
</button>
</div>
</div>
</div>
</Modal>
}
}
@@ -600,8 +582,7 @@ fn PickerDialog(
};
view! {
<div class="modal-overlay" on:click=move |_| on_close.run(())>
<div class="modal card picker" on:click=move |e: MouseEvent| e.stop_propagation()>
<Modal class="card picker" on_close=on_close>
<h2 class="modal-title">{title}</h2>
<div class="picker-body">
@@ -719,8 +700,7 @@ fn PickerDialog(
{confirm}
</button>
</div>
</div>
</div>
</Modal>
}
}
@@ -744,8 +724,7 @@ fn DownloadFormatDialog(
("tar.zst", i18n::t(i18n::k::FMT_TARZST), ".tar.zst"),
];
view! {
<div class="modal-overlay" on:click=move |_| on_close.run(())>
<div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
<Modal class="card" on_close=on_close>
<h2 class="modal-title">
{move || i18n::t_fmt(i18n::k::DOWNLOAD_TITLE, &name_title)}
</h2>
@@ -776,8 +755,7 @@ fn DownloadFormatDialog(
{i18n::tr(i18n::k::CANCEL)}
</button>
</div>
</div>
</div>
</Modal>
}
}
@@ -807,8 +785,7 @@ fn InfoDialog(
format!("{root_name}/{parent}")
};
view! {
<div class="modal-overlay" on:click=move |_| on_close.run(())>
<div class="modal card" on:click=move |e: MouseEvent| e.stop_propagation()>
<Modal class="card" on_close=on_close>
<h2 class="modal-title">{i18n::tr(i18n::k::FILE_INFO)}</h2>
<dl class="info-list">
<dt>{i18n::tr(i18n::k::NAME)}</dt>
@@ -827,7 +804,6 @@ fn InfoDialog(
{i18n::tr(i18n::k::CLOSE)}
</button>
</div>
</div>
</div>
</Modal>
}
}