1use std::cell::Cell;
2use std::marker::PhantomData;
3use std::rc::Rc;
4
5use sycamore::prelude::*;
6use wasm_bindgen::prelude::*;
7use web_sys::{Element, HtmlAnchorElement, HtmlBaseElement, KeyboardEvent};
8
9use crate::Route;
10
11pub trait Integration {
14 fn current_pathname(&self) -> String;
16
17 fn on_popstate(&self, f: Box<dyn FnMut()>);
19
20 fn click_handler(&self) -> Box<dyn Fn(web_sys::MouseEvent)>;
23}
24
25thread_local! {
26 static PATHNAME: Cell<Option<Signal<String>>> = const { Cell::new(None) };
27}
28
29#[derive(Default, Debug)]
33pub struct HistoryIntegration {
34 _internal: (),
37}
38
39impl HistoryIntegration {
40 pub fn new() -> Self {
42 Self::default()
43 }
44}
45
46impl Integration for HistoryIntegration {
47 fn current_pathname(&self) -> String {
48 window().location().pathname().unwrap_throw()
49 }
50
51 fn on_popstate(&self, f: Box<dyn FnMut()>) {
52 let closure = Closure::wrap(f);
53 window()
54 .add_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref())
55 .unwrap_throw();
56 closure.forget();
57 }
58
59 fn click_handler(&self) -> Box<dyn Fn(web_sys::MouseEvent)> {
60 Box::new(|ev| {
61 if let Some(a) = ev
62 .target()
63 .unwrap_throw()
64 .unchecked_into::<Element>()
65 .closest("a[href]")
66 .unwrap_throw()
67 {
68 let location = window().location();
69
70 let a = a.unchecked_into::<HtmlAnchorElement>();
71
72 if a.rel() == "external" {
74 return;
76 }
77
78 let origin = a.origin();
79 let a_pathname = a.pathname();
80 let hash = a.hash();
81
82 let meta_keys_pressed = meta_keys_pressed(ev.unchecked_ref::<KeyboardEvent>());
83 if !meta_keys_pressed && location.origin() == Ok(origin) {
84 if location.pathname().as_ref() != Ok(&a_pathname) {
85 ev.prevent_default();
87 PATHNAME.with(|pathname| {
88 let pathname = pathname.get().unwrap_throw();
89 let path = a_pathname
90 .strip_prefix(&base_pathname())
91 .unwrap_or(&a_pathname);
92 pathname.set(path.to_string());
93
94 let history = window().history().unwrap_throw();
96 history
97 .push_state_with_url(&JsValue::UNDEFINED, "", Some(&a_pathname))
98 .unwrap_throw();
99 window().scroll_to_with_x_and_y(0.0, 0.0);
100 });
101 } else if location.hash().as_ref() != Ok(&hash) {
102 } else {
104 ev.prevent_default();
106 }
107 }
108 }
109 })
110 }
111}
112
113fn base_pathname() -> String {
115 match document().query_selector("base[href]") {
116 Ok(Some(base)) => {
117 let base = base.unchecked_into::<HtmlBaseElement>().href();
118
119 let url = web_sys::Url::new(&base).unwrap_throw();
120 let mut pathname = url.pathname();
121 pathname.ends_with('/');
123 pathname.pop(); pathname
125 }
126 _ => "".to_string(),
127 }
128}
129
130#[derive(Props, Debug)]
132pub struct RouterProps<R, F, I>
133where
134 R: Route + 'static,
135 F: FnOnce(ReadSignal<R>) -> View + 'static,
136 I: Integration,
137{
138 view: F,
139 integration: I,
140 #[prop(default, setter(skip))]
141 _phantom: PhantomData<R>,
142}
143
144impl<R, F, I> RouterProps<R, F, I>
145where
146 R: Route + 'static,
147 F: FnOnce(ReadSignal<R>) -> View + 'static,
148 I: Integration,
149{
150 pub fn new(integration: I, view: F) -> Self {
152 Self {
153 view,
154 integration,
155 _phantom: PhantomData,
156 }
157 }
158}
159
160#[derive(Props, Debug)]
162pub struct RouterBaseProps<R, F, I>
163where
164 R: Route + 'static,
165 F: FnOnce(ReadSignal<R>) -> View + 'static,
166 I: Integration,
167{
168 view: F,
169 integration: I,
170 route: R,
171}
172
173impl<R, F, I> RouterBaseProps<R, F, I>
174where
175 R: Route + 'static,
176 F: FnOnce(ReadSignal<R>) -> View + 'static,
177 I: Integration,
178{
179 pub fn new(integration: I, view: F, route: R) -> Self {
181 Self {
182 view,
183 integration,
184 route,
185 }
186 }
187}
188
189#[component]
192pub fn Router<R, F, I>(props: RouterProps<R, F, I>) -> View
193where
194 R: Route + 'static,
195 F: FnOnce(ReadSignal<R>) -> View + 'static,
196 I: Integration + 'static,
197{
198 view! {
199 RouterBase(
200 view=props.view,
201 integration=props.integration,
202 route=R::default(),
204 )
205 }
206}
207
208#[component]
213pub fn RouterBase<R, F, I>(props: RouterBaseProps<R, F, I>) -> View
214where
215 R: Route + 'static,
216 F: FnOnce(ReadSignal<R>) -> View + 'static,
217 I: Integration + 'static,
218{
219 let RouterBaseProps {
220 view,
221 integration,
222 route,
223 } = props;
224 let integration = Rc::new(integration);
225 let base_pathname = base_pathname();
226
227 PATHNAME.with(|pathname| {
228 assert!(
229 pathname.get().is_none(),
230 "cannot have more than one Router component initialized"
231 );
232 let path = integration.current_pathname();
234 let path = path.strip_prefix(&base_pathname).unwrap_or(&path);
235 pathname.set(Some(create_signal(path.to_string())));
236 });
237 let pathname = PATHNAME.with(|p| p.get().unwrap_throw());
238
239 on_cleanup(|| PATHNAME.with(|pathname| pathname.set(None)));
241
242 integration.on_popstate(Box::new({
244 let integration = integration.clone();
245 move || {
246 let path = integration.current_pathname();
247 let path = path.strip_prefix(&base_pathname).unwrap_or(&path);
248 if pathname.with(|pathname| pathname != path) {
249 pathname.set(path.to_string());
250 }
251 }
252 }));
253 let route_signal = create_memo(move || pathname.with(|pathname| route.match_path(pathname)));
254 let view = view(route_signal);
255 let nodes = view.as_web_sys();
256 on_mount(move || {
257 for node in nodes {
258 let handler: Closure<dyn FnMut(web_sys::MouseEvent)> =
259 Closure::new(integration.click_handler());
260 node.add_event_listener_with_callback("click", handler.into_js_value().unchecked_ref())
261 .unwrap(); }
263 });
265 view
266}
267
268#[derive(Props, Debug)]
270pub struct StaticRouterProps<R, F>
271where
272 R: Route + 'static,
273 F: Fn(ReadSignal<R>) -> View + 'static,
274{
275 view: F,
276 route: R,
277}
278
279impl<R, F> StaticRouterProps<R, F>
280where
281 R: Route + 'static,
282 F: Fn(ReadSignal<R>) -> View + 'static,
283{
284 pub fn new(route: R, view: F) -> Self {
286 Self { view, route }
287 }
288}
289
290#[component]
295pub fn StaticRouter<R, F>(props: StaticRouterProps<R, F>) -> View
296where
297 R: Route + 'static,
298 F: Fn(ReadSignal<R>) -> View + 'static,
299{
300 view! {
301 StaticRouterBase(view=props.view, route=props.route)
302 }
303}
304
305#[component]
308fn StaticRouterBase<R, F>(props: StaticRouterProps<R, F>) -> View
309where
310 R: Route + 'static,
311 F: Fn(ReadSignal<R>) -> View + 'static,
312{
313 let StaticRouterProps { view, route } = props;
314
315 view(*create_signal(route))
316}
317
318pub fn navigate(url: &str) {
326 PATHNAME.with(|pathname| {
327 assert!(
328 pathname.get().is_some(),
329 "navigate can only be used with a Router"
330 );
331
332 let pathname = pathname.get().unwrap_throw();
333 let path = url.strip_prefix(&base_pathname()).unwrap_or(url);
334 pathname.set(path.to_string());
335
336 let history = window().history().unwrap_throw();
338 history
339 .push_state_with_url(&JsValue::UNDEFINED, "", Some(url))
340 .unwrap_throw();
341 window().scroll_to_with_x_and_y(0.0, 0.0);
342 });
343}
344
345pub fn navigate_replace(url: &str) {
354 PATHNAME.with(|pathname| {
355 assert!(
356 pathname.get().is_some(),
357 "navigate_replace can only be used with a Router"
358 );
359
360 let pathname = pathname.get().unwrap_throw();
361 let path = url.strip_prefix(&base_pathname()).unwrap_or(url);
362 pathname.set(path.to_string());
363
364 let history = window().history().unwrap_throw();
366 history
367 .replace_state_with_url(&JsValue::UNDEFINED, "", Some(url))
368 .unwrap_throw();
369 window().scroll_to_with_x_and_y(0.0, 0.0);
370 });
371}
372
373fn meta_keys_pressed(kb_event: &KeyboardEvent) -> bool {
374 kb_event.meta_key() || kb_event.ctrl_key() || kb_event.shift_key() || kb_event.alt_key()
375}
376
377#[cfg(test)]
378mod tests {
379 use sycamore::prelude::*;
380
381 use super::*;
382
383 #[test]
384 fn static_router() {
385 #[derive(Route, Clone, Copy)]
386 enum Routes {
387 #[to("/")]
388 Home,
389 #[to("/about")]
390 About,
391 #[not_found]
392 NotFound,
393 }
394
395 #[component(inline_props)]
396 fn Comp(path: String) -> View {
397 let route = Routes::match_route(
398 &Routes::Home,
402 &path
403 .split('/')
404 .filter(|s| !s.is_empty())
405 .collect::<Vec<_>>(),
406 );
407
408 view! {
409 StaticRouter(
410 route=route,
411 view=|route: ReadSignal<Routes>| {
412 match route.get() {
413 Routes::Home => view! {
414 "Home"
415 },
416 Routes::About => view! {
417 "About"
418 },
419 Routes::NotFound => view! {
420 "Not Found"
421 }
422 }
423 },
424 )
425 }
426 }
427
428 assert_eq!(
429 sycamore::render_to_string(|| view! { Comp(path="/".to_string()) }),
430 "Home"
431 );
432
433 assert_eq!(
434 sycamore::render_to_string(|| view! { Comp(path="/about".to_string()) }),
435 "About"
436 );
437
438 assert_eq!(
439 sycamore::render_to_string(|| view! { Comp(path="/404".to_string()) }),
440 "Not Found"
441 );
442 }
443}