euv_ui/component/router/hook/impl.rs
1use super::*;
2
3/// Implementation of router functionality.
4///
5/// Provides methods for managing browser history, overlays, navigation,
6/// and scroll behavior.
7impl Router {
8 /// Watches the route signal and scrolls the `<main>` content container
9 /// back to the top whenever the route changes.
10 ///
11 /// On each route change, queries the document for the first `<main>`
12 /// element and resets its `scrollTop` to zero. The sidebar scroll
13 /// position is preserved natively since the `<nav>` element is never
14 /// destroyed during route transitions.
15 ///
16 /// # Arguments
17 ///
18 /// - `Signal<String>` - The reactive signal holding the current route path.
19 pub fn use_scroll_to_top(route_signal: Signal<String>) {
20 watch!(route_signal, |_: String| {
21 let Some(window_value) = window() else {
22 return;
23 };
24 let Some(document_value) = window_value.document() else {
25 return;
26 };
27 if let Some(main_element) = document_value.query_selector("main").ok().flatten() {
28 let html_element: HtmlElement = main_element.unchecked_into();
29 html_element.set_scroll_top(0);
30 }
31 });
32 }
33
34 /// Subscribes to browser `hashchange` events and updates the given signal.
35 ///
36 /// Registers a global event listener on `window` that reads the current
37 /// route on every hash change and writes it into the provided signal.
38 /// The listener is automatically removed when the hook context is cleared.
39 ///
40 /// Increments `WINDOW_EVENT_DEPTH` before dispatching and decrements it
41 /// after, so that any code that checks re-entrancy can detect that it is
42 /// running within a window event handler context.
43 ///
44 /// Note: `navigate()` always defers `set_hash()` to a microtask, so by the
45 /// time the `hashchange` fires, all caller frames have already unwound and
46 /// there is no risk of recursive Closure invocation. The handler only needs
47 /// to update the route signal with the current URL hash value.
48 ///
49 /// # Arguments
50 ///
51 /// - `Signal<String>` - The reactive signal that holds the current route and will be updated on each hash change.
52 pub fn use_hash_change(route_signal: Signal<String>) {
53 App::use_window_event("hashchange", move || {
54 WINDOW_EVENT_DEPTH.with(|depth: &Cell<usize>| depth.set(depth.get() + 1));
55 route_signal.set(Self::current_route());
56 WINDOW_EVENT_DEPTH.with(|depth: &Cell<usize>| depth.set(depth.get() - 1));
57 });
58 }
59
60 /// Manages browser history for all overlays (modals, panels, drawers) so that
61 /// the back button closes the most recently opened overlay instead of navigating away.
62 ///
63 /// Uses a unified `OVERLAY_STACK` that records every overlay in the order it was opened.
64 /// A `popstate` listener pops the topmost entry and invokes its close callback, so
65 /// overlays close in reverse opening order regardless of type.
66 ///
67 /// Before consulting the overlay stack, the listener iterates over all registered
68 /// `popstate` guards (see [`register_popstate_guard`]). The first guard that returns
69 /// `true` consumes the event, preventing the overlay stack and normal navigation
70 /// from processing it.
71 ///
72 /// # Arguments
73 ///
74 /// - `Signal<bool>` - The reactive signal controlling the nav drawer visibility.
75 /// - `Signal<bool>` - The reactive signal tracking whether the viewport is mobile-sized.
76 pub fn use_overlay_history(drawer_open: Signal<bool>, mobile_signal: Signal<bool>) {
77 let was_drawer_open: Signal<bool> = App::use_signal(|| false);
78 watch!(drawer_open, |is_open: bool| {
79 let previous: bool = was_drawer_open.get();
80 if is_open && !previous && mobile_signal.get() {
81 let closer: Rc<dyn Fn()> = Rc::new(move || {
82 drawer_open.set(false);
83 });
84 Self::overlay_stack_push(closer);
85 }
86 was_drawer_open.set(is_open);
87 });
88 App::use_window_event("popstate", move || {
89 WINDOW_EVENT_DEPTH.with(|depth: &Cell<usize>| depth.set(depth.get() + 1));
90 let consumed: bool = POPSTATE_GUARDS.with(|guards: &PopstateGuardList| {
91 guards
92 .borrow()
93 .iter()
94 .any(|entry: &PopstateGuardEntry| entry.1())
95 });
96 if consumed {
97 WINDOW_EVENT_DEPTH.with(|depth: &Cell<usize>| depth.set(depth.get() - 1));
98 return;
99 }
100 if BACK_PENDING.with(|flag: &Cell<bool>| flag.get()) {
101 BACK_PENDING.with(|flag: &Cell<bool>| flag.set(false));
102 let pending_route: Option<String> =
103 NAVIGATE_AFTER_BACK.with(|cell: &Cell<Option<String>>| cell.take());
104 if let Some(closer) = Self::overlay_stack_pop() {
105 closer();
106 }
107 if let Some(route) = pending_route {
108 Self::navigate(&route);
109 }
110 WINDOW_EVENT_DEPTH.with(|depth: &Cell<usize>| depth.set(depth.get() - 1));
111 return;
112 }
113 if let Some(closer) = Self::overlay_stack_pop() {
114 closer();
115 WINDOW_EVENT_DEPTH.with(|depth: &Cell<usize>| depth.set(depth.get() - 1));
116 return;
117 }
118 WINDOW_EVENT_DEPTH.with(|depth: &Cell<usize>| depth.set(depth.get() - 1));
119 });
120 }
121
122 /// Watches the drawer open signal and scrolls the mobile navigation drawer
123 /// to make the currently active navigation item visible when the drawer opens.
124 ///
125 /// Uses nested `requestAnimationFrame` to defer the scroll until after the
126 /// framework has completed its DOM update cycle. The first `requestAnimationFrame`
127 /// fires after the framework's own `requestAnimationFrame`-based render pass,
128 /// and the second one fires after the browser has laid out the new DOM.
129 /// Locates the scrollable `c-nav-items-scroll` container and the active nav
130 /// item within the drawer, then sets `scrollTop` so the active item appears
131 /// near the vertical center of the container.
132 ///
133 /// # Arguments
134 ///
135 /// - `Signal<bool>` - The reactive signal controlling the mobile nav drawer visibility.
136 pub fn use_scroll_drawer_to_active(drawer_open: Signal<bool>) {
137 watch!(drawer_open, |is_open: bool| {
138 if !is_open {
139 return;
140 }
141 let Some(window_value) = window() else {
142 return;
143 };
144 let outer_raf: Window = window_value.clone();
145 let inner_raf_clone: Window = window_value.clone();
146 let inner_doc_clone: Window = window_value.clone();
147 let outer_closure: Closure<dyn FnMut()> = Closure::wrap(Box::new(move || {
148 let inner_raf: Window = inner_raf_clone.clone();
149 let inner_doc: Window = inner_doc_clone.clone();
150 let inner_closure: Closure<dyn FnMut()> = Closure::wrap(Box::new(move || {
151 let Some(document_value) = inner_doc.document() else {
152 return;
153 };
154 let Some(drawer_nav) = document_value
155 .query_selector(DRAWER_NAV_SELECTOR)
156 .ok()
157 .flatten()
158 else {
159 return;
160 };
161 let Some(active_element) = drawer_nav
162 .query_selector(ACTIVE_NAV_ITEM_SELECTOR)
163 .ok()
164 .flatten()
165 else {
166 return;
167 };
168 let active_html_element: HtmlElement = active_element.unchecked_into();
169 let Some(scroll_container) = drawer_nav
170 .query_selector(NAV_ITEMS_SCROLL_SELECTOR)
171 .ok()
172 .flatten()
173 else {
174 return;
175 };
176 let scroll_html_element: HtmlElement = scroll_container.unchecked_into();
177 let active_rect: DomRect = active_html_element.get_bounding_client_rect();
178 let container_rect: DomRect = scroll_html_element.get_bounding_client_rect();
179 let offset_from_container_top: f64 = active_rect.top() - container_rect.top();
180 let current_scroll_top: i32 = scroll_html_element.scroll_top();
181 let container_height: f64 = container_rect.height();
182 let active_height: f64 = active_rect.height();
183 let target_scroll_top: f64 = current_scroll_top as f64
184 + offset_from_container_top
185 - (container_height - active_height) / 2.0;
186 scroll_html_element.set_scroll_top(target_scroll_top.max(0.0) as i32);
187 }));
188 let _: Result<i32, JsValue> =
189 inner_raf.request_animation_frame(inner_closure.as_ref().unchecked_ref());
190 inner_closure.forget();
191 }));
192 let _: Result<i32, JsValue> =
193 outer_raf.request_animation_frame(outer_closure.as_ref().unchecked_ref());
194 outer_closure.forget();
195 });
196 }
197
198 /// Registers a `popstate` guard callback that is invoked on every `popstate`
199 /// event before the overlay stack is consulted.
200 ///
201 /// Guards are called in registration order. The first guard that returns `true`
202 /// consumes the `popstate` event, preventing the overlay stack and normal
203 /// navigation from processing it. This allows external modules (e.g. native
204 /// fullscreen, canvas fullscreen) to intercept the system back gesture without
205 /// registering their own independent `popstate` listener.
206 ///
207 /// Returns a guard ID that can be passed to [`Router::unregister_popstate_guard`] to
208 /// remove the guard when it is no longer needed.
209 ///
210 /// # Arguments
211 ///
212 /// - `Rc<dyn Fn() -> bool>` - The guard callback. Return `true` to consume the
213 /// `popstate` event, `false` to let subsequent guards or the overlay stack
214 /// handle it.
215 ///
216 /// # Returns
217 ///
218 /// - `usize` - A unique guard ID for later unregistration.
219 pub fn register_popstate_guard(guard: Rc<dyn Fn() -> bool>) -> usize {
220 NEXT_POPSTATE_GUARD_ID.with(|counter: &Cell<usize>| {
221 let id: usize = counter.get();
222 counter.set(id + 1);
223 POPSTATE_GUARDS.with(|guards: &PopstateGuardList| {
224 guards.borrow_mut().push((id, guard));
225 });
226 id
227 })
228 }
229
230 /// Pushes a browser history entry for an overlay that is about to open.
231 ///
232 /// Call this when an overlay (vconsole panel) opens so that the browser
233 /// back button will close the overlay instead of navigating away.
234 pub fn overlay_push_state() {
235 let Some(window) = window() else {
236 return;
237 };
238 let Ok(history) = window.history() else {
239 return;
240 };
241 let _: Result<(), JsValue> = history.push_state(&JsValue::NULL, "");
242 }
243
244 /// Performs a programmatic `history.back()` to consume the overlay's
245 /// history entry, optionally scheduling a navigation to run after the
246 /// `popstate` event fires.
247 ///
248 /// # Arguments
249 ///
250 /// - `Option<String>` - An optional route to navigate to after the back completes.
251 pub fn overlay_back(navigate_target: Option<String>) {
252 BACK_PENDING.with(|flag: &Cell<bool>| flag.set(true));
253 if let Some(ref route) = navigate_target {
254 NAVIGATE_AFTER_BACK.with(|cell: &Cell<Option<String>>| cell.set(Some(route.clone())));
255 }
256 let Some(window) = window() else {
257 return;
258 };
259 let Ok(history) = window.history() else {
260 return;
261 };
262 let _: Result<(), JsValue> = history.back();
263 }
264
265 /// Pushes an overlay close callback onto the unified `OVERLAY_STACK` and
266 /// pushes a browser history entry so the back button dismisses it.
267 ///
268 /// Call this whenever any overlay (modal, panel, or drawer) opens.
269 ///
270 /// # Arguments
271 ///
272 /// - `Rc<dyn Fn()>` - The callback that closes the overlay (e.g., sets its visibility signal to `false`).
273 pub(crate) fn overlay_stack_push(closer: Rc<dyn Fn()>) {
274 OVERLAY_STACK.with(|stack: &OverlayStack| {
275 stack.borrow_mut().push(OverlayEntry { closer });
276 });
277 Self::overlay_push_state();
278 }
279
280 /// Pops the most recently opened overlay from the unified `OVERLAY_STACK` and
281 /// returns its close callback, without invoking it.
282 ///
283 /// Also synchronizes the `MODAL_STACK` by removing the matching entry if the
284 /// popped overlay is a modal.
285 ///
286 /// # Returns
287 ///
288 /// - `Option<Rc<dyn Fn()>>` - The topmost overlay's close callback, or `None` if no overlay is open.
289 pub(crate) fn overlay_stack_pop() -> Option<Rc<dyn Fn()>> {
290 let closer: Option<Rc<dyn Fn()>> = OVERLAY_STACK.with(|stack: &OverlayStack| {
291 stack
292 .borrow_mut()
293 .pop()
294 .map(|entry: OverlayEntry| entry.closer)
295 });
296 if let Some(ref closer_ref) = closer {
297 MODAL_STACK.with(|stack: &ModalStack| {
298 let mut entries: RefMut<'_, Vec<ModalStackEntry>> = stack.borrow_mut();
299 if let Some(index) = entries
300 .iter()
301 .rposition(|(_, closer): &ModalStackEntry| Rc::ptr_eq(closer, closer_ref))
302 {
303 entries.remove(index);
304 }
305 });
306 }
307 closer
308 }
309
310 /// Closes the most recently opened overlay via the UI and consumes one
311 /// browser history entry.
312 ///
313 /// Pops the top entry from `OVERLAY_STACK` and calls `overlay_back` so that
314 /// the history count stays in sync. Use this when the user dismisses an overlay
315 /// through a close button, overlay click, or confirm/cancel action.
316 pub fn overlay_stack_close() {
317 OVERLAY_STACK.with(|stack: &OverlayStack| {
318 stack.borrow_mut().pop();
319 });
320 Self::overlay_back(None);
321 }
322
323 /// Registers an open modal by pushing it onto the global modal stack and
324 /// adding a browser history entry, enabling nested modals.
325 ///
326 /// The stack is ordered with the most recently opened modal on top. When the
327 /// user triggers a system back gesture (or presses the browser back button),
328 /// the `popstate` handler pops the topmost entry from `OVERLAY_STACK` and
329 /// invokes its close callback, so the most recently opened overlay is dismissed
330 /// first instead of navigating to the previous page.
331 ///
332 /// If the given visibility signal is already on the stack, this is a no-op so
333 /// that re-opening an already-open modal does not create duplicate stack or
334 /// history entries.
335 ///
336 /// # Arguments
337 ///
338 /// - `Signal<bool>` - The modal's visibility signal, used as a stable identity for later removal.
339 /// - `Rc<dyn Fn()>` - The callback that closes the modal (e.g., sets the visibility signal to `false`).
340 pub fn modal_push(visible: Signal<bool>, closer: Rc<dyn Fn()>) {
341 let already_open: bool = MODAL_STACK.with(|stack: &ModalStack| {
342 stack
343 .borrow()
344 .iter()
345 .any(|(signal, _): &ModalStackEntry| *signal == visible)
346 });
347 if already_open {
348 return;
349 }
350 MODAL_STACK.with(|stack: &ModalStack| stack.borrow_mut().push((visible, closer.clone())));
351 Self::overlay_stack_push(closer);
352 }
353
354 /// Closes a modal that was opened via [`Router::modal_push`] when the user dismisses
355 /// it through the UI (close button, overlay click, confirm/cancel action)
356 /// rather than the system back gesture.
357 ///
358 /// Removes the entry matching the given visibility signal from the global
359 /// stack (by identity, not necessarily the top, so nested modals stay
360 /// consistent) and consumes one matching browser history entry via
361 /// `overlay_stack_close`, keeping the history count in sync so a subsequent back
362 /// gesture behaves correctly.
363 ///
364 /// # Arguments
365 ///
366 /// - `Signal<bool>` - The visibility signal identifying the modal to remove.
367 pub fn modal_close_via_ui(visible: Signal<bool>) {
368 let removed: bool = MODAL_STACK.with(|stack: &ModalStack| {
369 let mut entries: RefMut<'_, Vec<ModalStackEntry>> = stack.borrow_mut();
370 if let Some(index) = entries
371 .iter()
372 .rposition(|(signal, _): &ModalStackEntry| *signal == visible)
373 {
374 entries.remove(index);
375 true
376 } else {
377 false
378 }
379 });
380 if removed {
381 Self::overlay_stack_close();
382 }
383 }
384
385 /// Opens the given URL in the system default browser using `window.open`
386 /// with the `_system` target name.
387 ///
388 /// In a bridge WebView environment, the `_system` target instructs the
389 /// shell opener plugin to delegate the URL to the operating system's
390 /// default browser. In a regular browser, `window.open` falls back to
391 /// opening a new tab or window as usual.
392 ///
393 /// # Arguments
394 ///
395 /// - `U: AsRef<str>` - The URL to open.
396 pub fn open_system_browser<U>(url: U)
397 where
398 U: AsRef<str>,
399 {
400 let Some(window_value) = window() else {
401 return;
402 };
403 if let Ok(open_fn) = Reflect::get(&window_value, &JsValue::from_str("open"))
404 .and_then(|value: JsValue| value.dyn_into::<Function>())
405 {
406 let _: Result<JsValue, JsValue> = open_fn.call2(
407 &window_value,
408 &JsValue::from_str(url.as_ref()),
409 &JsValue::from_str(SYSTEM_BROWSER_TARGET),
410 );
411 }
412 }
413
414 /// Creates a click event handler for external `<a>` links that opens
415 /// the URL in the system default browser.
416 ///
417 /// Calls `event.prevent_default()` to suppress the `<a>` element's
418 /// default navigation (which would open inside the WebView), then
419 /// delegates to `open_system_browser` so the URL is handled by the
420 /// operating system's default browser.
421 ///
422 /// # Arguments
423 ///
424 /// - `U: AsRef<str>` - The external URL to open on click.
425 ///
426 /// # Returns
427 ///
428 /// - `NativeEventHandler` - An event handler for click events.
429 pub fn external_link_handler<U>(url: U) -> NativeEventHandler
430 where
431 U: AsRef<str>,
432 {
433 let url_string: String = url.as_ref().to_string();
434 NativeEventHandler::create("click", move |event: Event| {
435 event.prevent_default();
436 Self::open_system_browser(&url_string);
437 })
438 }
439
440 /// Helper to close the drawer and navigate.
441 ///
442 /// Used internally by mobile nav items.
443 /// Closes the drawer via overlay back and schedules navigation to the target route
444 /// after the popstate event is processed.
445 ///
446 /// # Arguments
447 ///
448 /// - `Signal<bool>` - The drawer open signal.
449 /// - `T: AsRef<str>` - The target route.
450 pub fn close_drawer_and_navigate<T>(_drawer_open: Signal<bool>, target: T)
451 where
452 T: AsRef<str>,
453 {
454 Self::overlay_back(Some(target.as_ref().to_string()));
455 }
456}