yggdrasil/components/ui/
sheet.rs1use leptos::prelude::*;
4
5const SHEET_EXIT_MS: u32 = 200;
7const DRAG_DEAD_ZONE: f64 = 4.0;
9const DRAG_CLOSE_DISTANCE: f64 = 80.0;
11
12const SIDE_CLOSE_DISTANCE: f64 = 80.0;
14
15fn 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#[derive(Clone, Copy)]
27pub struct SheetClose(Callback<()>);
28
29impl SheetClose {
30 pub fn close(&self) {
31 self.0.run(());
32 }
33}
34
35pub fn use_sheet_close() -> SheetClose {
37 expect_context::<SheetClose>()
38}
39
40fn 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 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#[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 #[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
196fn 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#[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#[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#[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 #[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); assert_eq!(side_drag_offset((100.0, 100.0), (140.0, 100.0)), None); assert_eq!(side_drag_offset((100.0, 100.0), (80.0, 160.0)), None); 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); assert_eq!(drag_offset(100.0, 90.0), 0.0); assert_eq!(drag_offset(100.0, 150.0), 46.0); }
362}