1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
use crate::*;
/// Implementation of router functionality.
///
/// Provides methods for managing browser history, overlays, navigation,
/// and scroll behavior.
impl Router {
/// Watches the route signal and scrolls the `<main>` content container
/// back to the top whenever the route changes.
///
/// On each route change, queries the document for the first `<main>`
/// element and resets its `scrollTop` to zero. The sidebar scroll
/// position is preserved natively since the `<nav>` element is never
/// destroyed during route transitions.
///
/// # Arguments
///
/// - `Signal<String>` - The reactive signal holding the current route path.
pub fn use_scroll_to_top(route_signal: Signal<String>) {
watch!(route_signal, |_: String| {
let window_value: Window = window().expect("no global window exists");
let document_value: Document = window_value.document().expect("should have a document");
if let Some(main_element) = document_value.query_selector("main").ok().flatten() {
let html_element: HtmlElement = main_element.unchecked_into();
html_element.set_scroll_top(0);
}
});
}
/// Subscribes to browser `hashchange` events and updates the given signal.
///
/// Registers a global event listener on `window` that reads the current
/// route on every hash change and writes it into the provided signal.
/// The listener is automatically removed when the hook context is cleared.
///
/// Increments `WINDOW_EVENT_DEPTH` before dispatching and decrements it
/// after, so that any code that checks re-entrancy can detect that it is
/// running within a window event handler context.
///
/// Note: `navigate()` always defers `set_hash()` to a microtask, so by the
/// time the `hashchange` fires, all caller frames have already unwound and
/// there is no risk of recursive Closure invocation. The handler only needs
/// to update the route signal with the current URL hash value.
///
/// # Arguments
///
/// - `Signal<String>` - The reactive signal that holds the current route and will be updated on each hash change.
pub fn use_hash_change(route_signal: Signal<String>) {
App::use_window_event("hashchange", move || {
WINDOW_EVENT_DEPTH.with(|depth: &Cell<usize>| depth.set(depth.get() + 1));
route_signal.set(Self::current_route());
WINDOW_EVENT_DEPTH.with(|depth: &Cell<usize>| depth.set(depth.get() - 1));
});
}
/// Manages browser history for all overlays (modals, panels, drawers) so that
/// the back button closes the most recently opened overlay instead of navigating away.
///
/// Uses a unified `OVERLAY_STACK` that records every overlay in the order it was opened.
/// A `popstate` listener pops the topmost entry and invokes its close callback, so
/// overlays close in reverse opening order regardless of type.
///
/// Before consulting the overlay stack, the listener iterates over all registered
/// `popstate` guards (see [`register_popstate_guard`]). The first guard that returns
/// `true` consumes the event, preventing the overlay stack and normal navigation
/// from processing it.
///
/// # Arguments
///
/// - `Signal<bool>` - The reactive signal controlling the nav drawer visibility.
/// - `Signal<bool>` - The reactive signal tracking whether the viewport is mobile-sized.
pub fn use_overlay_history(drawer_open: Signal<bool>, mobile_signal: Signal<bool>) {
let was_drawer_open: Signal<bool> = App::use_signal(|| false);
watch!(drawer_open, |is_open: bool| {
let previous: bool = was_drawer_open.get();
if is_open && !previous && mobile_signal.get() {
let closer: Rc<dyn Fn()> = Rc::new(move || {
drawer_open.set(false);
});
Self::overlay_stack_push(closer);
}
was_drawer_open.set(is_open);
});
App::use_window_event("popstate", move || {
WINDOW_EVENT_DEPTH.with(|depth: &Cell<usize>| depth.set(depth.get() + 1));
let consumed: bool = POPSTATE_GUARDS.with(|guards: &PopstateGuardList| {
guards
.borrow()
.iter()
.any(|entry: &PopstateGuardEntry| entry.1())
});
if consumed {
WINDOW_EVENT_DEPTH.with(|depth: &Cell<usize>| depth.set(depth.get() - 1));
return;
}
if BACK_PENDING.with(|flag: &Cell<bool>| flag.get()) {
BACK_PENDING.with(|flag: &Cell<bool>| flag.set(false));
let pending_route: Option<String> =
NAVIGATE_AFTER_BACK.with(|cell: &Cell<Option<String>>| cell.take());
if let Some(closer) = Self::overlay_stack_pop() {
closer();
}
if let Some(route) = pending_route {
Self::navigate(&route);
}
WINDOW_EVENT_DEPTH.with(|depth: &Cell<usize>| depth.set(depth.get() - 1));
return;
}
if let Some(closer) = Self::overlay_stack_pop() {
closer();
WINDOW_EVENT_DEPTH.with(|depth: &Cell<usize>| depth.set(depth.get() - 1));
return;
}
WINDOW_EVENT_DEPTH.with(|depth: &Cell<usize>| depth.set(depth.get() - 1));
});
}
/// Watches the drawer open signal and scrolls the mobile navigation drawer
/// to make the currently active navigation item visible when the drawer opens.
///
/// Uses nested `requestAnimationFrame` to defer the scroll until after the
/// framework has completed its DOM update cycle. The first `requestAnimationFrame`
/// fires after the framework's own `requestAnimationFrame`-based render pass,
/// and the second one fires after the browser has laid out the new DOM.
/// Locates the scrollable `c-nav-items-scroll` container and the active nav
/// item within the drawer, then sets `scrollTop` so the active item appears
/// near the vertical center of the container.
///
/// # Arguments
///
/// - `Signal<bool>` - The reactive signal controlling the mobile nav drawer visibility.
pub fn use_scroll_drawer_to_active(drawer_open: Signal<bool>) {
watch!(drawer_open, |is_open: bool| {
if !is_open {
return;
}
let window_value: Window = window().expect("no global window exists");
let outer_raf: Window = window_value.clone();
let inner_raf_clone: Window = window_value.clone();
let inner_doc_clone: Window = window_value.clone();
let outer_closure: Closure<dyn FnMut()> = Closure::wrap(Box::new(move || {
let inner_raf: Window = inner_raf_clone.clone();
let inner_doc: Window = inner_doc_clone.clone();
let inner_closure: Closure<dyn FnMut()> = Closure::wrap(Box::new(move || {
let document_value: Document =
inner_doc.document().expect("should have a document");
let Some(drawer_nav) = document_value
.query_selector(DRAWER_NAV_SELECTOR)
.ok()
.flatten()
else {
return;
};
let Some(active_element) = drawer_nav
.query_selector(ACTIVE_NAV_ITEM_SELECTOR)
.ok()
.flatten()
else {
return;
};
let active_html_element: HtmlElement = active_element.unchecked_into();
let Some(scroll_container) = drawer_nav
.query_selector(NAV_ITEMS_SCROLL_SELECTOR)
.ok()
.flatten()
else {
return;
};
let scroll_html_element: HtmlElement = scroll_container.unchecked_into();
let active_rect: DomRect = active_html_element.get_bounding_client_rect();
let container_rect: DomRect = scroll_html_element.get_bounding_client_rect();
let offset_from_container_top: f64 = active_rect.top() - container_rect.top();
let current_scroll_top: i32 = scroll_html_element.scroll_top();
let container_height: f64 = container_rect.height();
let active_height: f64 = active_rect.height();
let target_scroll_top: f64 = current_scroll_top as f64
+ offset_from_container_top
- (container_height - active_height) / 2.0;
scroll_html_element.set_scroll_top(target_scroll_top.max(0.0) as i32);
}));
let _ = inner_raf.request_animation_frame(inner_closure.as_ref().unchecked_ref());
inner_closure.forget();
}));
let _ = outer_raf.request_animation_frame(outer_closure.as_ref().unchecked_ref());
outer_closure.forget();
});
}
/// Registers a `popstate` guard callback that is invoked on every `popstate`
/// event before the overlay stack is consulted.
///
/// Guards are called in registration order. The first guard that returns `true`
/// consumes the `popstate` event, preventing the overlay stack and normal
/// navigation from processing it. This allows external modules (e.g. native
/// fullscreen, canvas fullscreen) to intercept the system back gesture without
/// registering their own independent `popstate` listener.
///
/// Returns a guard ID that can be passed to [`Router::unregister_popstate_guard`] to
/// remove the guard when it is no longer needed.
///
/// # Arguments
///
/// - `Rc<dyn Fn() -> bool>` - The guard callback. Return `true` to consume the
/// `popstate` event, `false` to let subsequent guards or the overlay stack
/// handle it.
///
/// # Returns
///
/// - `usize` - A unique guard ID for later unregistration.
pub fn register_popstate_guard(guard: Rc<dyn Fn() -> bool>) -> usize {
NEXT_POPSTATE_GUARD_ID.with(|counter: &Cell<usize>| {
let id: usize = counter.get();
counter.set(id + 1);
POPSTATE_GUARDS.with(|guards: &PopstateGuardList| {
guards.borrow_mut().push((id, guard));
});
id
})
}
/// Removes a previously registered `popstate` guard by its ID.
///
/// After removal, the guard will no longer be invoked on `popstate` events.
///
/// # Arguments
///
/// - `usize` - The guard ID returned by [`Router::register_popstate_guard`].
#[allow(dead_code)]
pub(crate) fn unregister_popstate_guard(id: usize) {
POPSTATE_GUARDS.with(|guards: &PopstateGuardList| {
guards
.borrow_mut()
.retain(|entry: &PopstateGuardEntry| entry.0 != id);
});
}
/// Pushes a browser history entry for an overlay that is about to open.
///
/// Call this when an overlay (vconsole panel) opens so that the browser
/// back button will close the overlay instead of navigating away.
pub fn overlay_push_state() {
let window: Window = window().expect("no global window exists");
let history: History = window.history().expect("no history object exists");
let _ = history.push_state(&JsValue::NULL, "");
}
/// Performs a programmatic `history.back()` to consume the overlay's
/// history entry, optionally scheduling a navigation to run after the
/// `popstate` event fires.
///
/// # Arguments
///
/// - `Option<String>` - An optional route to navigate to after the back completes.
pub fn overlay_back(navigate_target: Option<String>) {
BACK_PENDING.with(|flag: &Cell<bool>| flag.set(true));
if let Some(ref route) = navigate_target {
NAVIGATE_AFTER_BACK.with(|cell: &Cell<Option<String>>| cell.set(Some(route.clone())));
}
let window: Window = window().expect("no global window exists");
let history: History = window.history().expect("no history object exists");
let _ = history.back();
}
/// Pushes an overlay close callback onto the unified `OVERLAY_STACK` and
/// pushes a browser history entry so the back button dismisses it.
///
/// Call this whenever any overlay (modal, panel, or drawer) opens.
///
/// # Arguments
///
/// - `Rc<dyn Fn()>` - The callback that closes the overlay (e.g., sets its visibility signal to `false`).
pub(crate) fn overlay_stack_push(closer: Rc<dyn Fn()>) {
OVERLAY_STACK.with(|stack: &OverlayStack| {
stack.borrow_mut().push(OverlayEntry { closer });
});
Self::overlay_push_state();
}
/// Pops the most recently opened overlay from the unified `OVERLAY_STACK` and
/// returns its close callback, without invoking it.
///
/// Also synchronizes the `MODAL_STACK` by removing the matching entry if the
/// popped overlay is a modal.
///
/// # Returns
///
/// - `Option<Rc<dyn Fn()>>` - The topmost overlay's close callback, or `None` if no overlay is open.
pub(crate) fn overlay_stack_pop() -> Option<Rc<dyn Fn()>> {
let closer: Option<Rc<dyn Fn()>> = OVERLAY_STACK.with(|stack: &OverlayStack| {
stack
.borrow_mut()
.pop()
.map(|entry: OverlayEntry| entry.closer)
});
if let Some(ref c) = closer {
MODAL_STACK.with(|stack: &ModalStack| {
let mut entries = stack.borrow_mut();
if let Some(index) = entries
.iter()
.rposition(|(_, closer): &ModalStackEntry| Rc::ptr_eq(closer, c))
{
entries.remove(index);
}
});
}
closer
}
/// Closes the most recently opened overlay via the UI and consumes one
/// browser history entry.
///
/// Pops the top entry from `OVERLAY_STACK` and calls `overlay_back` so that
/// the history count stays in sync. Use this when the user dismisses an overlay
/// through a close button, overlay click, or confirm/cancel action.
pub fn overlay_stack_close() {
OVERLAY_STACK.with(|stack: &OverlayStack| {
stack.borrow_mut().pop();
});
Self::overlay_back(None);
}
/// Registers an open modal by pushing it onto the global modal stack and
/// adding a browser history entry, enabling nested modals.
///
/// The stack is ordered with the most recently opened modal on top. When the
/// user triggers a system back gesture (or presses the browser back button),
/// the `popstate` handler pops the topmost entry from `OVERLAY_STACK` and
/// invokes its close callback, so the most recently opened overlay is dismissed
/// first instead of navigating to the previous page.
///
/// If the given visibility signal is already on the stack, this is a no-op so
/// that re-opening an already-open modal does not create duplicate stack or
/// history entries.
///
/// # Arguments
///
/// - `Signal<bool>` - The modal's visibility signal, used as a stable identity for later removal.
/// - `Rc<dyn Fn()>` - The callback that closes the modal (e.g., sets the visibility signal to `false`).
pub fn modal_push(visible: Signal<bool>, closer: Rc<dyn Fn()>) {
let already_open: bool = MODAL_STACK.with(|stack: &ModalStack| {
stack
.borrow()
.iter()
.any(|(signal, _): &ModalStackEntry| *signal == visible)
});
if already_open {
return;
}
MODAL_STACK.with(|stack: &ModalStack| stack.borrow_mut().push((visible, closer.clone())));
Self::overlay_stack_push(closer);
}
/// Closes a modal that was opened via [`Router::modal_push`] when the user dismisses
/// it through the UI (close button, overlay click, confirm/cancel action)
/// rather than the system back gesture.
///
/// Removes the entry matching the given visibility signal from the global
/// stack (by identity, not necessarily the top, so nested modals stay
/// consistent) and consumes one matching browser history entry via
/// `overlay_stack_close`, keeping the history count in sync so a subsequent back
/// gesture behaves correctly.
///
/// # Arguments
///
/// - `Signal<bool>` - The visibility signal identifying the modal to remove.
pub fn modal_close_via_ui(visible: Signal<bool>) {
let removed: bool = MODAL_STACK.with(|stack: &ModalStack| {
let mut entries = stack.borrow_mut();
if let Some(index) = entries
.iter()
.rposition(|(signal, _): &ModalStackEntry| *signal == visible)
{
entries.remove(index);
true
} else {
false
}
});
if removed {
Self::overlay_stack_close();
}
}
/// Opens the given URL in the system default browser using `window.open`
/// with the `_system` target name.
///
/// In a bridge WebView environment, the `_system` target instructs the
/// shell opener plugin to delegate the URL to the operating system's
/// default browser. In a regular browser, `window.open` falls back to
/// opening a new tab or window as usual.
///
/// # Arguments
///
/// - `U: AsRef<str>` - The URL to open.
pub fn open_system_browser<U>(url: U)
where
U: AsRef<str>,
{
let window_value: Window = window().expect("no global window exists");
if let Ok(open_fn) = Reflect::get(&window_value, &JsValue::from_str("open"))
.and_then(|value: JsValue| value.dyn_into::<Function>())
{
let _ = open_fn.call2(
&window_value,
&JsValue::from_str(url.as_ref()),
&JsValue::from_str(SYSTEM_BROWSER_TARGET),
);
}
}
/// Creates a click event handler for external `<a>` links that opens
/// the URL in the system default browser.
///
/// Calls `event.prevent_default()` to suppress the `<a>` element's
/// default navigation (which would open inside the WebView), then
/// delegates to `open_system_browser` so the URL is handled by the
/// operating system's default browser.
///
/// # Arguments
///
/// - `U: AsRef<str>` - The external URL to open on click.
///
/// # Returns
///
/// - `NativeEventHandler` - An event handler for click events.
pub fn external_link_handler<U>(url: U) -> NativeEventHandler
where
U: AsRef<str>,
{
let url_string: String = url.as_ref().to_string();
NativeEventHandler::create("click", move |event: Event| {
event.prevent_default();
Self::open_system_browser(&url_string);
})
}
/// Helper to close the drawer and navigate.
///
/// Used internally by mobile nav items.
/// Closes the drawer via overlay back and schedules navigation to the target route
/// after the popstate event is processed.
///
/// # Arguments
///
/// - `Signal<bool>` - The drawer open signal.
/// - `T: AsRef<str>` - The target route.
pub fn close_drawer_and_navigate<T>(_drawer_open: Signal<bool>, target: T)
where
T: AsRef<str>,
{
Self::overlay_back(Some(target.as_ref().to_string()));
}
}