Skip to main content

yggdrasil/components/ui/
sheet.rs

1//! 抽屉:手机上的导航与目录面板。底部抽屉样式见 shell.css 的 `.sheet`,
2//! 左侧抽屉(后台导航)见 admin.css 的 `.side-sheet`。
3use leptos::prelude::*;
4
5/// 关闭动画时长(ms),与 shell.css 的 `sheet-out` 一致。
6const SHEET_EXIT_MS: u32 = 200;
7/// 拖拽死区:手指移动超过 4px 才开始跟手,避免轻点误拖。
8const DRAG_DEAD_ZONE: f64 = 4.0;
9/// 向下拖过该距离松手即关闭。
10const DRAG_CLOSE_DISTANCE: f64 = 80.0;
11
12/// 左侧抽屉向左拖过该距离松手即关闭。
13const SIDE_CLOSE_DISTANCE: f64 = 80.0;
14
15/// 拖拽位移:死区内为 0,向上拖不跟手(抽屉已贴底)。
16fn drag_offset(start_y: f64, current_y: f64) -> f64 {
17    let dy = current_y - start_y;
18    if dy <= DRAG_DEAD_ZONE {
19        0.0
20    } else {
21        dy - DRAG_DEAD_ZONE
22    }
23}
24
25/// 抽屉内容用来关闭抽屉(带退出动画、焦点还给触发按钮)。
26#[derive(Clone, Copy)]
27pub struct SheetClose(Callback<()>);
28
29impl SheetClose {
30    pub fn close(&self) {
31        self.0.run(());
32    }
33}
34
35/// 在 [`BottomSheet`] / [`SideSheet`] 的子组件里取得关闭句柄。
36pub fn use_sheet_close() -> SheetClose {
37    expect_context::<SheetClose>()
38}
39
40/// 两种抽屉共用的开合生命周期,返回 `(closing, close)`:
41///
42/// - `open` 翻转时锁定 / 解除页面滚动,打开时按 `focus_targets` 顺序聚焦第一个匹配项;
43/// - 卸载时解除滚动锁(例如抽屉打开时跳到别的页面);
44/// - `close`:播放退出动画([`SHEET_EXIT_MS`])后真正关闭,焦点立即还给 `trigger_id`;
45///   `on_close_start` 在动画开始前调用(清掉进行中的拖拽),`on_closed` 在关闭后调用(复位位移);
46/// - 通过 context 提供 [`SheetClose`] 给抽屉内容。
47fn use_sheet_lifecycle(
48    open: RwSignal<bool>,
49    trigger_id: String,
50    focus_targets: Vec<String>,
51    on_close_start: impl Fn() + Send + Sync + 'static,
52    on_closed: impl Fn() + Send + Sync + 'static,
53) -> (RwSignal<bool>, Callback<()>) {
54    let closing = RwSignal::new(false);
55    let was_open = RwSignal::new(false);
56
57    Effect::new(move |_| {
58        let is_open = open.get();
59        if is_open == was_open.get_untracked() {
60            return;
61        }
62        was_open.set(is_open);
63        lock_page_scroll(is_open);
64        if is_open {
65            focus_selectors(&focus_targets);
66        }
67    });
68    // 卸载时(例如抽屉打开时跳到别的页面)解除滚动锁。
69    on_cleanup(move || lock_page_scroll(false));
70
71    let on_closed = std::sync::Arc::new(on_closed);
72    let close = Callback::new(move |()| {
73        if !open.get_untracked() || closing.get_untracked() {
74            return;
75        }
76        closing.set(true);
77        on_close_start();
78        focus_selectors(&[format!("#{trigger_id}")]);
79        let on_closed = on_closed.clone();
80        leptos::task::spawn_local(async move {
81            crate::utils::time::sleep_ms(SHEET_EXIT_MS).await;
82            let _ = open.try_set(false);
83            let _ = closing.try_set(false);
84            on_closed();
85        });
86    });
87    provide_context(SheetClose(close));
88    (closing, close)
89}
90
91/// 底部抽屉。
92///
93/// 从底部以 heavy 弹簧升起;点遮罩、按 Esc、点按或向下拖过 80px 把手关闭。打开时聚焦
94/// 标有 `aria-current="page"` 的项,没有时聚焦第一个链接或按钮;关闭后焦点回到
95/// `trigger_id` 指向的按钮。打开期间锁住页面滚动。子组件用 [`use_sheet_close`] 关闭。
96///
97/// Props:
98/// - `open`:开关(读写,关闭动画结束后由组件置回 `false`)
99/// - `id` / `trigger_id` / `label`:面板 id、触发按钮 id 与无障碍名称
100/// - `children`:抽屉内容(打开时才渲染,每次打开重新创建)
101#[component]
102pub fn BottomSheet(
103    open: RwSignal<bool>,
104    #[prop(into)] id: String,
105    #[prop(into)] trigger_id: String,
106    #[prop(into)] label: String,
107    children: ChildrenFn,
108) -> impl IntoView {
109    let drag_start = RwSignal::new(None::<f64>);
110    let drag_dy = RwSignal::new(0.0_f64);
111
112    let (closing, close) = use_sheet_lifecycle(
113        open,
114        trigger_id,
115        vec![
116            format!("#{id} [aria-current='page']"),
117            format!("#{id} .sheet-body :is(a[href], button)"),
118        ],
119        move || drag_start.set(None),
120        move || {
121            let _ = drag_dy.try_set(0.0);
122        },
123    );
124
125    move || {
126        open.get().then(|| {
127            let id = id.clone();
128            let handle_id = format!("{id}-handle");
129            view! {
130                <div
131                    class="sheet-scrim"
132                    data-closing=move || closing.get().then_some("true")
133                    on:click=move |_| close.run(())
134                ></div>
135                <div
136                    id=id
137                    class="sheet"
138                    role="dialog"
139                    aria-modal="true"
140                    aria-label=label.clone()
141                    data-closing=move || closing.get().then_some("true")
142                    data-dragging=move || drag_start.get().is_some().then_some("true")
143                    style=move || {
144                        let dy = drag_dy.get();
145                        if dy > 0.0 { format!("transform: translateY({dy}px);") } else { String::new() }
146                    }
147                    on:keydown=move |e| {
148                        if e.key() == "Escape" {
149                            e.prevent_default();
150                            close.run(());
151                        }
152                    }
153                >
154                    <button
155                        id=handle_id.clone()
156                        class="sheet-handle"
157                        type="button"
158                        aria-label="向下拖动或点按关闭"
159                        on:pointerdown=move |e| {
160                            drag_start.set(Some(e.client_y() as f64));
161                            // 捕获指针:手指移出把手后仍能收到移动与抬起事件。
162                            #[cfg(target_arch = "wasm32")]
163                            if let Some(el) = web_sys::window()
164                                .and_then(|w| w.document())
165                                .and_then(|d| d.get_element_by_id(&handle_id))
166                            {
167                                let _ = el.set_pointer_capture(e.pointer_id());
168                            }
169                        }
170                        on:pointermove=move |e| {
171                            if let Some(start) = drag_start.get_untracked() {
172                                drag_dy.set(drag_offset(start, e.client_y() as f64));
173                            }
174                        }
175                        on:pointerup=move |_| {
176                            let moved = drag_dy.get_untracked();
177                            drag_start.set(None);
178                            if moved > DRAG_CLOSE_DISTANCE || moved == 0.0 {
179                                close.run(());
180                            } else {
181                                drag_dy.set(0.0);
182                            }
183                        }
184                        on:pointercancel=move |_| {
185                            drag_start.set(None);
186                            drag_dy.set(0.0);
187                        }
188                    ></button>
189                    <div class="sheet-body">{children()}</div>
190                </div>
191            }
192        })
193    }
194}
195
196/// 左侧抽屉的拖拽位移(≤ 0):只认向左、且水平位移大于竖直位移的拖动(竖直方向留给
197/// 抽屉内滚动),并减去 4px 死区。返回 `None` 表示这次按下还不算拖拽。
198fn side_drag_offset(start: (f64, f64), current: (f64, f64)) -> Option<f64> {
199    let dx = current.0 - start.0;
200    let dy = current.1 - start.1;
201    if dx >= -DRAG_DEAD_ZONE || dx.abs() <= dy.abs() {
202        return None;
203    }
204    Some(dx + DRAG_DEAD_ZONE)
205}
206
207/// 打开期间锁住页面滚动(与底部抽屉共用 `html.sheet-open`)。
208#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
209fn lock_page_scroll(locked: bool) {
210    #[cfg(target_arch = "wasm32")]
211    if let Some(html) = web_sys::window()
212        .and_then(|w| w.document())
213        .and_then(|d| d.document_element())
214    {
215        let _ = html.class_list().toggle_with_force("sheet-open", locked);
216    }
217}
218
219/// 聚焦第一个能匹配到的选择器(仅 wasm)。
220#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
221fn focus_selectors(selectors: &[String]) {
222    #[cfg(target_arch = "wasm32")]
223    {
224        use wasm_bindgen::JsCast;
225        let Some(document) = web_sys::window().and_then(|w| w.document()) else {
226            return;
227        };
228        if let Some(el) = selectors
229            .iter()
230            .find_map(|selector| document.query_selector(selector).ok().flatten())
231            .and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok())
232        {
233            let _ = el.focus();
234        }
235    }
236}
237
238/// 左侧抽屉。
239///
240/// 从左侧以 heavy 弹簧滑出;点遮罩、按 Esc 或向左拖过 80px 关闭(4px 死区,竖直拖动
241/// 留给内容滚动)。打开时聚焦 `aria-current="page"` 的项,没有时聚焦第一个链接或按钮;
242/// 关闭后焦点回到 `trigger_id`。打开期间锁住页面滚动。子组件用 [`use_sheet_close`] 关闭。
243///
244/// Props 同 [`BottomSheet`]。
245#[component]
246pub fn SideSheet(
247    open: RwSignal<bool>,
248    #[prop(into)] id: String,
249    #[prop(into)] trigger_id: String,
250    #[prop(into)] label: String,
251    children: ChildrenFn,
252) -> impl IntoView {
253    let press = RwSignal::new(None::<(f64, f64)>);
254    let drag_dx = RwSignal::new(None::<f64>);
255
256    let (closing, close) = use_sheet_lifecycle(
257        open,
258        trigger_id,
259        vec![
260            format!("#{id} [aria-current='page']"),
261            format!("#{id} :is(a[href], button)"),
262        ],
263        move || press.set(None),
264        move || {
265            let _ = drag_dx.try_set(None);
266        },
267    );
268
269    move || {
270        open.get().then(|| {
271            let id = id.clone();
272            #[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
273            let panel_id = id.clone();
274            view! {
275                <div
276                    class="sheet-scrim"
277                    data-closing=move || closing.get().then_some("true")
278                    on:click=move |_| close.run(())
279                ></div>
280                <div
281                    id=id
282                    class="side-sheet"
283                    role="dialog"
284                    aria-modal="true"
285                    aria-label=label.clone()
286                    data-closing=move || closing.get().then_some("true")
287                    data-dragging=move || drag_dx.get().is_some().then_some("true")
288                    style=move || {
289                        drag_dx
290                            .get()
291                            .map(|dx| format!("transform: translateX({dx}px);"))
292                            .unwrap_or_default()
293                    }
294                    on:keydown=move |e| {
295                        if e.key() == "Escape" {
296                            e.prevent_default();
297                            close.run(());
298                        }
299                    }
300                    on:pointerdown=move |e| {
301                        press.set(Some((e.client_x() as f64, e.client_y() as f64)));
302                    }
303                    on:pointermove=move |e| {
304                        let Some(start) = press.get_untracked() else {
305                            return;
306                        };
307                        let point = (e.client_x() as f64, e.client_y() as f64);
308                        if let Some(offset) = side_drag_offset(start, point) {
309                            // 开始跟手后捕获指针:手指移出抽屉仍能收到抬起事件。
310                            #[cfg(target_arch = "wasm32")]
311                            if drag_dx.get_untracked().is_none() {
312                                if let Some(el) = web_sys::window()
313                                    .and_then(|w| w.document())
314                                    .and_then(|d| d.get_element_by_id(&panel_id))
315                                {
316                                    let _ = el.set_pointer_capture(e.pointer_id());
317                                }
318                            }
319                            drag_dx.set(Some(offset));
320                        } else if drag_dx.get_untracked().is_some() {
321                            drag_dx.set(Some(0.0));
322                        }
323                    }
324                    on:pointerup=move |_| {
325                        press.set(None);
326                        if drag_dx.get_untracked().is_some_and(|dx| -dx > SIDE_CLOSE_DISTANCE) {
327                            close.run(());
328                        } else {
329                            drag_dx.set(None);
330                        }
331                    }
332                    on:pointercancel=move |_| {
333                        press.set(None);
334                        drag_dx.set(None);
335                    }
336                >
337                    {children()}
338                </div>
339            }
340        })
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::{drag_offset, side_drag_offset};
347
348    #[test]
349    fn side_drag_offset_only_tracks_leftward_horizontal_drags() {
350        assert_eq!(side_drag_offset((100.0, 100.0), (97.0, 100.0)), None); // 死区内
351        assert_eq!(side_drag_offset((100.0, 100.0), (140.0, 100.0)), None); // 向右
352        assert_eq!(side_drag_offset((100.0, 100.0), (80.0, 160.0)), None); // 竖直为主
353        assert_eq!(side_drag_offset((100.0, 100.0), (50.0, 110.0)), Some(-46.0));
354    }
355
356    #[test]
357    fn drag_offset_ignores_small_moves_and_upward_drags() {
358        assert_eq!(drag_offset(100.0, 103.0), 0.0); // 死区内
359        assert_eq!(drag_offset(100.0, 90.0), 0.0); // 向上
360        assert_eq!(drag_offset(100.0, 150.0), 46.0); // 减去 4px 死区
361    }
362}