use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavigationType {
Push,
Replace,
Pop,
Initial,
}
#[derive(Debug, Clone, Default)]
pub struct HistoryState {
pub path: String,
pub params: HashMap<String, String>,
pub route_name: Option<String>,
pub data: HashMap<String, String>,
pub scroll_position: Option<(i32, i32)>,
}
impl HistoryState {
pub fn new(path: impl Into<String>) -> Self {
Self {
path: path.into(),
params: HashMap::new(),
route_name: None,
data: HashMap::new(),
scroll_position: None,
}
}
pub fn with_params(mut self, params: HashMap<String, String>) -> Self {
self.params = params;
self
}
pub fn with_route_name(mut self, name: impl Into<String>) -> Self {
self.route_name = Some(name.into());
self
}
pub fn with_data(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.data.insert(key.into(), value.into());
self
}
pub fn with_scroll(mut self, x: i32, y: i32) -> Self {
self.scroll_position = Some((x, y));
self
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(&HistoryStateJson {
path: self.path.clone(),
params: self.params.clone(),
route_name: self.route_name.clone(),
data: self.data.clone(),
scroll_x: self.scroll_position.map(|(x, _)| x),
scroll_y: self.scroll_position.map(|(_, y)| y),
})
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
let parsed: HistoryStateJson = serde_json::from_str(json)?;
Ok(Self {
path: parsed.path,
params: parsed.params,
route_name: parsed.route_name,
data: parsed.data,
scroll_position: match (parsed.scroll_x, parsed.scroll_y) {
(Some(x), Some(y)) => Some((x, y)),
_ => None,
},
})
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct HistoryStateJson {
path: String,
params: HashMap<String, String>,
route_name: Option<String>,
data: HashMap<String, String>,
scroll_x: Option<i32>,
scroll_y: Option<i32>,
}
#[cfg(wasm)]
pub fn push_state(state: &HistoryState) -> Result<(), String> {
let window = web_sys::window().ok_or("Window not available")?;
let history = window.history().map_err(|_| "History not available")?;
let js_state = state_to_js_object(state)?;
history
.push_state_with_url(&js_state, "", Some(&state.path))
.map_err(|_| "Failed to push state".to_string())
}
#[cfg(native)]
pub fn push_state(_state: &HistoryState) -> Result<(), String> {
Ok(())
}
#[cfg(wasm)]
pub fn replace_state(state: &HistoryState) -> Result<(), String> {
let window = web_sys::window().ok_or("Window not available")?;
let history = window.history().map_err(|_| "History not available")?;
let js_state = state_to_js_object(state)?;
history
.replace_state_with_url(&js_state, "", Some(&state.path))
.map_err(|_| "Failed to replace state".to_string())
}
#[cfg(native)]
pub fn replace_state(_state: &HistoryState) -> Result<(), String> {
Ok(())
}
#[cfg(wasm)]
fn state_to_js_object(state: &HistoryState) -> Result<wasm_bindgen::JsValue, String> {
let wire = HistoryStateJson {
path: state.path.clone(),
params: state.params.clone(),
route_name: state.route_name.clone(),
data: state.data.clone(),
scroll_x: state.scroll_position.map(|(x, _)| x),
scroll_y: state.scroll_position.map(|(_, y)| y),
};
serde_wasm_bindgen::to_value(&wire).map_err(|e| format!("serialize state: {e}"))
}
#[cfg(wasm)]
fn state_from_js_value(value: wasm_bindgen::JsValue) -> Option<HistoryState> {
if value.is_null() || value.is_undefined() {
return None;
}
if let Some(s) = value.as_string() {
return HistoryState::from_json(&s).ok();
}
let wire: HistoryStateJson = serde_wasm_bindgen::from_value(value).ok()?;
Some(HistoryState {
path: wire.path,
params: wire.params,
route_name: wire.route_name,
data: wire.data,
scroll_position: match (wire.scroll_x, wire.scroll_y) {
(Some(x), Some(y)) => Some((x, y)),
_ => None,
},
})
}
#[cfg(wasm)]
pub fn go_back() -> Result<(), String> {
let window = web_sys::window().ok_or("Window not available")?;
let history = window.history().map_err(|_| "History not available")?;
history.back().map_err(|_| "Failed to go back".to_string())
}
#[cfg(native)]
pub fn go_back() -> Result<(), String> {
Ok(())
}
#[cfg(wasm)]
pub fn go_forward() -> Result<(), String> {
let window = web_sys::window().ok_or("Window not available")?;
let history = window.history().map_err(|_| "History not available")?;
history
.forward()
.map_err(|_| "Failed to go forward".to_string())
}
#[cfg(native)]
pub fn go_forward() -> Result<(), String> {
Ok(())
}
#[cfg(wasm)]
pub fn go(delta: i32) -> Result<(), String> {
let window = web_sys::window().ok_or("Window not available")?;
let history = window.history().map_err(|_| "History not available")?;
history
.go_with_delta(delta)
.map_err(|_| "Failed to navigate".to_string())
}
#[cfg(native)]
pub fn go(_delta: i32) -> Result<(), String> {
Ok(())
}
#[cfg(wasm)]
pub fn current_path() -> Result<String, String> {
let window = web_sys::window().ok_or("Window not available")?;
let location = window.location();
location
.pathname()
.map_err(|_| "Failed to get pathname".to_string())
}
#[cfg(native)]
pub fn current_path() -> Result<String, String> {
Ok("/".to_string())
}
#[cfg(wasm)]
pub fn current_search() -> Result<String, String> {
let window = web_sys::window().ok_or("Window not available")?;
let location = window.location();
location
.search()
.map_err(|_| "Failed to get search".to_string())
}
#[cfg(wasm)]
pub fn current_hash() -> Result<String, String> {
let window = web_sys::window().ok_or("Window not available")?;
let location = window.location();
location
.hash()
.map_err(|_| "Failed to get hash".to_string())
}
#[cfg(wasm)]
pub fn setup_popstate_listener<F>(
callback: F,
) -> Result<wasm_bindgen::closure::Closure<dyn FnMut(web_sys::PopStateEvent)>, wasm_bindgen::JsValue>
where
F: Fn(String, Option<HistoryState>) + 'static,
{
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window object"))?;
let closure =
wasm_bindgen::closure::Closure::wrap(Box::new(move |event: web_sys::PopStateEvent| {
let path = web_sys::window()
.and_then(|w| w.location().pathname().ok())
.unwrap_or_else(|| "/".to_string());
let state = state_from_js_value(event.state());
callback(path, state);
}) as Box<dyn FnMut(_)>);
window.add_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref())?;
Ok(closure)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_navigation_type() {
assert_ne!(NavigationType::Push, NavigationType::Replace);
assert_eq!(NavigationType::Pop, NavigationType::Pop);
}
#[test]
fn test_history_state_new() {
let state = HistoryState::new("/users/42/");
assert_eq!(state.path, "/users/42/");
assert!(state.params.is_empty());
assert!(state.route_name.is_none());
}
#[test]
fn test_history_state_builder() {
let mut params = HashMap::new();
params.insert("id".to_string(), "42".to_string());
let state = HistoryState::new("/users/42/")
.with_params(params)
.with_route_name("user_detail")
.with_data("ref", "home")
.with_scroll(0, 500);
assert_eq!(state.path, "/users/42/");
assert_eq!(state.params.get("id"), Some(&"42".to_string()));
assert_eq!(state.route_name, Some("user_detail".to_string()));
assert_eq!(state.data.get("ref"), Some(&"home".to_string()));
assert_eq!(state.scroll_position, Some((0, 500)));
}
#[test]
fn test_history_state_json_roundtrip() {
let mut params = HashMap::new();
params.insert("id".to_string(), "42".to_string());
let state = HistoryState::new("/users/42/")
.with_params(params)
.with_route_name("user_detail");
let json = state.to_json().unwrap();
let restored = HistoryState::from_json(&json).unwrap();
assert_eq!(restored.path, state.path);
assert_eq!(restored.params, state.params);
assert_eq!(restored.route_name, state.route_name);
}
#[test]
fn test_push_state_non_wasm() {
let state = HistoryState::new("/test/");
assert!(push_state(&state).is_ok());
}
#[test]
fn test_replace_state_non_wasm() {
let state = HistoryState::new("/test/");
assert!(replace_state(&state).is_ok());
}
#[test]
fn test_navigation_functions_non_wasm() {
assert!(go_back().is_ok());
assert!(go_forward().is_ok());
assert!(go(-1).is_ok());
}
#[test]
fn test_current_path_non_wasm() {
assert_eq!(current_path().unwrap(), "/");
}
}