Skip to main content

rustolio_web/router/
mod.rs

1//
2// SPDX-License-Identifier: MPL-2.0
3//
4// Copyright (c) 2026 Tobias Binnewies. All rights reserved.
5//
6// This Source Code Form is subject to the terms of the Mozilla Public
7// License, v. 2.0. If a copy of the MPL was not distributed with this
8// file, You can obtain one at http://mozilla.org/MPL/2.0/.
9//
10
11use crate::prelude::*;
12
13pub trait Routes: Clone + PartialEq + 'static {
14    fn all_routes() -> impl Iterator<Item = (&'static str, usize)>;
15
16    fn resolve(route_match: Option<matchit::Match<&usize>>) -> Option<Self>;
17
18    fn element(self) -> Element;
19
20    fn href(&self) -> String;
21}
22
23#[derive(Debug, PartialEq, Eq)]
24pub struct Router<R> {
25    current_route: GlobalSignal<Option<R>>,
26}
27
28// Impl `Clone` and `Copy` manually to avoid requiring `T: Clone + Copy`
29impl<T> Clone for Router<T> {
30    fn clone(&self) -> Self {
31        *self
32    }
33}
34impl<T> Copy for Router<T> {}
35
36impl<R: Routes> Router<R> {
37    pub fn new() -> crate::Result<Self> {
38        let mut router = matchit::Router::new();
39
40        for (path, id) in R::all_routes() {
41            router.insert(path, id).expect("Failed to insert route");
42        }
43
44        let current_route = GlobalSignal::new({
45            let pathname = location()?
46                .pathname()
47                .context("Failed to get global pathname")?;
48            let route_match = router.at(&pathname).ok();
49            R::resolve(route_match)
50        });
51
52        // Adding listener for URL change
53        let listener = html::Listener::new_result("popstate", move |_: Event| {
54            let router = router.clone();
55            let pathname = location()?
56                .pathname()
57                .context("Failed to get global pathname")?;
58            let route_match = router.at(&pathname).ok();
59            current_route.set(R::resolve(route_match));
60            Ok(())
61        });
62        window()?.add_event_listener(&listener)?;
63
64        Ok(Self { current_route })
65    }
66
67    pub fn current_route(&self) -> Option<R> {
68        self.current_route.value()
69    }
70
71    pub fn component(&self) -> Elements {
72        let current_route = self.current_route;
73        elements! {
74            move || {
75                let route = current_route.value();
76                let Some(route) = route else {
77                    return Self::fallback();
78                };
79                route.element()
80            }
81        }
82    }
83
84    #[track_caller]
85    pub fn navigate(route: R) -> crate::Result<()> {
86        let path = route.href();
87
88        if location()?.pathname().unwrap() == path {
89            // If the path is already the current path, do nothing
90            return Ok(());
91        }
92
93        history()?
94            .push_state_with_url(&JsValue::NULL, "", Some(&path))
95            .context("Failed to push new url")?;
96        window()?
97            .dispatch_event(&Event::new("popstate").context("Failed to construct custom event")?)
98            .context("Failed to dispatch new url event")?;
99
100        Ok(())
101    }
102
103    fn fallback() -> Element {
104        panic!(
105            "{}",
106            r#"
107URL not found or a param could not be parsed.
108
109A fallback route could be implemented:
110
111#[derive(Debug, Clone, PartialEq, rustolio::Router)]
112enum AppRouter {
113    #[fallback]
114    AppFallbackPage,
115}
116
117#[component
118fn AppFallbackPage() -> Element {
119    h1! { "NOT FOUND" }
120}
121        "#
122        )
123    }
124}