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