app

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

commit 23a018160e76e3d88adb0c5c01a8d491506fe4ce
parent 922c5ac1be67c22e6151b00f65efad0bb3c37315
Author: triesap <tyson@radroots.org>
Date:   Fri,  6 Feb 2026 13:41:19 +0000

ui: wire nav header scroll modes

- add nav header bg and collapse mode enums
- render large and compact titles with collapse progress
- add blur and opaque background states with auto modes
- hide actions when background is active

Diffstat:
Mcrates/ui-components/assets/nav.css | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/ui-components/src/lib.rs | 6+++++-
Mcrates/ui-components/src/nav_header.rs | 130++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
3 files changed, 181 insertions(+), 22 deletions(-)

diff --git a/crates/ui-components/assets/nav.css b/crates/ui-components/assets/nav.css @@ -7,6 +7,26 @@ gap: 6px; padding: calc(var(--safe-t) + 6px) 16px 10px; background: transparent; + isolation: isolate; + --collapse: 0; +} + +.nav-header__background { + position: absolute; + inset: 0; + z-index: 0; + opacity: 0; + background: transparent; + transition: opacity var(--dur-2) var(--ease-ios); + pointer-events: none; +} + +.nav-header__content { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: 6px; } .nav-header__bar { @@ -16,7 +36,8 @@ min-height: var(--nav-header-height); } -.nav-header__title { +.nav-header__compact, +.nav-header__large { display: flex; align-items: center; justify-content: flex-start; @@ -33,9 +54,6 @@ .nav-header__title-text { font-family: var(--font-sansd); - font-size: 22px; - line-height: 28px; - font-weight: 700; letter-spacing: -0.01em; text-transform: capitalize; max-width: min(72vw, 420px); @@ -44,9 +62,50 @@ white-space: nowrap; } +.nav-header__title-large { + font-size: 34px; + line-height: 41px; + font-weight: 700; + transform-origin: left top; + transform: translateY(calc(var(--collapse) * -12px)) + scale(calc(1 - (var(--collapse) * 0.18))); + opacity: calc(1 - var(--collapse)); + transition: transform var(--dur-2) var(--ease-ios), opacity var(--dur-2) var(--ease-ios); +} + +.nav-header__title-compact { + font-size: 17px; + line-height: 22px; + font-weight: 600; + opacity: var(--collapse); + transform: translateY(calc((1 - var(--collapse)) * 8px)); + transition: transform var(--dur-2) var(--ease-ios), opacity var(--dur-2) var(--ease-ios); +} + .nav-header__actions { display: inline-flex; align-items: center; justify-content: flex-end; gap: 8px; } + +.nav-header[data-bg="opaque"][data-bg-state="active"] .nav-header__background, +.nav-header[data-bg="auto-opaque"][data-bg-state="active"] .nav-header__background { + opacity: 1; + background: var(--bg-app); +} + +.nav-header[data-bg="blur"][data-bg-state="active"] .nav-header__background, +.nav-header[data-bg="auto-blur"][data-bg-state="active"] .nav-header__background { + opacity: 1; + background: var(--material-regular); + backdrop-filter: blur(18px) saturate(180%); +} + +@media (prefers-reduced-motion: reduce) { + .nav-header__background, + .nav-header__title-large, + .nav-header__title-compact { + transition: none; + } +} diff --git a/crates/ui-components/src/lib.rs b/crates/ui-components/src/lib.rs @@ -120,4 +120,8 @@ pub use scroll::{ RadrootsAppUiScrollContainer, RadrootsAppUiScrollContext, }; -pub use nav_header::RadrootsAppUiNavHeader; +pub use nav_header::{ + RadrootsAppUiNavHeader, + RadrootsAppUiNavHeaderBgMode, + RadrootsAppUiNavHeaderCollapseMode, +}; diff --git a/crates/ui-components/src/nav_header.rs b/crates/ui-components/src/nav_header.rs @@ -3,42 +3,138 @@ use leptos::ev::MouseEvent; use leptos::prelude::*; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RadrootsAppUiNavHeaderBgMode { + Transparent, + Opaque, + Blur, + AutoOpaque, + AutoBlur, +} + +impl RadrootsAppUiNavHeaderBgMode { + pub const fn as_str(self) -> &'static str { + match self { + RadrootsAppUiNavHeaderBgMode::Transparent => "transparent", + RadrootsAppUiNavHeaderBgMode::Opaque => "opaque", + RadrootsAppUiNavHeaderBgMode::Blur => "blur", + RadrootsAppUiNavHeaderBgMode::AutoOpaque => "auto-opaque", + RadrootsAppUiNavHeaderBgMode::AutoBlur => "auto-blur", + } + } + + pub const fn is_auto(self) -> bool { + matches!( + self, + RadrootsAppUiNavHeaderBgMode::AutoOpaque | RadrootsAppUiNavHeaderBgMode::AutoBlur + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RadrootsAppUiNavHeaderCollapseMode { + None, + Scroll, +} + #[component] pub fn RadrootsAppUiNavHeader( label: String, #[prop(optional)] on_label_click: Option<Callback<MouseEvent>>, - #[prop(optional)] right: Option<AnyView>, + #[prop(optional)] bg_mode: Option<RadrootsAppUiNavHeaderBgMode>, + #[prop(optional)] collapse_mode: Option<RadrootsAppUiNavHeaderCollapseMode>, + #[prop(optional)] right: Option<ChildrenFn>, #[prop(optional)] id: Option<String>, #[prop(optional)] class: Option<String>, ) -> impl IntoView { + let bg_mode = bg_mode.unwrap_or(RadrootsAppUiNavHeaderBgMode::AutoBlur); + let collapse_mode = collapse_mode.unwrap_or(RadrootsAppUiNavHeaderCollapseMode::Scroll); let class_value = match class { Some(value) => format!("nav-header {value}"), None => "nav-header".to_string(), }; - let title_view: AnyView = if let Some(callback) = on_label_click { + let label_large = label.clone(); + let label_compact = label.clone(); + let title_large = nav_header_title_view( + label_large, + "nav-header__title-text nav-header__title-large", + on_label_click.clone(), + ); + let title_compact = nav_header_title_view( + label_compact, + "nav-header__title-text nav-header__title-compact", + on_label_click, + ); + let scroll_context = use_context::<crate::RadrootsAppUiScrollContext>(); + let collapse_progress = Signal::derive(move || match collapse_mode { + RadrootsAppUiNavHeaderCollapseMode::None => 0.0, + RadrootsAppUiNavHeaderCollapseMode::Scroll => scroll_context + .as_ref() + .map(|context| context.collapse_progress.get()) + .unwrap_or(0.0), + }); + let bg_active = Signal::derive(move || { + let scrolled = collapse_progress.get() > 0.02; + match bg_mode { + RadrootsAppUiNavHeaderBgMode::Transparent => false, + RadrootsAppUiNavHeaderBgMode::Opaque | RadrootsAppUiNavHeaderBgMode::Blur => true, + RadrootsAppUiNavHeaderBgMode::AutoOpaque | RadrootsAppUiNavHeaderBgMode::AutoBlur => { + scrolled + } + } + }); + let show_actions = Signal::derive(move || !bg_active.get()); + let right_slot = right; + view! { + <header + id=id + class=class_value + attr:data-bg=bg_mode.as_str() + attr:data-bg-state=move || if bg_active.get() { "active" } else { "idle" } + style=move || format!("--collapse: {:.3};", collapse_progress.get()) + > + <div class="nav-header__background" aria-hidden="true"></div> + <div class="nav-header__content"> + <div class="nav-header__bar"> + <div class="nav-header__compact"> + {title_compact} + </div> + {move || { + if show_actions.get() { + right_slot + .as_ref() + .map(|slot| slot()) + .map(|view| view! { <div class="nav-header__actions">{view}</div> }.into_any()) + .unwrap_or_else(|| view! { <></> }.into_any()) + } else { + view! { <></> }.into_any() + } + }} + </div> + <div class="nav-header__large"> + {title_large} + </div> + </div> + </header> + } +} + +fn nav_header_title_view( + label: String, + class: &str, + on_label_click: Option<Callback<MouseEvent>>, +) -> AnyView { + if let Some(callback) = on_label_click { let on_click = move |ev: MouseEvent| { callback.run(ev); }; view! { <button class="nav-header__title-button" on:click=on_click> - <span class="nav-header__title-text">{label}</span> + <span class=class>{label}</span> </button> } .into_any() } else { - view! { <div class="nav-header__title-text">{label}</div> }.into_any() - }; - view! { - <header id=id class=class_value> - <div class="nav-header__bar"> - <div class="nav-header__title"> - {title_view} - </div> - {right - .map(|view| view! { <div class="nav-header__actions">{view}</div> }.into_any()) - .unwrap_or_else(|| view! { <></> }.into_any()) - } - </div> - </header> + view! { <span class=class>{label}</span> }.into_any() } }