use std::cell::Cell;
use std::marker::PhantomData;
use std::rc::Rc;
use sycamore::prelude::*;
use wasm_bindgen::prelude::*;
use web_sys::{Element, HtmlAnchorElement, HtmlBaseElement, KeyboardEvent};
use crate::Route;
pub trait Integration {
fn current_pathname(&self) -> String;
fn on_popstate(&self, f: Box<dyn FnMut()>);
fn click_handler(&self) -> Box<dyn Fn(web_sys::MouseEvent)>;
}
thread_local! {
static PATHNAME: Cell<Option<Signal<String>>> = const { Cell::new(None) };
}
#[derive(Default, Debug)]
pub struct HistoryIntegration {
_internal: (),
}
impl HistoryIntegration {
pub fn new() -> Self {
Self::default()
}
}
impl Integration for HistoryIntegration {
fn current_pathname(&self) -> String {
window().location().pathname().unwrap_throw()
}
fn on_popstate(&self, f: Box<dyn FnMut()>) {
let closure = Closure::wrap(f);
window()
.add_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref())
.unwrap_throw();
closure.forget();
}
fn click_handler(&self) -> Box<dyn Fn(web_sys::MouseEvent)> {
Box::new(|ev| {
if let Some(a) = ev
.target()
.unwrap_throw()
.unchecked_into::<Element>()
.closest("a[href]")
.unwrap_throw()
{
let location = window().location();
let a = a.unchecked_into::<HtmlAnchorElement>();
if a.rel() == "external" {
return;
}
let origin = a.origin();
let a_pathname = a.pathname();
let hash = a.hash();
let meta_keys_pressed = meta_keys_pressed(ev.unchecked_ref::<KeyboardEvent>());
if !meta_keys_pressed && location.origin() == Ok(origin) {
if location.hash().as_ref() != Ok(&hash) {
} else if location.pathname().as_ref() != Ok(&a_pathname) {
ev.prevent_default();
PATHNAME.with(|pathname| {
let pathname = pathname.get().unwrap_throw();
let path = a_pathname
.strip_prefix(&base_pathname())
.unwrap_or(&a_pathname);
pathname.set(path.to_string());
let history = window().history().unwrap_throw();
history
.push_state_with_url(&JsValue::UNDEFINED, "", Some(&a_pathname))
.unwrap_throw();
window().scroll_to_with_x_and_y(0.0, 0.0);
});
} else {
ev.prevent_default();
}
}
}
})
}
}
fn base_pathname() -> String {
match document().query_selector("base[href]") {
Ok(Some(base)) => {
let base = base.unchecked_into::<HtmlBaseElement>().href();
let url = web_sys::Url::new(&base).unwrap_throw();
let mut pathname = url.pathname();
pathname.ends_with('/');
pathname.pop(); pathname
}
_ => "".to_string(),
}
}
#[derive(Props, Debug)]
pub struct RouterProps<R, F, I>
where
R: Route + 'static,
F: FnOnce(ReadSignal<R>) -> View + 'static,
I: Integration,
{
view: F,
integration: I,
#[prop(default, setter(skip))]
_phantom: PhantomData<R>,
}
impl<R, F, I> RouterProps<R, F, I>
where
R: Route + 'static,
F: FnOnce(ReadSignal<R>) -> View + 'static,
I: Integration,
{
pub fn new(integration: I, view: F) -> Self {
Self {
view,
integration,
_phantom: PhantomData,
}
}
}
#[derive(Props, Debug)]
pub struct RouterBaseProps<R, F, I>
where
R: Route + 'static,
F: FnOnce(ReadSignal<R>) -> View + 'static,
I: Integration,
{
view: F,
integration: I,
route: R,
}
impl<R, F, I> RouterBaseProps<R, F, I>
where
R: Route + 'static,
F: FnOnce(ReadSignal<R>) -> View + 'static,
I: Integration,
{
pub fn new(integration: I, view: F, route: R) -> Self {
Self {
view,
integration,
route,
}
}
}
#[component]
pub fn Router<R, F, I>(props: RouterProps<R, F, I>) -> View
where
R: Route + 'static,
F: FnOnce(ReadSignal<R>) -> View + 'static,
I: Integration + 'static,
{
view! {
RouterBase(
view=props.view,
integration=props.integration,
route=R::default(),
)
}
}
#[component]
pub fn RouterBase<R, F, I>(props: RouterBaseProps<R, F, I>) -> View
where
R: Route + 'static,
F: FnOnce(ReadSignal<R>) -> View + 'static,
I: Integration + 'static,
{
let RouterBaseProps {
view,
integration,
route,
} = props;
let integration = Rc::new(integration);
let base_pathname = base_pathname();
PATHNAME.with(|pathname| {
assert!(
pathname.get().is_none(),
"cannot have more than one Router component initialized"
);
let path = integration.current_pathname();
let path = path.strip_prefix(&base_pathname).unwrap_or(&path);
pathname.set(Some(create_signal(path.to_string())));
});
let pathname = PATHNAME.with(|p| p.get().unwrap_throw());
on_cleanup(|| PATHNAME.with(|pathname| pathname.set(None)));
integration.on_popstate(Box::new({
let integration = integration.clone();
move || {
let path = integration.current_pathname();
let path = path.strip_prefix(&base_pathname).unwrap_or(&path);
if pathname.with(|pathname| pathname != path) {
pathname.set(path.to_string());
}
}
}));
let route_signal = create_memo(move || pathname.with(|pathname| route.match_path(pathname)));
let view = view(route_signal);
let nodes = view.as_web_sys();
on_mount(move || {
for node in nodes {
let handler: Closure<dyn FnMut(web_sys::MouseEvent)> =
Closure::new(integration.click_handler());
node.add_event_listener_with_callback("click", handler.into_js_value().unchecked_ref())
.unwrap(); }
});
view
}
#[derive(Props, Debug)]
pub struct StaticRouterProps<R, F>
where
R: Route + 'static,
F: Fn(ReadSignal<R>) -> View + 'static,
{
view: F,
route: R,
}
impl<R, F> StaticRouterProps<R, F>
where
R: Route + 'static,
F: Fn(ReadSignal<R>) -> View + 'static,
{
pub fn new(route: R, view: F) -> Self {
Self { view, route }
}
}
#[component]
pub fn StaticRouter<R, F>(props: StaticRouterProps<R, F>) -> View
where
R: Route + 'static,
F: Fn(ReadSignal<R>) -> View + 'static,
{
view! {
StaticRouterBase(view=props.view, route=props.route)
}
}
#[component]
fn StaticRouterBase<R, F>(props: StaticRouterProps<R, F>) -> View
where
R: Route + 'static,
F: Fn(ReadSignal<R>) -> View + 'static,
{
let StaticRouterProps { view, route } = props;
view(*create_signal(route))
}
pub fn navigate(url: &str) {
PATHNAME.with(|pathname| {
assert!(
pathname.get().is_some(),
"navigate can only be used with a Router"
);
let pathname = pathname.get().unwrap_throw();
let path = url.strip_prefix(&base_pathname()).unwrap_or(url);
pathname.set(path.to_string());
let history = window().history().unwrap_throw();
history
.push_state_with_url(&JsValue::UNDEFINED, "", Some(url))
.unwrap_throw();
window().scroll_to_with_x_and_y(0.0, 0.0);
});
}
pub fn navigate_replace(url: &str) {
PATHNAME.with(|pathname| {
assert!(
pathname.get().is_some(),
"navigate_replace can only be used with a Router"
);
let pathname = pathname.get().unwrap_throw();
let path = url.strip_prefix(&base_pathname()).unwrap_or(url);
pathname.set(path.to_string());
let history = window().history().unwrap_throw();
history
.replace_state_with_url(&JsValue::UNDEFINED, "", Some(url))
.unwrap_throw();
window().scroll_to_with_x_and_y(0.0, 0.0);
});
}
fn meta_keys_pressed(kb_event: &KeyboardEvent) -> bool {
kb_event.meta_key() || kb_event.ctrl_key() || kb_event.shift_key() || kb_event.alt_key()
}
#[cfg(test)]
mod tests {
use sycamore::prelude::*;
use super::*;
#[test]
fn static_router() {
#[derive(Route, Clone, Copy)]
enum Routes {
#[to("/")]
Home,
#[to("/about")]
About,
#[not_found]
NotFound,
}
#[component(inline_props)]
fn Comp(path: String) -> View {
let route = Routes::match_route(
&Routes::Home,
&path
.split('/')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>(),
);
view! {
StaticRouter(
route=route,
view=|route: ReadSignal<Routes>| {
match route.get() {
Routes::Home => view! {
"Home"
},
Routes::About => view! {
"About"
},
Routes::NotFound => view! {
"Not Found"
}
}
},
)
}
}
assert_eq!(
sycamore::render_to_string(|| view! { Comp(path="/".to_string()) }),
"Home"
);
assert_eq!(
sycamore::render_to_string(|| view! { Comp(path="/about".to_string()) }),
"About"
);
assert_eq!(
sycamore::render_to_string(|| view! { Comp(path="/404".to_string()) }),
"Not Found"
);
}
}