Skip to main content

yggdrasil/components/ui/
surfaces.rs

1//! Positioned popovers, modal shells and the danger confirm dialog.
2use leptos::ev;
3use leptos::prelude::*;
4
5/// Popover 遮罩与面板的层级(z-40 遮罩 < z-50 面板),与 Tooltip/lightbox 同 z-50。
6const POPOVER_OVERLAY_CLASS: &str = "fixed inset-0 z-40";
7
8/// 受控式通用 Popover(浮层)组件。
9///
10/// 与 [`Tooltip`](super::Tooltip) 对称——但 Tooltip 是纯 CSS hover、无状态;Popover 是点击触发、
11/// 受控开关,用于承载确认框、轻量表单等交互内容。
12///
13/// ## 定位策略
14///
15/// 父容器常有 `overflow-hidden`(如表格面板),`position:absolute` 子节点
16/// 会被裁掉,故面板用 **`position:fixed`**,以触发点击的**视口坐标**(`MouseEvent::
17/// client_x()/client_y()`)作为锚点(参照 `theme.rs` 圆形展开动画的坐标用法)。无需
18/// `getBoundingClientRect`/`node_ref`。
19///
20/// - `placement: "top"`(默认):面板底边贴点击点上方(`bottom: 100vh - y + gap`)。
21/// - `placement: "bottom"`:面板顶边贴点击点下方(`top: y + gap`)。
22/// - 水平由 `align` 决定:`"center"`(默认)面板中心对齐点击点(`left: x` +
23///   `-translate-x-1/2`);`"start"` 面板左缘贴点向右延伸(`left: x`);
24///   `"end"` 面板右缘贴点向左延伸(`right: 100vw - x`)。
25///
26/// 触发器贴近视口边缘时(如表格最右列的按钮),居中的宽面板会越出视口——
27/// 此时用 `align` 让面板朝视口内侧延伸(与 Tooltip 的 align 同款约定,
28/// 见 issue #14)。`end` 用 `right` 锚定右缘,与面板内容宽度无关,天然不越右缘。
29///
30/// ## 关闭路径(三条)
31///
32/// 1. 点遮罩(透明,仅作点击兜底)→ `on_close`。
33/// 2. Escape 键 → `on_close`(`open` 期间注册 window 级 keydown 监听,关闭/卸载时移除)。
34/// 3. 面板内确认/取消按钮调用 `on_close`。
35///
36/// ## Props
37///
38/// - `open`:受控开关(值、信号或闭包);`false` 时组件不渲染任何内容(SSR 安全)。
39/// - `anchor_x` / `anchor_y`:触发点击的视口坐标(值、信号或闭包)。
40/// - `placement`:`"top"`(默认)/ `"bottom"`。
41/// - `align`:`"center"`(默认)/ `"start"` / `"end"`(水平对齐,见上)。
42/// - `children`:面板内容(确认框等)。
43/// - `on_close`:任一关闭路径触发。
44#[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    // Escape 关闭:组件 open 时注册全局 keydown 监听,关闭/卸载时移除。
55    // 监听句柄是 Send + Sync 的普通值,可直接放进 StoredValue。
56    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    // 面板定位:placement 决定垂直方向;align 决定水平。end 用 right 锚定
79    // (右缘 = 点击点),与面板宽度解耦。
80    // 居中由 .popover-panel[data-align="center"] 的 translate 属性完成,
81    // 与入场动画的 transform(只缩放)互不干扰。
82    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            // top:面板在点击点上方——用 bottom 锚定 viewport 底,差值即视口高度 - y + 间隙。
103            // 视口高度用 100vh,纯 CSS 无需 JS 读取 scrollHeight。
104            format!("bottom: calc(100vh - {y}px + 8px); {horizontal}")
105        }
106    };
107
108    move || {
109        open.get().then(|| {
110            view! {
111                // 透明遮罩:拦截外部点击(点遮罩即关)。z-40 < 面板 z-50。
112                <div class=POPOVER_OVERLAY_CLASS on:click=move |_| on_close.run(())></div>
113                // 面板:fixed 定位逃出 overflow-hidden 容器。
114                <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
127/// 弹窗关闭/元素退出动画统一时长(ms),与 base.css 的 .modal-overlay / .modal-panel 关闭过渡对应;
128/// 调用方需要在动画播放完毕后再真正卸载/摘除元素时复用本常量。
129pub const EXIT_ANIM_MS: u32 = 200;
130
131/// 弹窗遮罩基础类(不含内边距——不同弹窗尺寸的响应式内边距不同,经 `overlay_padding` 传入)。
132const MODAL_OVERLAY_BASE: &str = "dialog-overlay modal-overlay animate-modal-overlay-enter";
133/// 弹窗面板基础类(不含宽度——不同弹窗尺寸不同,经 `panel_class` 传入追加)。
134const MODAL_PANEL_BASE: &str = "dialog-panel modal-panel animate-modal-panel-enter";
135
136/// 焦点锁定:Tab / Shift+Tab 在最上层打开的弹窗内循环,焦点不会跑到页面背后。
137#[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/// 弹窗打开时把焦点交给标题为 `title` 的最上层面板(仅 wasm)。
194#[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/// 通用弹窗外壳:遮罩 + 面板容器 + 开合动画状态机(`opened` / `closing` / 退出计时)。
218///
219/// 收敛 `AssetUploadModal` / `AssetPickerModal` 曾各自照抄的一份遮罩 + 面板 + 开合动画
220/// 状态机,顺带补齐两者曾意外分叉的 `role="dialog"` / `aria-modal` / `aria-label`
221/// (此前只有 `AssetPickerModal` 有)。调用方仍各自负责标题栏、内容区等具体内容
222/// (经 `children` 传入),以及自身域相关的开关副作用(如重置搜索词/选中集)。
223///
224/// ## 关闭路径与同帧动画
225///
226/// 本组件自身处理遮罩点击关闭。调用方若在 `children` 内还有别的关闭入口
227/// (标题栏 × 按钮、选中即关闭等),必须用与本组件相同的写法**同步**先置
228/// `closing.set(true)` 再置 `visible.set(false)`——同一帧内让存活元素换上
229/// `.is-closing` 类,过渡动画才能从可见态播放(只翻 `visible`、等下一帧的
230/// `Effect` 兜底补置 `closing` 为时已晚,元素已被提前卸载,动画会变成
231/// 瞬间消失而非淡出)。因此 `visible` / `closing` 是与调用方共享的 `RwSignal`,
232/// 而非本组件内部私有状态;`opened`(mount 守卫)才是本组件私有的。
233///
234/// 面板上的点击不会冒泡:面板内的站内链接因此收不到文档级路由协调器,会退化为整页跳转,
235/// 弹窗内容里需要导航时请用 `crate::navigation::push`。
236///
237/// ## Props
238///
239/// - `visible` / `closing`:与调用方共享的显隐 / 关闭动画信号。
240/// - `title`:面板的 `aria-label`(不渲染可见标题——各调用方的标题栏自行渲染)。
241/// - `role`:面板角色,默认 `dialog`;危险确认传 `alertdialog`。
242/// - `overlay_padding`:遮罩内边距(响应式内边距因弹窗尺寸而异,默认 `"p-6"`)。
243/// - `panel_class`:在共享面板底座上追加的宽度等布局类(默认 `"w-full max-w-lg"`)。
244/// - `children`:面板内容(含调用方自己的标题栏、× 按钮等);显示期间渲染,每次打开重新创建。
245#[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    // 公共弹窗在打开时接管焦点,Escape 关闭后还给触发器;监听随显隐同步清理。
256    let listener = StoredValue::new(None::<WindowListenerHandle>);
257    // 打开前获得焦点的元素(JS 句柄,非 Send),关闭后归还焦点。
258    #[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    // 是否曾开过弹窗:mount 时 visible=false 也会跑一次 Effect,不加此守卫会
306    // 在页面加载后 EXIT_ANIM_MS 内渲染一层透明遮罩(opacity:0 仍拦截点击)吞掉首次点击。
307    let opened = RwSignal::new(false);
308
309    // visible 翻转驱动关闭动画:关闭入口(× / 遮罩 / Esc)会同步先置 closing 再翻
310    // visible(见本组件 doc);这里只负责重开复位与 EXIT_ANIM_MS 后的复位卸载。
311    // 闭包只订阅 visible,closing/opened 全用 get_untracked 防自触发循环。
312    Effect::new(move |_| {
313        if visible.get() {
314            opened.set(true);
315            closing.set(false);
316        } else if opened.get_untracked() {
317            // 非交互路径的 visible 翻转(理论上不存在)兜底补置 closing。
318            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    // 用 Memo 去重:visible → closing 的过渡期内 `shown` 恒为 true,子树原地保留,
329    // `.is-closing` 类才能让 CSS 过渡从可见态播放。
330    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                // 遮罩:点击关闭。
345                <div
346                    class=overlay_class
347                    on:click=move |_| {
348                        closing.set(true);
349                        visible.set(false);
350                    }
351                >
352                    // 面板:阻止点击穿透到遮罩。
353                    <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/// 危险操作确认对话框(`alertdialog`):左侧 danger 图标、标题与说明,底栏「取消」与
372/// danger 主按钮。点确认先关闭再回调 `on_confirm`,执行与结果提示由调用方负责。
373/// `title` 同时作为面板的无障碍名称,同一时刻打开的对话框标题不能重复。
374///
375/// `description` 可传字符串、信号或闭包(说明里带当前对象名时随状态更新)。
376#[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}