app

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

commit e699c73c9b80439b71429789bd33ed58bd59a6f5
parent 8045278c5b40b550ccc410fcf7ace4d530181738
Author: triesap <tyson@radroots.org>
Date:   Fri,  6 Feb 2026 13:11:45 +0000

ui: add scroll context utilities

- add scroll context struct and container component
- add collapse progress and velocity helpers with tests
- wire scroll container to update reactive signals
- include web-sys dependency for scroll targets

Diffstat:
MCargo.lock | 1+
Mcrates/ui-components/Cargo.toml | 1+
Mcrates/ui-components/src/lib.rs | 7+++++++
Acrates/ui-components/src/scroll.rs | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 124 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1758,6 +1758,7 @@ dependencies = [ "leptos", "radroots-app-ui-core", "radroots-app-ui-primitives", + "web-sys", ] [[package]] diff --git a/crates/ui-components/Cargo.toml b/crates/ui-components/Cargo.toml @@ -14,3 +14,4 @@ radroots-app-ui-core = { path = "../ui-core" } radroots-app-ui-primitives = { path = "../ui-primitives" } leptos = { workspace = true, features = ["csr"] } icondata.workspace = true +web-sys.workspace = true diff --git a/crates/ui-components/src/lib.rs b/crates/ui-components/src/lib.rs @@ -10,6 +10,7 @@ mod separator; mod spinner; mod dialog; mod sheet; +mod scroll; pub use button::RadrootsAppUiButton; pub use button_layout::{ @@ -112,3 +113,9 @@ pub use sheet::{ RadrootsAppUiSheetTitle, RadrootsAppUiSheetTrigger, }; +pub use scroll::{ + radroots_app_ui_collapse_progress, + radroots_app_ui_scroll_velocity, + RadrootsAppUiScrollContainer, + RadrootsAppUiScrollContext, +}; diff --git a/crates/ui-components/src/scroll.rs b/crates/ui-components/src/scroll.rs @@ -0,0 +1,115 @@ +#![forbid(unsafe_code)] + +use leptos::ev::Event; +use leptos::prelude::*; +use web_sys::HtmlElement; + +const DEFAULT_COLLAPSE_RANGE: f64 = 120.0; + +#[derive(Clone, Copy, Debug)] +struct RadrootsAppUiScrollSample { + scroll_top: f64, + time_ms: f64, +} + +impl Default for RadrootsAppUiScrollSample { + fn default() -> Self { + Self { + scroll_top: 0.0, + time_ms: 0.0, + } + } +} + +#[derive(Clone, Debug)] +pub struct RadrootsAppUiScrollContext { + pub scroll_top: RwSignal<f64>, + pub scroll_velocity: RwSignal<f64>, + pub collapse_progress: RwSignal<f64>, +} + +impl RadrootsAppUiScrollContext { + pub fn new() -> Self { + Self { + scroll_top: RwSignal::new(0.0), + scroll_velocity: RwSignal::new(0.0), + collapse_progress: RwSignal::new(0.0), + } + } +} + +pub fn radroots_app_ui_collapse_progress(scroll_top: f64, collapse_range: f64) -> f64 { + if collapse_range <= 0.0 { + return 0.0; + } + (scroll_top / collapse_range).clamp(0.0, 1.0) +} + +pub fn radroots_app_ui_scroll_velocity(prev_top: f64, next_top: f64, dt_ms: f64) -> f64 { + if dt_ms <= 0.0 { + return 0.0; + } + (next_top - prev_top) / dt_ms * 1000.0 +} + +#[component] +pub fn RadrootsAppUiScrollContainer( + #[prop(optional)] id: Option<String>, + #[prop(optional)] class: Option<String>, + #[prop(optional)] collapse_range: Option<f64>, + #[prop(optional)] context: Option<RadrootsAppUiScrollContext>, + children: Children, +) -> impl IntoView { + let context = context.unwrap_or_else(RadrootsAppUiScrollContext::new); + provide_context(context.clone()); + let last_sample = RwSignal::new_local(RadrootsAppUiScrollSample::default()); + let collapse_range_value = collapse_range.unwrap_or(DEFAULT_COLLAPSE_RANGE); + let class_value = class.unwrap_or_else(|| "app-page app-page-scroll app-page-chrome".to_string()); + let on_scroll = move |ev: Event| { + let target = event_target::<HtmlElement>(&ev); + let scroll_top = target.scroll_top() as f64; + let time_ms = ev.time_stamp(); + let prev = last_sample.get_untracked(); + let velocity = radroots_app_ui_scroll_velocity(prev.scroll_top, scroll_top, time_ms - prev.time_ms); + last_sample.set(RadrootsAppUiScrollSample { scroll_top, time_ms }); + context.scroll_top.set(scroll_top); + context.scroll_velocity.set(velocity); + context + .collapse_progress + .set(radroots_app_ui_collapse_progress(scroll_top, collapse_range_value)); + }; + view! { + <div id=id class=class_value on:scroll=on_scroll> + {children()} + </div> + } +} + +#[cfg(test)] +mod tests { + use super::{radroots_app_ui_collapse_progress, radroots_app_ui_scroll_velocity}; + + #[test] + fn collapse_progress_clamps_range() { + assert_eq!(radroots_app_ui_collapse_progress(0.0, 120.0), 0.0); + assert_eq!(radroots_app_ui_collapse_progress(60.0, 120.0), 0.5); + assert_eq!(radroots_app_ui_collapse_progress(180.0, 120.0), 1.0); + assert_eq!(radroots_app_ui_collapse_progress(-10.0, 120.0), 0.0); + } + + #[test] + fn collapse_progress_handles_zero_range() { + assert_eq!(radroots_app_ui_collapse_progress(10.0, 0.0), 0.0); + } + + #[test] + fn scroll_velocity_uses_delta_per_second() { + assert_eq!(radroots_app_ui_scroll_velocity(0.0, 100.0, 1000.0), 100.0); + assert_eq!(radroots_app_ui_scroll_velocity(100.0, 0.0, 500.0), -200.0); + } + + #[test] + fn scroll_velocity_handles_zero_time() { + assert_eq!(radroots_app_ui_scroll_velocity(0.0, 100.0, 0.0), 0.0); + } +}