app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit b4e98d3040f673137639465a115e2fe0a53a9009
parent 3cd170ae4c8b6474f46315d4ea21d1e59123b832
Author: triesap <triesap@radroots.dev>
Date:   Wed, 21 Jan 2026 20:18:22 +0000

ui: add scroll lock primitive

- add scroll lock state with guard-based acquire and release
- apply and restore body styles while preserving scroll offset
- add wasm and non-wasm lock handlers with error mapping
- add unit tests for lock count behavior

Diffstat:
Mcrates/ui-primitives/Cargo.toml | 2++
Mcrates/ui-primitives/src/lib.rs | 8++++++++
Acrates/ui-primitives/src/scroll_lock.rs | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 193 insertions(+), 0 deletions(-)

diff --git a/crates/ui-primitives/Cargo.toml b/crates/ui-primitives/Cargo.toml @@ -15,9 +15,11 @@ leptos = { workspace = true, features = ["csr"] } [target.'cfg(target_arch = "wasm32")'.dependencies] web-sys = { workspace = true, features = [ + "CssStyleDeclaration", "Document", "Element", "EventTarget", + "HtmlBodyElement", "FocusEvent", "HtmlElement", "KeyboardEvent", diff --git a/crates/ui-primitives/src/lib.rs b/crates/ui-primitives/src/lib.rs @@ -4,6 +4,7 @@ mod portal; mod presence; mod dismissable; mod focus; +mod scroll_lock; pub use portal::{RadrootsAppUiPortal, RadrootsAppUiPortalMount}; pub use presence::{ @@ -22,3 +23,10 @@ pub use focus::{ radroots_app_ui_focus_scope_selector, RadrootsAppUiFocusScope, }; +pub use scroll_lock::{ + radroots_app_ui_scroll_lock_acquire, + radroots_app_ui_scroll_lock_release, + RadrootsAppUiScrollLockError, + RadrootsAppUiScrollLockGuard, + RadrootsAppUiScrollLockResult, +}; diff --git a/crates/ui-primitives/src/scroll_lock.rs b/crates/ui-primitives/src/scroll_lock.rs @@ -0,0 +1,183 @@ +use std::sync::{Mutex, OnceLock}; + +#[cfg(target_arch = "wasm32")] +use web_sys::{window, CssStyleDeclaration}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsAppUiScrollLockError { + WindowUnavailable, + DocumentUnavailable, + BodyUnavailable, + StyleUnavailable, +} + +pub type RadrootsAppUiScrollLockResult<T> = Result<T, RadrootsAppUiScrollLockError>; + +#[derive(Debug)] +pub struct RadrootsAppUiScrollLockGuard { + active: bool, +} + +impl Drop for RadrootsAppUiScrollLockGuard { + fn drop(&mut self) { + if self.active { + let _ = radroots_app_ui_scroll_lock_release(); + self.active = false; + } + } +} + +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +#[derive(Debug, Default, Clone)] +struct ScrollLockSnapshot { + scroll_y: f64, + overflow: String, + position: String, + top: String, + width: String, +} + +#[derive(Debug, Default)] +struct ScrollLockState { + count: usize, + snapshot: Option<ScrollLockSnapshot>, +} + +static SCROLL_LOCK_STATE: OnceLock<Mutex<ScrollLockState>> = OnceLock::new(); + +fn scroll_lock_state() -> &'static Mutex<ScrollLockState> { + SCROLL_LOCK_STATE.get_or_init(|| Mutex::new(ScrollLockState::default())) +} + +pub fn radroots_app_ui_scroll_lock_acquire() -> RadrootsAppUiScrollLockResult<RadrootsAppUiScrollLockGuard> { + let mut state = scroll_lock_state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.count == 0 { + scroll_lock_apply(&mut state)?; + } + state.count += 1; + Ok(RadrootsAppUiScrollLockGuard { active: true }) +} + +pub fn radroots_app_ui_scroll_lock_release() -> RadrootsAppUiScrollLockResult<()> { + let mut state = scroll_lock_state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.count == 0 { + return Ok(()); + } + state.count = state.count.saturating_sub(1); + if state.count == 0 { + scroll_lock_restore(&mut state)?; + } + Ok(()) +} + +#[cfg(target_arch = "wasm32")] +fn scroll_lock_apply(state: &mut ScrollLockState) -> RadrootsAppUiScrollLockResult<()> { + let window = window().ok_or(RadrootsAppUiScrollLockError::WindowUnavailable)?; + let document = window + .document() + .ok_or(RadrootsAppUiScrollLockError::DocumentUnavailable)?; + let body = document + .body() + .ok_or(RadrootsAppUiScrollLockError::BodyUnavailable)?; + let style = body.style(); + let snapshot = ScrollLockSnapshot { + scroll_y: window.scroll_y(), + overflow: style_value(&style, "overflow")?, + position: style_value(&style, "position")?, + top: style_value(&style, "top")?, + width: style_value(&style, "width")?, + }; + style_set(&style, "overflow", "hidden")?; + style_set(&style, "position", "fixed")?; + style_set(&style, "width", "100%")?; + style_set(&style, "top", &format!("-{}px", snapshot.scroll_y))?; + state.snapshot = Some(snapshot); + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn scroll_lock_apply(state: &mut ScrollLockState) -> RadrootsAppUiScrollLockResult<()> { + state.snapshot = None; + Ok(()) +} + +#[cfg(target_arch = "wasm32")] +fn scroll_lock_restore(state: &mut ScrollLockState) -> RadrootsAppUiScrollLockResult<()> { + let Some(snapshot) = state.snapshot.take() else { + return Ok(()); + }; + let window = window().ok_or(RadrootsAppUiScrollLockError::WindowUnavailable)?; + let document = window + .document() + .ok_or(RadrootsAppUiScrollLockError::DocumentUnavailable)?; + let body = document + .body() + .ok_or(RadrootsAppUiScrollLockError::BodyUnavailable)?; + let style = body.style(); + style_set(&style, "overflow", &snapshot.overflow)?; + style_set(&style, "position", &snapshot.position)?; + style_set(&style, "top", &snapshot.top)?; + style_set(&style, "width", &snapshot.width)?; + window.scroll_to_with_x_and_y(0.0, snapshot.scroll_y); + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn scroll_lock_restore(state: &mut ScrollLockState) -> RadrootsAppUiScrollLockResult<()> { + state.snapshot = None; + Ok(()) +} + +#[cfg(target_arch = "wasm32")] +fn style_set( + style: &CssStyleDeclaration, + name: &str, + value: &str, +) -> RadrootsAppUiScrollLockResult<()> { + style + .set_property(name, value) + .map_err(|_| RadrootsAppUiScrollLockError::StyleUnavailable) +} + +#[cfg(target_arch = "wasm32")] +fn style_value(style: &CssStyleDeclaration, name: &str) -> RadrootsAppUiScrollLockResult<String> { + style + .get_property_value(name) + .map_err(|_| RadrootsAppUiScrollLockError::StyleUnavailable) +} + +#[cfg(test)] +fn scroll_lock_count_for_test() -> usize { + let state = scroll_lock_state() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.count +} + +#[cfg(test)] +mod tests { + use super::{ + radroots_app_ui_scroll_lock_acquire, + radroots_app_ui_scroll_lock_release, + scroll_lock_count_for_test, + }; + + #[test] + fn scroll_lock_guard_updates_count() { + assert_eq!(scroll_lock_count_for_test(), 0); + let guard = radroots_app_ui_scroll_lock_acquire().expect("lock"); + assert_eq!(scroll_lock_count_for_test(), 1); + drop(guard); + assert_eq!(scroll_lock_count_for_test(), 0); + } + + #[test] + fn scroll_lock_release_is_idempotent() { + let _ = radroots_app_ui_scroll_lock_release().expect("release"); + assert_eq!(scroll_lock_count_for_test(), 0); + } +}