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 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 _: Result<i32, JsValue> =
182 inner_raf.request_animation_frame(inner_closure.as_ref().unchecked_ref());
183 inner_closure.forget();
184 }));
185 let _: Result<i32, JsValue> =
186 outer_raf.request_animation_frame(outer_closure.as_ref().unchecked_ref());
187 outer_closure.forget();
188 });
189 }
190
191 /// Registers a `popstate` guard callback that is invoked on every `popstate`
192 /// event before the overlay stack is consulted.
193 ///
194 /// Guards are called in registration order. The first guard that returns `true`
195 /// consumes the `popstate` event, preventing the overlay stack and normal
196 /// navigation from processing it. This allows external modules (e.g. native
197 /// fullscreen, canvas fullscreen) to intercept the system back gesture without
198 /// registering their own independent `popstate` listener.
199 ///
200 /// Returns a guard ID that can be passed to [`Router::unregister_popstate_guard`] to
201 /// remove the guard when it is no longer needed.
202 ///
203 /// # Arguments
204 ///
205 /// - `Rc<dyn Fn() -> bool>` - The guard callback. Return `true` to consume the
206 /// `popstate` event, `false` to let subsequent guards or the overlay stack
207 /// handle it.
208 ///
209 /// # Returns
210 ///
211 /// - `usize` - A unique guard ID for later unregistration.
212 pub fn register_popstate_guard(guard: Rc<dyn Fn() -> bool>) -> usize {
213 NEXT_POPSTATE_GUARD_ID.with(|counter: &Cell<usize>| {
214 let id: usize = counter.get();
215 counter.set(id + 1);
216 POPSTATE_GUARDS.with(|guards: &PopstateGuardList| {
217 guards.borrow_mut().push((id, guard));
218 });
219 id
220 })
221 }
222
223 /// Pushes a browser history entry for an overlay that is about to open.
224 ///
225 /// Call this when an overlay (vconsole panel) opens so that the browser
226 /// back button will close the overlay instead of navigating away.
227 pub fn overlay_push_state() {
228 let window: Window = window().expect("no global window exists");
229 let history: History = window.history().expect("no history object exists");
230 let _: Result<(), JsValue> = history.push_state(&JsValue::NULL, "");
231 }
232
233 /// Performs a programmatic `history.back()` to consume the overlay's
234 /// history entry, optionally scheduling a navigation to run after the
235 /// `popstate` event fires.
236 ///
237 /// # Arguments
238 ///
239 /// - `Option<String>` - An optional route to navigate to after the back completes.
240 pub fn overlay_back(navigate_target: Option<String>) {
241 BACK_PENDING.with(|flag: &Cell<bool>| flag.set(true));
242 if let Some(ref route) = navigate_target {
243 NAVIGATE_AFTER_BACK.with(|cell: &Cell<Option<String>>| cell.set(Some(route.clone())));
244 }
245 let window: Window = window().expect("no global window exists");
246 let history: History = window.history().expect("no history object exists");
247 let _: Result<(), JsValue> = history.back();
248 }
249
250 /// Pushes an overlay close callback onto the unified `OVERLAY_STACK` and
251 /// pushes a browser history entry so the back button dismisses it.
252 ///
253 /// Call this whenever any overlay (modal, panel, or drawer) opens.
254 ///
255 /// # Arguments
256 ///
257 /// - `Rc<dyn Fn()>` - The callback that closes the overlay (e.g., sets its visibility signal to `false`).
258 pub(crate) fn overlay_stack_push(closer: Rc<dyn Fn()>) {
259 OVERLAY_STACK.with(|stack: &OverlayStack| {
260 stack.borrow_mut().push(OverlayEntry { closer });
261 });
262 Self::overlay_push_state();
263 }
264
265 /// Pops the most recently opened overlay from the unified `OVERLAY_STACK` and
266 /// returns its close callback, without invoking it.
267 ///
268 /// Also synchronizes the `MODAL_STACK` by removing the matching entry if the
269 /// popped overlay is a modal.
270 ///
271 /// # Returns
272 ///
273 /// - `Option<Rc<dyn Fn()>>` - The topmost overlay's close callback, or `None` if no overlay is open.
274 pub(crate) fn overlay_stack_pop() -> Option<Rc<dyn Fn()>> {
275 let closer: Option<Rc<dyn Fn()>> = OVERLAY_STACK.with(|stack: &OverlayStack| {
276 stack
277 .borrow_mut()
278 .pop()
279 .map(|entry: OverlayEntry| entry.closer)
280 });
281 if let Some(ref closer_ref) = closer {
282 MODAL_STACK.with(|stack: &ModalStack| {
283 let mut entries: RefMut<'_, Vec<ModalStackEntry>> = stack.borrow_mut();
284 if let Some(index) = entries
285 .iter()
286 .rposition(|(_, closer): &ModalStackEntry| Rc::ptr_eq(closer, closer_ref))
287 {
288 entries.remove(index);
289 }
290 });
291 }
292 closer
293 }
294
295 /// Closes the most recently opened overlay via the UI and consumes one
296 /// browser history entry.
297 ///
298 /// Pops the top entry from `OVERLAY_STACK` and calls `overlay_back` so that
299 /// the history count stays in sync. Use this when the user dismisses an overlay
300 /// through a close button, overlay click, or confirm/cancel action.
301 pub fn overlay_stack_close() {
302 OVERLAY_STACK.with(|stack: &OverlayStack| {
303 stack.borrow_mut().pop();
304 });
305 Self::overlay_back(None);
306 }
307
308 /// Registers an open modal by pushing it onto the global modal stack and
309 /// adding a browser history entry, enabling nested modals.
310 ///
311 /// The stack is ordered with the most recently opened modal on top. When the
312 /// user triggers a system back gesture (or presses the browser back button),
313 /// the `popstate` handler pops the topmost entry from `OVERLAY_STACK` and
314 /// invokes its close callback, so the most recently opened overlay is dismissed
315 /// first instead of navigating to the previous page.
316 ///
317 /// If the given visibility signal is already on the stack, this is a no-op so
318 /// that re-opening an already-open modal does not create duplicate stack or
319 /// history entries.
320 ///
321 /// # Arguments
322 ///
323 /// - `Signal<bool>` - The modal's visibility signal, used as a stable identity for later removal.
324 /// - `Rc<dyn Fn()>` - The callback that closes the modal (e.g., sets the visibility signal to `false`).
325 pub fn modal_push(visible: Signal<bool>, closer: Rc<dyn Fn()>) {
326 let already_open: bool = MODAL_STACK.with(|stack: &ModalStack| {
327 stack
328 .borrow()
329 .iter()
330 .any(|(signal, _): &ModalStackEntry| *signal == visible)
331 });
332 if already_open {
333 return;
334 }
335 MODAL_STACK.with(|stack: &ModalStack| stack.borrow_mut().push((visible, closer.clone())));
336 Self::overlay_stack_push(closer);
337 }
338
339 /// Closes a modal that was opened via [`Router::modal_push`] when the user dismisses
340 /// it through the UI (close button, overlay click, confirm/cancel action)
341 /// rather than the system back gesture.
342 ///
343 /// Removes the entry matching the given visibility signal from the global
344 /// stack (by identity, not necessarily the top, so nested modals stay
345 /// consistent) and consumes one matching browser history entry via
346 /// `overlay_stack_close`, keeping the history count in sync so a subsequent back
347 /// gesture behaves correctly.
348 ///
349 /// # Arguments
350 ///
351 /// - `Signal<bool>` - The visibility signal identifying the modal to remove.
352 pub fn modal_close_via_ui(visible: Signal<bool>) {
353 let removed: bool = MODAL_STACK.with(|stack: &ModalStack| {
354 let mut entries: RefMut<'_, Vec<ModalStackEntry>> = stack.borrow_mut();
355 if let Some(index) = entries
356 .iter()
357 .rposition(|(signal, _): &ModalStackEntry| *signal == visible)
358 {
359 entries.remove(index);
360 true
361 } else {
362 false
363 }
364 });
365 if removed {
366 Self::overlay_stack_close();
367 }
368 }
369
370 /// Opens the given URL in the system default browser using `window.open`
371 /// with the `_system` target name.
372 ///
373 /// In a bridge WebView environment, the `_system` target instructs the
374 /// shell opener plugin to delegate the URL to the operating system's
375 /// default browser. In a regular browser, `window.open` falls back to
376 /// opening a new tab or window as usual.
377 ///
378 /// # Arguments
379 ///
380 /// - `U: AsRef<str>` - The URL to open.
381 pub fn open_system_browser<U>(url: U)
382 where
383 U: AsRef<str>,
384 {
385 let window_value: Window = window().expect("no global window exists");
386 if let Ok(open_fn) = Reflect::get(&window_value, &JsValue::from_str("open"))
387 .and_then(|value: JsValue| value.dyn_into::<Function>())
388 {
389 let _: Result<JsValue, JsValue> = open_fn.call2(
390 &window_value,
391 &JsValue::from_str(url.as_ref()),
392 &JsValue::from_str(SYSTEM_BROWSER_TARGET),
393 );
394 }
395 }
396
397 /// Creates a click event handler for external `<a>` links that opens
398 /// the URL in the system default browser.
399 ///
400 /// Calls `event.prevent_default()` to suppress the `<a>` element's
401 /// default navigation (which would open inside the WebView), then
402 /// delegates to `open_system_browser` so the URL is handled by the
403 /// operating system's default browser.
404 ///
405 /// # Arguments
406 ///
407 /// - `U: AsRef<str>` - The external URL to open on click.
408 ///
409 /// # Returns
410 ///
411 /// - `NativeEventHandler` - An event handler for click events.
412 pub fn external_link_handler<U>(url: U) -> NativeEventHandler
413 where
414 U: AsRef<str>,
415 {
416 let url_string: String = url.as_ref().to_string();
417 NativeEventHandler::create("click", move |event: Event| {
418 event.prevent_default();
419 Self::open_system_browser(&url_string);
420 })
421 }
422
423 /// Helper to close the drawer and navigate.
424 ///
425 /// Used internally by mobile nav items.
426 /// Closes the drawer via overlay back and schedules navigation to the target route
427 /// after the popstate event is processed.
428 ///
429 /// # Arguments
430 ///
431 /// - `Signal<bool>` - The drawer open signal.
432 /// - `T: AsRef<str>` - The target route.
433 pub fn close_drawer_and_navigate<T>(_drawer_open: Signal<bool>, target: T)
434 where
435 T: AsRef<str>,
436 {
437 Self::overlay_back(Some(target.as_ref().to_string()));
438 }
439}