nightshade_api/web/
menus.rs1use leptos::html;
4use leptos::portal::Portal;
5use leptos::prelude::*;
6use wasm_bindgen::JsCast;
7use web_sys::{HtmlElement, KeyboardEvent};
8
9fn active_element() -> Option<HtmlElement> {
10 web_sys::window()?
11 .document()?
12 .active_element()
13 .and_then(|element| element.dyn_into::<HtmlElement>().ok())
14}
15
16fn same_element(left: &HtmlElement, right: &HtmlElement) -> bool {
17 let left: &wasm_bindgen::JsValue = left.as_ref();
18 let right: &wasm_bindgen::JsValue = right.as_ref();
19 left == right
20}
21
22fn items_within(container: &HtmlElement) -> Vec<HtmlElement> {
23 let Ok(list) =
24 container.query_selector_all(".nightshade-menu-item:not([aria-disabled='true'])")
25 else {
26 return Vec::new();
27 };
28 (0..list.length())
29 .filter_map(|index| list.item(index))
30 .filter_map(|node| node.dyn_into::<HtmlElement>().ok())
31 .collect()
32}
33
34fn roving_focus(container: &HtmlElement, event: &KeyboardEvent) -> bool {
35 let items = items_within(container);
36 if items.is_empty() {
37 return false;
38 }
39 let current = active_element()
40 .and_then(|active| items.iter().position(|item| same_element(item, &active)));
41 match event.key().as_str() {
42 "ArrowDown" => {
43 event.prevent_default();
44 let next = current.map(|index| (index + 1) % items.len()).unwrap_or(0);
45 let _ = items[next].focus();
46 true
47 }
48 "ArrowUp" => {
49 event.prevent_default();
50 let next = current
51 .map(|index| (index + items.len() - 1) % items.len())
52 .unwrap_or(items.len() - 1);
53 let _ = items[next].focus();
54 true
55 }
56 "Home" => {
57 event.prevent_default();
58 let _ = items[0].focus();
59 true
60 }
61 "End" => {
62 event.prevent_default();
63 let _ = items[items.len() - 1].focus();
64 true
65 }
66 _ => false,
67 }
68}
69
70#[component]
72pub fn Menu(#[prop(into)] label: String, children: ChildrenFn) -> impl IntoView {
73 let open = RwSignal::new(false);
74 let root = NodeRef::<html::Div>::new();
75 let list_ref = NodeRef::<html::Div>::new();
76 let children = StoredValue::new(children);
77
78 let handle = window_event_listener(leptos::ev::mousedown, move |event| {
79 if !open.get_untracked() {
80 return;
81 }
82 let inside = root.get_untracked().is_some_and(|node| {
83 event
84 .target()
85 .and_then(|target| target.dyn_into::<web_sys::Node>().ok())
86 .is_some_and(|target| node.contains(Some(&target)))
87 });
88 if !inside {
89 open.set(false);
90 }
91 });
92 on_cleanup(move || handle.remove());
93
94 Effect::new(move |_| {
95 if open.get()
96 && let Some(list) = list_ref.get()
97 && let Some(first) = items_within(&list).into_iter().next()
98 {
99 let _ = first.focus();
100 }
101 });
102
103 let on_keydown = move |event: KeyboardEvent| {
104 if event.key() == "Escape" {
105 event.prevent_default();
106 open.set(false);
107 return;
108 }
109 if let Some(list) = list_ref.get() {
110 roving_focus(&list, &event);
111 }
112 };
113
114 view! {
115 <div class="nightshade-menu" node_ref=root>
116 <button
117 class="nightshade-button"
118 aria-haspopup="menu"
119 aria-expanded=move || open.get().to_string()
120 on:click=move |_| open.update(|value| *value = !*value)
121 >
122 {label}
123 </button>
124 <Show when=move || open.get() fallback=|| ()>
125 <div
126 class="nightshade-menu-list"
127 role="menu"
128 node_ref=list_ref
129 on:keydown=on_keydown
130 on:click=move |_| open.set(false)
131 >
132 {children.with_value(|render| render())}
133 </div>
134 </Show>
135 </div>
136 }
137}
138
139#[component]
144pub fn MenuItem(
145 #[prop(into)] label: String,
146 #[prop(into, optional)] shortcut: String,
147 #[prop(optional)] checked: Option<Signal<bool>>,
148 #[prop(into, optional)] disabled: Signal<bool>,
149 #[prop(optional)] on_select: Option<Callback<()>>,
150) -> impl IntoView {
151 let role = if checked.is_some() {
152 "menuitemcheckbox"
153 } else {
154 "menuitem"
155 };
156 let handle = move |_event: web_sys::MouseEvent| {
157 if disabled.get_untracked() {
158 return;
159 }
160 if let Some(callback) = on_select {
161 callback.run(());
162 }
163 };
164 let on_keydown = move |event: KeyboardEvent| {
165 if matches!(event.key().as_str(), "Enter" | " ")
166 && !disabled.get_untracked()
167 && let Some(callback) = on_select
168 {
169 event.prevent_default();
170 callback.run(());
171 }
172 };
173 view! {
174 <div
175 class="nightshade-menu-item"
176 role=role
177 tabindex="-1"
178 aria-disabled=move || disabled.get().to_string()
179 aria-checked=checked.map(|checked| move || checked.get().to_string())
180 on:click=handle
181 on:keydown=on_keydown
182 >
183 {checked
184 .map(|checked| {
185 view! {
186 <span class="nightshade-menu-check" class:on=move || checked.get()>
187 "\u{2713}"
188 </span>
189 }
190 })}
191 <span class="nightshade-menu-label">{label}</span>
192 <span class="shortcut">{shortcut}</span>
193 </div>
194 }
195}
196
197#[component]
199pub fn MenuSeparator() -> impl IntoView {
200 view! { <div class="nightshade-menu-separator" role="separator"></div> }
201}
202
203#[component]
205pub fn Submenu(#[prop(into)] label: String, children: ChildrenFn) -> impl IntoView {
206 let open = RwSignal::new(false);
207 let children = StoredValue::new(children);
208 view! {
209 <div
210 class="nightshade-submenu"
211 on:mouseenter=move |_| open.set(true)
212 on:mouseleave=move |_| open.set(false)
213 >
214 <div
215 class="nightshade-menu-item"
216 role="menuitem"
217 tabindex="-1"
218 aria-haspopup="menu"
219 aria-expanded=move || open.get().to_string()
220 on:click=move |event| {
221 event.stop_propagation();
222 open.update(|value| *value = !*value);
223 }
224 >
225 <span class="nightshade-menu-label">{label}</span>
226 <span class="nightshade-menu-arrow">"\u{203a}"</span>
227 </div>
228 <Show when=move || open.get() fallback=|| ()>
229 <div class="nightshade-menu-list submenu" role="menu">
230 {children.with_value(|render| render())}
231 </div>
232 </Show>
233 </div>
234 }
235}
236
237#[component]
242pub fn ContextMenu(
243 open: RwSignal<bool>,
244 #[prop(into)] x: Signal<i32>,
245 #[prop(into)] y: Signal<i32>,
246 children: ChildrenFn,
247) -> impl IntoView {
248 let list_ref = NodeRef::<html::Div>::new();
249 let children = StoredValue::new(children);
250
251 Effect::new(move |_| {
252 if open.get()
253 && let Some(list) = list_ref.get()
254 && let Some(first) = items_within(&list).into_iter().next()
255 {
256 let _ = first.focus();
257 }
258 });
259
260 let on_keydown = move |event: KeyboardEvent| {
261 if event.key() == "Escape" {
262 event.prevent_default();
263 open.set(false);
264 return;
265 }
266 if let Some(list) = list_ref.get() {
267 roving_focus(&list, &event);
268 }
269 };
270
271 view! {
272 <Show when=move || open.get() fallback=|| ()>
273 <Portal>
274 <div class="nightshade-context-scrim" on:mousedown=move |_| open.set(false)>
275 <div
276 class="nightshade-context-menu"
277 role="menu"
278 node_ref=list_ref
279 style=move || format!("left:{}px;top:{}px", x.get(), y.get())
280 on:mousedown=|event| event.stop_propagation()
281 on:keydown=on_keydown
282 on:click=move |_| open.set(false)
283 >
284 {children.with_value(|render| render())}
285 </div>
286 </div>
287 </Portal>
288 </Show>
289 }
290}
291
292#[component]
294pub fn TabBar(tabs: Vec<(String, String)>, active: RwSignal<String>) -> impl IntoView {
295 let ids: Vec<String> = tabs.iter().map(|(id, _)| id.clone()).collect();
296 let on_keydown = move |event: KeyboardEvent| {
297 let current = ids.iter().position(|id| id == &active.get_untracked());
298 let Some(current) = current else {
299 return;
300 };
301 match event.key().as_str() {
302 "ArrowRight" => {
303 event.prevent_default();
304 active.set(ids[(current + 1) % ids.len()].clone());
305 }
306 "ArrowLeft" => {
307 event.prevent_default();
308 active.set(ids[(current + ids.len() - 1) % ids.len()].clone());
309 }
310 _ => {}
311 }
312 };
313 view! {
314 <div class="nightshade-tab-bar" role="tablist" on:keydown=on_keydown>
315 {tabs
316 .into_iter()
317 .map(|(id, label)| {
318 let id_for_class = id.clone();
319 let id_for_aria = id.clone();
320 let id_for_tabindex = id.clone();
321 view! {
322 <button
323 role="tab"
324 aria-selected=move || (active.get() == id_for_aria).to_string()
325 tabindex=move || {
326 if active.get() == id_for_tabindex { "0" } else { "-1" }
327 }
328 class=move || {
329 if active.get() == id_for_class {
330 "nightshade-tab active"
331 } else {
332 "nightshade-tab"
333 }
334 }
335 on:click=move |_| active.set(id.clone())
336 >
337 {label}
338 </button>
339 }
340 })
341 .collect_view()}
342 </div>
343 }
344}