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