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 /// Triggers `overlay_back`, which sets the `BACK_PENDING` flag and calls
314 /// `history.back()`. The resulting `popstate` handler invocation pops the
315 /// top entry from `OVERLAY_STACK` and runs its close callback, keeping the
316 /// history count in sync. Use this when the user dismisses an overlay
317 /// through a close button, overlay click, or confirm/cancel action.
318 ///
319 /// Note: this method does **not** pop `OVERLAY_STACK` itself — the popstate
320 /// handler is the single owner of the pop, so UI dismissal and the system
321 /// back gesture share one consistent path.
322 pub fn overlay_stack_close() {
323 Self::overlay_back(None);
324 }
325
326 /// Registers an open modal by pushing it onto the global modal stack and
327 /// adding a browser history entry, enabling nested modals.
328 ///
329 /// The stack is ordered with the most recently opened modal on top. When the
330 /// user triggers a system back gesture (or presses the browser back button),
331 /// the `popstate` handler pops the topmost entry from `OVERLAY_STACK` and
332 /// invokes its close callback, so the most recently opened overlay is dismissed
333 /// first instead of navigating to the previous page.
334 ///
335 /// If the given visibility signal is already on the stack, this is a no-op so
336 /// that re-opening an already-open modal does not create duplicate stack or
337 /// history entries.
338 ///
339 /// # Arguments
340 ///
341 /// - `Signal<bool>` - The modal's visibility signal, used as a stable identity for later removal.
342 /// - `Rc<dyn Fn()>` - The callback that closes the modal (e.g., sets the visibility signal to `false`).
343 pub fn modal_push(visible: Signal<bool>, closer: Rc<dyn Fn()>) {
344 let already_open: bool = MODAL_STACK.with(|stack: &ModalStack| {
345 stack
346 .borrow()
347 .iter()
348 .any(|(signal, _): &ModalStackEntry| *signal == visible)
349 });
350 if already_open {
351 return;
352 }
353 MODAL_STACK.with(|stack: &ModalStack| stack.borrow_mut().push((visible, closer.clone())));
354 Self::overlay_stack_push(closer);
355 }
356
357 /// Closes a modal that was opened via [`Router::modal_push`] when the user dismisses
358 /// it through the UI (close button, overlay click, confirm/cancel action)
359 /// rather than the system back gesture.
360 ///
361 /// Removes the entry matching the given visibility signal from the global
362 /// stack (by identity, not necessarily the top, so nested modals stay
363 /// consistent) and consumes one matching browser history entry via
364 /// `overlay_stack_close`, keeping the history count in sync so a subsequent back
365 /// gesture behaves correctly.
366 ///
367 /// # Arguments
368 ///
369 /// - `Signal<bool>` - The visibility signal identifying the modal to remove.
370 pub fn modal_close_via_ui(visible: Signal<bool>) {
371 let removed: bool = MODAL_STACK.with(|stack: &ModalStack| {
372 let mut entries: RefMut<'_, Vec<ModalStackEntry>> = stack.borrow_mut();
373 if let Some(index) = entries
374 .iter()
375 .rposition(|(signal, _): &ModalStackEntry| *signal == visible)
376 {
377 entries.remove(index);
378 true
379 } else {
380 false
381 }
382 });
383 if removed {
384 Self::overlay_stack_close();
385 }
386 }
387
388 /// Opens the given URL in the system default browser using `window.open`
389 /// with the `_system` target name.
390 ///
391 /// In a bridge WebView environment, the `_system` target instructs the
392 /// shell opener plugin to delegate the URL to the operating system's
393 /// default browser. In a regular browser, `window.open` falls back to
394 /// opening a new tab or window as usual.
395 ///
396 /// # Arguments
397 ///
398 /// - `U: AsRef<str>` - The URL to open.
399 pub fn open_system_browser<U>(url: U)
400 where
401 U: AsRef<str>,
402 {
403 let Some(window_value) = window() else {
404 return;
405 };
406 if let Ok(open_fn) = Reflect::get(&window_value, &JsValue::from_str("open"))
407 .and_then(|value: JsValue| value.dyn_into::<Function>())
408 {
409 let _: Result<JsValue, JsValue> = open_fn.call2(
410 &window_value,
411 &JsValue::from_str(url.as_ref()),
412 &JsValue::from_str(SYSTEM_BROWSER_TARGET),
413 );
414 }
415 }
416
417 /// Creates a click event handler for external `<a>` links that opens
418 /// the URL in the system default browser.
419 ///
420 /// Calls `event.prevent_default()` to suppress the `<a>` element's
421 /// default navigation (which would open inside the WebView), then
422 /// delegates to `open_system_browser` so the URL is handled by the
423 /// operating system's default browser.
424 ///
425 /// # Arguments
426 ///
427 /// - `U: AsRef<str>` - The external URL to open on click.
428 ///
429 /// # Returns
430 ///
431 /// - `NativeEventHandler` - An event handler for click events.
432 pub fn external_link_handler<U>(url: U) -> NativeEventHandler
433 where
434 U: AsRef<str>,
435 {
436 let url_string: String = url.as_ref().to_string();
437 NativeEventHandler::create("click", move |event: Event| {
438 event.prevent_default();
439 Self::open_system_browser(&url_string);
440 })
441 }
442
443 /// Helper to close the drawer and navigate.
444 ///
445 /// Used internally by mobile nav items.
446 /// Closes the drawer via overlay back and schedules navigation to the target route
447 /// after the popstate event is processed.
448 ///
449 /// # Arguments
450 ///
451 /// - `Signal<bool>` - The drawer open signal.
452 /// - `T: AsRef<str>` - The target route.
453 pub fn close_drawer_and_navigate<T>(_drawer_open: Signal<bool>, target: T)
454 where
455 T: AsRef<str>,
456 {
457 Self::overlay_back(Some(target.as_ref().to_string()));
458 }
459}