Skip to main content

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 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 _: Result<i32, JsValue> =
182                    inner_raf.request_animation_frame(inner_closure.as_ref().unchecked_ref());
183                inner_closure.forget();
184            }));
185            let _: Result<i32, JsValue> =
186                outer_raf.request_animation_frame(outer_closure.as_ref().unchecked_ref());
187            outer_closure.forget();
188        });
189    }
190
191    /// Registers a `popstate` guard callback that is invoked on every `popstate`
192    /// event before the overlay stack is consulted.
193    ///
194    /// Guards are called in registration order. The first guard that returns `true`
195    /// consumes the `popstate` event, preventing the overlay stack and normal
196    /// navigation from processing it. This allows external modules (e.g. native
197    /// fullscreen, canvas fullscreen) to intercept the system back gesture without
198    /// registering their own independent `popstate` listener.
199    ///
200    /// Returns a guard ID that can be passed to [`Router::unregister_popstate_guard`] to
201    /// remove the guard when it is no longer needed.
202    ///
203    /// # Arguments
204    ///
205    /// - `Rc<dyn Fn() -> bool>` - The guard callback. Return `true` to consume the
206    ///   `popstate` event, `false` to let subsequent guards or the overlay stack
207    ///   handle it.
208    ///
209    /// # Returns
210    ///
211    /// - `usize` - A unique guard ID for later unregistration.
212    pub fn register_popstate_guard(guard: Rc<dyn Fn() -> bool>) -> usize {
213        NEXT_POPSTATE_GUARD_ID.with(|counter: &Cell<usize>| {
214            let id: usize = counter.get();
215            counter.set(id + 1);
216            POPSTATE_GUARDS.with(|guards: &PopstateGuardList| {
217                guards.borrow_mut().push((id, guard));
218            });
219            id
220        })
221    }
222
223    /// Pushes a browser history entry for an overlay that is about to open.
224    ///
225    /// Call this when an overlay (vconsole panel) opens so that the browser
226    /// back button will close the overlay instead of navigating away.
227    pub fn overlay_push_state() {
228        let window: Window = window().expect("no global window exists");
229        let history: History = window.history().expect("no history object exists");
230        let _: Result<(), JsValue> = history.push_state(&JsValue::NULL, "");
231    }
232
233    /// Performs a programmatic `history.back()` to consume the overlay's
234    /// history entry, optionally scheduling a navigation to run after the
235    /// `popstate` event fires.
236    ///
237    /// # Arguments
238    ///
239    /// - `Option<String>` - An optional route to navigate to after the back completes.
240    pub fn overlay_back(navigate_target: Option<String>) {
241        BACK_PENDING.with(|flag: &Cell<bool>| flag.set(true));
242        if let Some(ref route) = navigate_target {
243            NAVIGATE_AFTER_BACK.with(|cell: &Cell<Option<String>>| cell.set(Some(route.clone())));
244        }
245        let window: Window = window().expect("no global window exists");
246        let history: History = window.history().expect("no history object exists");
247        let _: Result<(), JsValue> = history.back();
248    }
249
250    /// Pushes an overlay close callback onto the unified `OVERLAY_STACK` and
251    /// pushes a browser history entry so the back button dismisses it.
252    ///
253    /// Call this whenever any overlay (modal, panel, or drawer) opens.
254    ///
255    /// # Arguments
256    ///
257    /// - `Rc<dyn Fn()>` - The callback that closes the overlay (e.g., sets its visibility signal to `false`).
258    pub(crate) fn overlay_stack_push(closer: Rc<dyn Fn()>) {
259        OVERLAY_STACK.with(|stack: &OverlayStack| {
260            stack.borrow_mut().push(OverlayEntry { closer });
261        });
262        Self::overlay_push_state();
263    }
264
265    /// Pops the most recently opened overlay from the unified `OVERLAY_STACK` and
266    /// returns its close callback, without invoking it.
267    ///
268    /// Also synchronizes the `MODAL_STACK` by removing the matching entry if the
269    /// popped overlay is a modal.
270    ///
271    /// # Returns
272    ///
273    /// - `Option<Rc<dyn Fn()>>` - The topmost overlay's close callback, or `None` if no overlay is open.
274    pub(crate) fn overlay_stack_pop() -> Option<Rc<dyn Fn()>> {
275        let closer: Option<Rc<dyn Fn()>> = OVERLAY_STACK.with(|stack: &OverlayStack| {
276            stack
277                .borrow_mut()
278                .pop()
279                .map(|entry: OverlayEntry| entry.closer)
280        });
281        if let Some(ref closer_ref) = closer {
282            MODAL_STACK.with(|stack: &ModalStack| {
283                let mut entries: RefMut<'_, Vec<ModalStackEntry>> = stack.borrow_mut();
284                if let Some(index) = entries
285                    .iter()
286                    .rposition(|(_, closer): &ModalStackEntry| Rc::ptr_eq(closer, closer_ref))
287                {
288                    entries.remove(index);
289                }
290            });
291        }
292        closer
293    }
294
295    /// Closes the most recently opened overlay via the UI and consumes one
296    /// browser history entry.
297    ///
298    /// Pops the top entry from `OVERLAY_STACK` and calls `overlay_back` so that
299    /// the history count stays in sync. Use this when the user dismisses an overlay
300    /// through a close button, overlay click, or confirm/cancel action.
301    pub fn overlay_stack_close() {
302        OVERLAY_STACK.with(|stack: &OverlayStack| {
303            stack.borrow_mut().pop();
304        });
305        Self::overlay_back(None);
306    }
307
308    /// Registers an open modal by pushing it onto the global modal stack and
309    /// adding a browser history entry, enabling nested modals.
310    ///
311    /// The stack is ordered with the most recently opened modal on top. When the
312    /// user triggers a system back gesture (or presses the browser back button),
313    /// the `popstate` handler pops the topmost entry from `OVERLAY_STACK` and
314    /// invokes its close callback, so the most recently opened overlay is dismissed
315    /// first instead of navigating to the previous page.
316    ///
317    /// If the given visibility signal is already on the stack, this is a no-op so
318    /// that re-opening an already-open modal does not create duplicate stack or
319    /// history entries.
320    ///
321    /// # Arguments
322    ///
323    /// - `Signal<bool>` - The modal's visibility signal, used as a stable identity for later removal.
324    /// - `Rc<dyn Fn()>` - The callback that closes the modal (e.g., sets the visibility signal to `false`).
325    pub fn modal_push(visible: Signal<bool>, closer: Rc<dyn Fn()>) {
326        let already_open: bool = MODAL_STACK.with(|stack: &ModalStack| {
327            stack
328                .borrow()
329                .iter()
330                .any(|(signal, _): &ModalStackEntry| *signal == visible)
331        });
332        if already_open {
333            return;
334        }
335        MODAL_STACK.with(|stack: &ModalStack| stack.borrow_mut().push((visible, closer.clone())));
336        Self::overlay_stack_push(closer);
337    }
338
339    /// Closes a modal that was opened via [`Router::modal_push`] when the user dismisses
340    /// it through the UI (close button, overlay click, confirm/cancel action)
341    /// rather than the system back gesture.
342    ///
343    /// Removes the entry matching the given visibility signal from the global
344    /// stack (by identity, not necessarily the top, so nested modals stay
345    /// consistent) and consumes one matching browser history entry via
346    /// `overlay_stack_close`, keeping the history count in sync so a subsequent back
347    /// gesture behaves correctly.
348    ///
349    /// # Arguments
350    ///
351    /// - `Signal<bool>` - The visibility signal identifying the modal to remove.
352    pub fn modal_close_via_ui(visible: Signal<bool>) {
353        let removed: bool = MODAL_STACK.with(|stack: &ModalStack| {
354            let mut entries: RefMut<'_, Vec<ModalStackEntry>> = stack.borrow_mut();
355            if let Some(index) = entries
356                .iter()
357                .rposition(|(signal, _): &ModalStackEntry| *signal == visible)
358            {
359                entries.remove(index);
360                true
361            } else {
362                false
363            }
364        });
365        if removed {
366            Self::overlay_stack_close();
367        }
368    }
369
370    /// Opens the given URL in the system default browser using `window.open`
371    /// with the `_system` target name.
372    ///
373    /// In a bridge WebView environment, the `_system` target instructs the
374    /// shell opener plugin to delegate the URL to the operating system's
375    /// default browser. In a regular browser, `window.open` falls back to
376    /// opening a new tab or window as usual.
377    ///
378    /// # Arguments
379    ///
380    /// - `U: AsRef<str>` - The URL to open.
381    pub fn open_system_browser<U>(url: U)
382    where
383        U: AsRef<str>,
384    {
385        let window_value: Window = window().expect("no global window exists");
386        if let Ok(open_fn) = Reflect::get(&window_value, &JsValue::from_str("open"))
387            .and_then(|value: JsValue| value.dyn_into::<Function>())
388        {
389            let _: Result<JsValue, JsValue> = open_fn.call2(
390                &window_value,
391                &JsValue::from_str(url.as_ref()),
392                &JsValue::from_str(SYSTEM_BROWSER_TARGET),
393            );
394        }
395    }
396
397    /// Creates a click event handler for external `<a>` links that opens
398    /// the URL in the system default browser.
399    ///
400    /// Calls `event.prevent_default()` to suppress the `<a>` element's
401    /// default navigation (which would open inside the WebView), then
402    /// delegates to `open_system_browser` so the URL is handled by the
403    /// operating system's default browser.
404    ///
405    /// # Arguments
406    ///
407    /// - `U: AsRef<str>` - The external URL to open on click.
408    ///
409    /// # Returns
410    ///
411    /// - `NativeEventHandler` - An event handler for click events.
412    pub fn external_link_handler<U>(url: U) -> NativeEventHandler
413    where
414        U: AsRef<str>,
415    {
416        let url_string: String = url.as_ref().to_string();
417        NativeEventHandler::create("click", move |event: Event| {
418            event.prevent_default();
419            Self::open_system_browser(&url_string);
420        })
421    }
422
423    /// Helper to close the drawer and navigate.
424    ///
425    /// Used internally by mobile nav items.
426    /// Closes the drawer via overlay back and schedules navigation to the target route
427    /// after the popstate event is processed.
428    ///
429    /// # Arguments
430    ///
431    /// - `Signal<bool>` - The drawer open signal.
432    /// - `T: AsRef<str>` - The target route.
433    pub fn close_drawer_and_navigate<T>(_drawer_open: Signal<bool>, target: T)
434    where
435        T: AsRef<str>,
436    {
437        Self::overlay_back(Some(target.as_ref().to_string()));
438    }
439}