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}