yggdrasil/components/ui/
surfaces.rs1use leptos::ev;
3use leptos::prelude::*;
4
5const POPOVER_OVERLAY_CLASS: &str = "fixed inset-0 z-40";
7
8#[component]
45pub fn Popover(
46 #[prop(into)] open: Signal<bool>,
47 #[prop(into)] anchor_x: Signal<i32>,
48 #[prop(into)] anchor_y: Signal<i32>,
49 children: ChildrenFn,
50 #[prop(into)] on_close: Callback<()>,
51 #[prop(default = "top".to_string(), into)] placement: String,
52 #[prop(default = "center".to_string(), into)] align: String,
53) -> impl IntoView {
54 let listener = StoredValue::new(None::<WindowListenerHandle>);
57 Effect::new(move |_| {
58 let is_open = open.get();
59 if let Some(previous) = listener.try_update_value(|l| l.take()).flatten() {
60 previous.remove();
61 }
62 if !is_open {
63 return;
64 }
65 let handle = window_event_listener(ev::keydown, move |event| {
66 if event.key() == "Escape" {
67 on_close.run(());
68 }
69 });
70 listener.set_value(Some(handle));
71 });
72 on_cleanup(move || {
73 if let Some(Some(handle)) = listener.try_update_value(|l| l.take()) {
74 handle.remove();
75 }
76 });
77
78 let align_attr = match align.as_str() {
83 "start" => "start",
84 "end" => "end",
85 _ => "center",
86 };
87 let placement_attr = if placement == "bottom" {
88 "bottom"
89 } else {
90 "top"
91 };
92 let style = move || {
93 let x = anchor_x.get();
94 let y = anchor_y.get();
95 let horizontal = match align_attr {
96 "end" => format!("right: calc(100vw - {x}px);"),
97 _ => format!("left: {x}px;"),
98 };
99 if placement_attr == "bottom" {
100 format!("top: {}px; {horizontal}", y + 8)
101 } else {
102 format!("bottom: calc(100vh - {y}px + 8px); {horizontal}")
105 }
106 };
107
108 move || {
109 open.get().then(|| {
110 view! {
111 <div class=POPOVER_OVERLAY_CLASS on:click=move |_| on_close.run(())></div>
113 <div
115 class="popover-panel"
116 data-placement=placement_attr
117 data-align=align_attr
118 style=style
119 >
120 {children()}
121 </div>
122 }
123 })
124 }
125}
126
127pub const EXIT_ANIM_MS: u32 = 200;
130
131const MODAL_OVERLAY_BASE: &str = "dialog-overlay modal-overlay animate-modal-overlay-enter";
133const MODAL_PANEL_BASE: &str = "dialog-panel modal-panel animate-modal-panel-enter";
135
136#[cfg(target_arch = "wasm32")]
138pub(crate) fn trap_tab(event: &web_sys::KeyboardEvent, title: &str) {
139 use wasm_bindgen::JsCast;
140 const FOCUSABLE: &str = "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])";
141 let Some(document) = web_sys::window().and_then(|w| w.document()) else {
142 return;
143 };
144 let Ok(panels) =
145 document.query_selector_all("[data-ygg-modal-panel][data-ygg-modal-open='true']")
146 else {
147 return;
148 };
149 let panel = (0..panels.length())
150 .rev()
151 .filter_map(|i| panels.item(i))
152 .filter_map(|node| node.dyn_into::<web_sys::Element>().ok())
153 .find(|panel| panel.get_attribute("aria-label").as_deref() == Some(title));
154 let Some(panel) = panel else {
155 return;
156 };
157 let Ok(nodes) = panel.query_selector_all(FOCUSABLE) else {
158 return;
159 };
160 let focusable: Vec<web_sys::HtmlElement> = (0..nodes.length())
161 .filter_map(|i| nodes.item(i))
162 .filter_map(|node| node.dyn_into::<web_sys::HtmlElement>().ok())
163 .filter(|el| el.offset_parent().is_some())
164 .collect();
165 let (Some(first), Some(last)) = (focusable.first(), focusable.last()) else {
166 event.prevent_default();
167 return;
168 };
169 let active = document.active_element();
170 let inside = active
171 .as_ref()
172 .is_some_and(|el| panel.contains(Some(el.unchecked_ref())));
173 let at = |target: &web_sys::HtmlElement| {
174 active
175 .as_ref()
176 .is_some_and(|el| el.is_same_node(Some(target.unchecked_ref())))
177 };
178 if event.shift_key()
179 && (!inside
180 || at(first)
181 || active
182 .as_ref()
183 .is_some_and(|el| el.is_same_node(Some(panel.unchecked_ref()))))
184 {
185 event.prevent_default();
186 let _ = last.focus();
187 } else if !event.shift_key() && (!inside || at(last)) {
188 event.prevent_default();
189 let _ = first.focus();
190 }
191}
192
193#[cfg(target_arch = "wasm32")]
195fn focus_modal_panel(document: &web_sys::Document, title: &str) {
196 use wasm_bindgen::JsCast;
197 let Ok(panels) =
198 document.query_selector_all("[data-ygg-modal-panel][data-ygg-modal-open='true']")
199 else {
200 return;
201 };
202 for index in (0..panels.length()).rev() {
203 if let Some(panel) = panels
204 .item(index)
205 .and_then(|node| node.dyn_into::<web_sys::Element>().ok())
206 {
207 if panel.get_attribute("aria-label").as_deref() == Some(title) {
208 if let Some(element) = panel.dyn_ref::<web_sys::HtmlElement>() {
209 let _ = element.focus();
210 }
211 break;
212 }
213 }
214 }
215}
216
217#[component]
246pub fn ModalShell(
247 visible: RwSignal<bool>,
248 closing: RwSignal<bool>,
249 #[prop(into)] title: String,
250 #[prop(default = "dialog".to_string(), into)] role: String,
251 #[prop(default = "p-6".to_string(), into)] overlay_padding: String,
252 #[prop(default = "w-full max-w-lg".to_string(), into)] panel_class: String,
253 children: ChildrenFn,
254) -> impl IntoView {
255 let listener = StoredValue::new(None::<WindowListenerHandle>);
257 #[cfg(target_arch = "wasm32")]
259 let previous_focus: StoredValue<Option<web_sys::Element>, LocalStorage> =
260 StoredValue::new_local(None);
261 {
262 let title = title.clone();
263 Effect::new(move |_| {
264 let is_visible = visible.get();
265 if let Some(old) = listener.try_update_value(|l| l.take()).flatten() {
266 old.remove();
267 }
268 if is_visible {
269 #[cfg(target_arch = "wasm32")]
270 if let Some(document) = web_sys::window().and_then(|w| w.document()) {
271 previous_focus.set_value(document.active_element());
272 focus_modal_panel(&document, &title);
273 }
274 let title = title.clone();
275 let handle = window_event_listener(ev::keydown, move |event| {
276 let key = event.key();
277 if key == "Escape" {
278 closing.set(true);
279 visible.set(false);
280 } else if key == "Tab" {
281 #[cfg(target_arch = "wasm32")]
282 trap_tab(&event, &title);
283 #[cfg(not(target_arch = "wasm32"))]
284 let _ = &title;
285 }
286 });
287 listener.set_value(Some(handle));
288 } else {
289 #[cfg(target_arch = "wasm32")]
290 if let Some(element) = previous_focus.try_update_value(|p| p.take()).flatten() {
291 use wasm_bindgen::JsCast;
292 if let Some(element) = element.dyn_ref::<web_sys::HtmlElement>() {
293 let _ = element.focus();
294 }
295 }
296 }
297 });
298 }
299 on_cleanup(move || {
300 if let Some(Some(old)) = listener.try_update_value(|l| l.take()) {
301 old.remove();
302 }
303 });
304
305 let opened = RwSignal::new(false);
308
309 Effect::new(move |_| {
313 if visible.get() {
314 opened.set(true);
315 closing.set(false);
316 } else if opened.get_untracked() {
317 if !closing.get_untracked() {
319 closing.set(true);
320 }
321 leptos::task::spawn_local(async move {
322 crate::utils::time::sleep_ms(EXIT_ANIM_MS).await;
323 let _ = closing.try_set(false);
324 });
325 }
326 });
327
328 let shown = Memo::new(move |_| visible.get() || closing.get());
331 move || {
332 shown.get().then(|| {
333 let overlay_class = {
334 let overlay_padding = overlay_padding.clone();
335 move || {
336 let is_closing = closing.get();
337 format!(
338 "{MODAL_OVERLAY_BASE} {overlay_padding}{}",
339 if is_closing { " is-closing" } else { "" }
340 )
341 }
342 };
343 view! {
344 <div
346 class=overlay_class
347 on:click=move |_| {
348 closing.set(true);
349 visible.set(false);
350 }
351 >
352 <div
354 class=format!("{MODAL_PANEL_BASE} {panel_class}")
355 role=role.clone()
356 tabindex="-1"
357 data-ygg-modal-panel="true"
358 data-ygg-modal-open=move || if visible.get() { "true" } else { "false" }
359 aria-modal="true"
360 aria-label=title.clone()
361 on:click=move |evt| evt.stop_propagation()
362 >
363 {children()}
364 </div>
365 </div>
366 }
367 })
368 }
369}
370
371#[component]
377pub fn ConfirmDialog(
378 visible: RwSignal<bool>,
379 #[prop(into)] title: String,
380 #[prop(into)] description: Signal<String>,
381 #[prop(into)] confirm_label: String,
382 #[prop(default = super::IconName::Trash)] icon: super::IconName,
383 #[prop(into)] on_confirm: Callback<()>,
384) -> impl IntoView {
385 use super::{button_class, ButtonSize, ButtonVariant, Icon};
386
387 let closing = RwSignal::new(false);
388 let close = move || {
389 closing.set(true);
390 visible.set(false);
391 };
392 let modal_title = title.clone();
393 view! {
394 <ModalShell
395 visible=visible
396 closing=closing
397 title=modal_title
398 role="alertdialog"
399 overlay_padding="p-4"
400 panel_class="confirm-dialog"
401 >
402 <div class="confirm-dialog-body">
403 <span class="confirm-dialog-icon" aria-hidden="true">
404 <Icon name=icon size=18/>
405 </span>
406 <div>
407 <h2>{title.clone()}</h2>
408 <p>{move || description.get()}</p>
409 </div>
410 </div>
411 <div class="confirm-dialog-actions">
412 <button
413 class=button_class(ButtonVariant::Ghost, ButtonSize::Md)
414 type="button"
415 on:click=move |_| close()
416 >
417 "取消"
418 </button>
419 <button
420 class=button_class(ButtonVariant::Danger, ButtonSize::Md)
421 type="button"
422 on:click=move |_| {
423 close();
424 on_confirm.run(());
425 }
426 >
427 {confirm_label.clone()}
428 </button>
429 </div>
430 </ModalShell>
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn closed_overlays_render_nothing() {
440 let html = Owner::new().with(|| {
441 let visible = RwSignal::new(false);
442 let closing = RwSignal::new(false);
443 view! {
444 <ModalShell visible closing title="标题">"内容"</ModalShell>
445 <Popover open=false anchor_x=0 anchor_y=0 on_close=|_| {}>"浮层"</Popover>
446 }
447 .to_html()
448 });
449 assert!(!html.contains("modal-overlay"), "{html}");
450 assert!(!html.contains("popover-panel"), "{html}");
451 }
452
453 #[test]
454 fn open_modal_renders_dialog_semantics_and_popover_style() {
455 let html = Owner::new().with(|| {
456 let visible = RwSignal::new(true);
457 let closing = RwSignal::new(false);
458 view! {
459 <ModalShell visible closing title="标题" role="alertdialog">"内容"</ModalShell>
460 <Popover open=true anchor_x=10 anchor_y=20 placement="bottom" align="end" on_close=|_| {}>
461 "浮层"
462 </Popover>
463 }
464 .to_html()
465 });
466 assert!(html.contains("role=\"alertdialog\""), "{html}");
467 assert!(html.contains("aria-label=\"标题\""), "{html}");
468 assert!(html.contains("data-ygg-modal-open=\"true\""), "{html}");
469 assert!(
470 html.contains("top: 28px; right: calc(100vw - 10px);"),
471 "{html}"
472 );
473 }
474}