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}