Skip to main content

euv_ui/component/router/hook/
impl.rs

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