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:
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()
}
}