Skip to main content

nightshade_api/web/base/
overlay.rs

1use leptos::html;
2use leptos::portal::Portal;
3use leptos::prelude::*;
4use wasm_bindgen::{JsCast, JsValue};
5use web_sys::{HtmlDivElement, HtmlElement, KeyboardEvent};
6
7/// Renders `children` into a `<Portal>` so they escape the normal DOM flow and
8/// stack above the rest of the app.
9#[component]
10pub fn Overlay(children: ChildrenFn) -> impl IntoView {
11    let children = StoredValue::new(children);
12    view! { <Portal>{move || children.with_value(|render| render())}</Portal> }
13}
14
15/// A full-screen backdrop behind overlay content. Clicking it runs the optional
16/// `on_dismiss` callback.
17#[component]
18pub fn Scrim(
19    #[prop(optional)] on_dismiss: Option<Callback<()>>,
20    children: Children,
21) -> impl IntoView {
22    let handle = move |_event: web_sys::MouseEvent| {
23        if let Some(callback) = on_dismiss {
24            callback.run(());
25        }
26    };
27    view! {
28        <div class="nightshade-scrim" on:click=handle>
29            {children()}
30        </div>
31    }
32}
33
34const FOCUSABLE_SELECTOR: &str = "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])";
35
36/// A portalled dialog shown while `open` is true. It traps Tab focus within the
37/// dialog, closes on Escape or backdrop click, and restores focus to the
38/// previously focused element when dismissed.
39#[component]
40pub fn Modal(open: RwSignal<bool>, children: ChildrenFn) -> impl IntoView {
41    let dialog_ref = NodeRef::<html::Div>::new();
42    let previously_focused = StoredValue::new_local(None::<HtmlElement>);
43    let children = StoredValue::new(children);
44
45    Effect::new(move |_| {
46        if open.get() {
47            previously_focused.set_value(active_element());
48            if let Some(dialog) = dialog_ref.get() {
49                let _ = dialog.focus();
50            }
51        } else if let Some(element) = previously_focused.get_value() {
52            let _ = element.focus();
53            previously_focused.set_value(None);
54        }
55    });
56
57    on_cleanup(move || {
58        if let Some(element) = previously_focused.get_value() {
59            let _ = element.focus();
60        }
61    });
62
63    let on_keydown = move |event: KeyboardEvent| match event.key().as_str() {
64        "Escape" => {
65            event.prevent_default();
66            open.set(false);
67        }
68        "Tab" => {
69            if let Some(dialog) = dialog_ref.get() {
70                trap_tab(&dialog, &event);
71            }
72        }
73        _ => {}
74    };
75
76    view! {
77        <Show when=move || open.get() fallback=|| ()>
78            <Overlay>
79                <div class="nightshade-scrim" on:click=move |_| open.set(false)>
80                    <div
81                        node_ref=dialog_ref
82                        class="nightshade-modal"
83                        role="dialog"
84                        aria-modal="true"
85                        tabindex="-1"
86                        on:click=|event| event.stop_propagation()
87                        on:keydown=on_keydown
88                    >
89                        {children.with_value(|render| render())}
90                    </div>
91                </div>
92            </Overlay>
93        </Show>
94    }
95}
96
97fn active_element() -> Option<HtmlElement> {
98    web_sys::window()?
99        .document()?
100        .active_element()
101        .and_then(|element| element.dyn_into::<HtmlElement>().ok())
102}
103
104fn trap_tab(dialog: &HtmlDivElement, event: &KeyboardEvent) {
105    let Ok(list) = dialog.query_selector_all(FOCUSABLE_SELECTOR) else {
106        return;
107    };
108    let focusable = (0..list.length())
109        .filter_map(|index| list.item(index))
110        .filter_map(|node| node.dyn_into::<HtmlElement>().ok())
111        .collect::<Vec<_>>();
112    let (Some(first), Some(last)) = (focusable.first(), focusable.last()) else {
113        return;
114    };
115    let active = active_element();
116    if event.shift_key() {
117        if active
118            .as_ref()
119            .is_some_and(|element| same_element(element, first))
120        {
121            event.prevent_default();
122            let _ = last.focus();
123        }
124    } else if active
125        .as_ref()
126        .is_some_and(|element| same_element(element, last))
127    {
128        event.prevent_default();
129        let _ = first.focus();
130    }
131}
132
133fn same_element(left: &HtmlElement, right: &HtmlElement) -> bool {
134    let left: &JsValue = left.as_ref();
135    let right: &JsValue = right.as_ref();
136    left == right
137}