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 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}