modal.rs
⎇
Raw
1//! The one modal shell, a native `<dialog>`.
2//!
3//! Opened with `showModal()`, so the browser supplies the backdrop
4//! (`::backdrop`), the focus trap, inertness for the rest of the page, the
5//! top layer, and Escape-to-close. None of that is hand-rolled here.
6
7use leptos::prelude::*;
8use wasm_bindgen::JsCast;
9use web_sys::MouseEvent;
10
11#[component]
12pub fn Modal(
13 /// Classes for the card, appended to `modal` (e.g. `"card picker"`).
14 #[prop(into)]
15 class: String,
16 /// Runs when the dialog asks to close: Escape, or a backdrop click.
17 /// The owner clears its own state, which unmounts the dialog.
18 on_close: Callback<()>,
19 children: Children,
20) -> impl IntoView {
21 let dialog: NodeRef<leptos::html::Dialog> = NodeRef::new();
22 Effect::new(move |_| {
23 if let Some(el) = dialog.get() {
24 // `show_modal`, never `show`: only the modal form gets the top
25 // layer, the backdrop and the focus trap.
26 let _ = el.show_modal();
27 }
28 });
29 view! {
30 <dialog
31 node_ref=dialog
32 class="modal-host"
33 // Fires once the browser has closed the dialog, which Escape
34 // does on its own. Tell the owner so its state clears too.
35 // Unmounting an open dialog does not fire this, so the close
36 // path the owner drives cannot loop back here.
37 on:close=move |_| on_close.run(())
38 // A click on the backdrop targets the <dialog> itself; a click
39 // anywhere inside targets that element instead. The card below
40 // carries the padding, so the dialog box is never a click
41 // target except on the backdrop.
42 on:click=move |ev: MouseEvent| {
43 let Some(el) = dialog.get() else { return };
44 let on_backdrop = ev
45 .target()
46 .and_then(|t| t.dyn_into::<web_sys::Node>().ok())
47 .is_some_and(|t| el.is_same_node(Some(&t)));
48 if on_backdrop {
49 on_close.run(());
50 }
51 }
52 >
53 <div class=format!("modal {class}")>{children()}</div>
54 </dialog>
55 }
56}
57