nightshade_api/web/base/
overlay.rs1use leptos::html;
2use leptos::portal::Portal;
3use leptos::prelude::*;
4use wasm_bindgen::{JsCast, JsValue};
5use web_sys::{HtmlDivElement, HtmlElement, KeyboardEvent};
6
7#[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#[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#[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}