app

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

commit da9067f22962ea9fed09c33249250ea77b65084e
parent a9b2462795521e0f4151fe9926b8539afbfd3e3d
Author: triesap <tyson@radroots.org>
Date:   Fri,  6 Feb 2026 14:34:03 +0000

ui: migrate pages to chrome layout

- wrap routes with app page chrome and nav tabs
- adjust app page padding and body layout for header + tabs
- extend nav tabs styling and icon keys for home/test
- refactor logs actions to avoid rc in children

Diffstat:
Mapp/app.css | 9++++++++-
Mapp/src/app.rs | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mapp/src/logs.rs | 291+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mapp/src/settings.rs | 13++++---------
Mapp/src/settings_status.rs | 72++++++++++++++++++++++++++++++++++++------------------------------------
Mapp/src/ui_demo.rs | 9+++------
Mcrates/ui-components/assets/nav_tabs.css | 15+++++++++++++++
Mcrates/ui-components/src/icon.rs | 22+++++++++++++++++++++-
Mcrates/ui-components/src/nav_header.rs | 12++++++------
Mcrates/ui-components/src/scroll.rs | 11++++++-----
10 files changed, 384 insertions(+), 243 deletions(-)

diff --git a/app/app.css b/app/app.css @@ -171,10 +171,17 @@ } .app-page-chrome { - padding-top: calc(var(--nav-header-height) + var(--safe-t)); padding-bottom: calc(var(--nav-tabs-height) + var(--safe-b)); } + .app-page-body { + padding: 0 16px 24px; + } + + .app-page-shell { + display: contents; + } + .app-view { will-change: opacity; position: relative; diff --git a/app/src/app.rs b/app/src/app.rs @@ -2,7 +2,7 @@ use leptos::ev::{KeyboardEvent, MouseEvent}; use leptos::prelude::*; use leptos::task::spawn_local; use leptos_router::components::{A, Route, Router, Routes}; -use leptos_router::hooks::use_navigate; +use leptos_router::hooks::{use_location, use_navigate}; use leptos_router::path; use web_sys::HtmlElement; @@ -19,6 +19,12 @@ use radroots_app_ui_components::{ RadrootsAppUiButtonLayoutPair, RadrootsAppUiIcon, RadrootsAppUiIconKey, + RadrootsAppUiNavHeader, + RadrootsAppUiNavHeaderBgMode, + RadrootsAppUiNavHeaderCollapseMode, + RadrootsAppUiNavTabs, + RadrootsAppUiScrollContainer, + RadrootsAppUiScrollContext, RadrootsAppUiSpinner, }; use uuid::Uuid; @@ -95,6 +101,84 @@ impl HomeView { } } +#[component] +pub(crate) fn AppPageChrome( + title: String, + #[prop(optional)] header_right: Option<ChildrenFn>, + #[prop(optional)] show_tabs: Option<bool>, + #[prop(optional)] bg_mode: Option<RadrootsAppUiNavHeaderBgMode>, + #[prop(optional)] collapse_mode: Option<RadrootsAppUiNavHeaderCollapseMode>, + children: Children, +) -> impl IntoView { + let scroll_context = RadrootsAppUiScrollContext::new(); + provide_context(scroll_context.clone()); + let show_tabs = show_tabs.unwrap_or(true); + let bg_mode = bg_mode.unwrap_or(RadrootsAppUiNavHeaderBgMode::AutoBlur); + let collapse_mode = collapse_mode.unwrap_or(RadrootsAppUiNavHeaderCollapseMode::Scroll); + let location = use_location(); + let is_home = move || location.pathname.get() == "/"; + let is_test = move || location.pathname.get().starts_with("/test"); + let is_settings = move || location.pathname.get().starts_with("/settings"); + view! { + <div class="app-page-shell"> + <RadrootsAppUiScrollContainer + id=None + classes=Some("app-page app-page-scroll app-page-chrome".to_string()) + collapse_range=None + context=Some(scroll_context.clone()) + > + <RadrootsAppUiNavHeader + label=title + on_label_click=None + bg_mode=Some(bg_mode) + collapse_mode=Some(collapse_mode) + right=header_right + id=None + class=None + /> + <div class="app-page-body"> + {children()} + </div> + </RadrootsAppUiScrollContainer> + {move || { + if show_tabs { + view! { + <RadrootsAppUiNavTabs> + <A + href="/" + attr:class="nav-tabs__item" + attr:data-active=move || if is_home() { "true" } else { "false" } + attr:aria-label=t!("app.nav.home") + > + <RadrootsAppUiIcon key=RadrootsAppUiIconKey::Home size=22 /> + </A> + <A + href="/test" + attr:class="nav-tabs__item" + attr:data-active=move || if is_test() { "true" } else { "false" } + attr:aria-label=t!("app.nav.ui") + > + <RadrootsAppUiIcon key=RadrootsAppUiIconKey::Beaker size=22 /> + </A> + <A + href="/settings" + attr:class="nav-tabs__item" + attr:data-active=move || if is_settings() { "true" } else { "false" } + attr:aria-label=t!("app.nav.settings") + > + <RadrootsAppUiIcon key=RadrootsAppUiIconKey::Settings size=22 /> + </A> + </RadrootsAppUiNavTabs> + } + .into_any() + } else { + view! { <></> }.into_any() + } + }} + </div> + } +} + fn error_label(key: &str) -> Option<String> { let label = match key { "error.app.init.idb" => t!("error.app.init.idb"), @@ -1435,43 +1519,48 @@ fn HomePage() -> impl IntoView { let is_activity = move || matches!(current_view.get(), HomeView::Activity); let is_profile = move || matches!(current_view.get(), HomeView::Profile); view! { - <main id="app-home" class="app-page app-page-fixed flex flex-col items-center justify-start gap-6 pt-10"> - <div - id="app-home-toggle" - class="home-toggle" - class:home-toggle--left=is_activity - class:home-toggle--right=is_profile - role="tablist" - aria-label="Home view" + <AppPageChrome title=t!("app.nav.home")> + <section + id="app-home" + class="flex flex-col items-center justify-start gap-6 pt-6" > - <div class="home-toggle__indicator" aria-hidden="true"></div> - <button - id="app-home-toggle-activity" - class="home-toggle__button" - class:is-active=is_activity - type="button" - role="tab" - aria-selected=move || if is_activity() { "true" } else { "false" } - on:click=move |_| current_view.set(HomeView::Activity) - > - {"Activity"} - </button> - <button - id="app-home-toggle-profile" - class="home-toggle__button" - class:is-active=is_profile - type="button" - role="tab" - aria-selected=move || if is_profile() { "true" } else { "false" } - on:click=move |_| current_view.set(HomeView::Profile) + <div + id="app-home-toggle" + class="home-toggle" + class:home-toggle--left=is_activity + class:home-toggle--right=is_profile + role="tablist" + aria-label="Home view" > - {"Profile"} - </button> - </div> - <p id="app-home-view-title" class="home-toggle__title"> - {move || current_view.get().label()} - </p> - </main> + <div class="home-toggle__indicator" aria-hidden="true"></div> + <button + id="app-home-toggle-activity" + class="home-toggle__button" + class:is-active=is_activity + type="button" + role="tab" + aria-selected=move || if is_activity() { "true" } else { "false" } + on:click=move |_| current_view.set(HomeView::Activity) + > + {"Activity"} + </button> + <button + id="app-home-toggle-profile" + class="home-toggle__button" + class:is-active=is_profile + type="button" + role="tab" + aria-selected=move || if is_profile() { "true" } else { "false" } + on:click=move |_| current_view.set(HomeView::Profile) + > + {"Profile"} + </button> + </div> + <p id="app-home-view-title" class="home-toggle__title"> + {move || current_view.get().label()} + </p> + </section> + </AppPageChrome> } } @@ -1618,18 +1707,6 @@ fn AppShell() -> impl IntoView { if gate.show_app { return view! { <div id="app-shell"> - <div - id="app-topbar" - class="flex items-center justify-end px-4 pt-4" - > - <A - href="/settings" - attr:aria-label=t!("app.nav.settings") - attr:class="inline-flex h-9 w-9 items-center justify-center rounded-full" - > - <RadrootsAppUiIcon key=RadrootsAppUiIconKey::Settings size=20 /> - </A> - </div> <Routes fallback=|| view! { <main id="app-not-found" class="app-page app-page-fixed"> diff --git a/app/src/logs.rs b/app/src/logs.rs @@ -12,18 +12,20 @@ use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; #[cfg(target_arch = "wasm32")] use wasm_bindgen::JsValue; -use std::rc::Rc; use radroots_app_core::datastore::RadrootsClientWebDatastore; use radroots_app_core::idb::IDB_CONFIG_LOGS; use crate::{ + app::AppPageChrome, app_context, app_log_buffer_flush_no_prune, app_log_entries_clear, app_log_entries_dump, app_log_entries_load, app_log_metadata, + RadrootsAppContext, + RadrootsAppKeyMapConfig, RadrootsAppLogEntry, RadrootsAppLogLevel, }; @@ -52,6 +54,79 @@ fn logs_datastore() -> RadrootsClientWebDatastore { RadrootsClientWebDatastore::new(Some(IDB_CONFIG_LOGS)) } +fn logs_resolve_backends( + context: &Option<RadrootsAppContext>, +) -> Option<(RadrootsClientWebDatastore, RadrootsAppKeyMapConfig)> { + context.as_ref().and_then(|context| { + context.backends.with_untracked(|value| { + value.as_ref().map(|backends| { + (logs_datastore(), backends.config.datastore.key_maps.clone()) + }) + }) + }) +} + +fn logs_refresh( + context: &Option<RadrootsAppContext>, + entries: RwSignal<Vec<RadrootsAppLogEntry>, LocalStorage>, + loading: RwSignal<bool, LocalStorage>, + dump_error: RwSignal<Option<String>, LocalStorage>, +) { + let Some((datastore, key_maps)) = logs_resolve_backends(context) else { + entries.set(Vec::new()); + dump_error.set(None); + return; + }; + loading.set(true); + spawn_local(async move { + let _ = app_log_buffer_flush_no_prune(&datastore, &key_maps).await; + let result = app_log_entries_load(&datastore, &key_maps).await; + match result { + Ok(mut items) => { + items.sort_by(|a, b| b.timestamp_ms.cmp(&a.timestamp_ms)); + dump_error.set(None); + entries.set(items); + } + Err(err) => { + dump_error.set(Some(format!("error: {err}"))); + entries.set(Vec::new()); + } + } + loading.set(false); + }); +} + +fn logs_clear( + context: &Option<RadrootsAppContext>, + entries: RwSignal<Vec<RadrootsAppLogEntry>, LocalStorage>, + loading: RwSignal<bool, LocalStorage>, + dump_error: RwSignal<Option<String>, LocalStorage>, +) { + let Some((datastore, key_maps)) = logs_resolve_backends(context) else { + entries.set(Vec::new()); + dump_error.set(None); + return; + }; + loading.set(true); + spawn_local(async move { + let _ = app_log_entries_clear(&datastore, &key_maps).await; + let _ = app_log_buffer_flush_no_prune(&datastore, &key_maps).await; + let result = app_log_entries_load(&datastore, &key_maps).await; + match result { + Ok(mut items) => { + items.sort_by(|a, b| b.timestamp_ms.cmp(&a.timestamp_ms)); + dump_error.set(None); + entries.set(items); + } + Err(err) => { + dump_error.set(Some(format!("error: {err}"))); + entries.set(Vec::new()); + } + } + loading.set(false); + }); +} + fn log_level_color(level: RadrootsAppLogLevel) -> &'static str { match level { RadrootsAppLogLevel::Debug => "#6b7280", @@ -324,7 +399,7 @@ pub fn RadrootsAppLogsPage() -> impl IntoView { let page_size = RwSignal::new_local(logs_page_size_default()); let page_index = RwSignal::new_local(0usize); let dump_config = RwSignal::new_local(None::<RadrootsAppLogDumpConfig>); - let context = Rc::new(app_context()); + let context = app_context(); let filtered_entries = Memo::new(move |_| { let level_filter = filter_level.get(); let query = filter_query.get(); @@ -394,72 +469,21 @@ pub fn RadrootsAppLogsPage() -> impl IntoView { let header = log_dump_header_with_context(context); log_dump_with_context(&items, header) }); - let resolve_backends = { - let context = Rc::clone(&context); - Rc::new(move || { - context.as_ref().as_ref().and_then(|context| { - context - .backends - .with_untracked(|value| { - value.as_ref().map(|backends| { - ( - Rc::new(logs_datastore()), - backends.config.datastore.key_maps.clone(), - ) - }) - }) - }) - }) - }; - let refresh = { - let resolve_backends = Rc::clone(&resolve_backends); - Rc::new(move || { - let Some((datastore, key_maps)) = resolve_backends() else { - entries.set(Vec::new()); - dump_error.set(None); - return; - }; - loading.set(true); - let entries_signal = entries; - let loading_signal = loading; - spawn_local(async move { - let _ = app_log_buffer_flush_no_prune(datastore.as_ref(), &key_maps).await; - let result = app_log_entries_load(datastore.as_ref(), &key_maps).await; - match result { - Ok(mut items) => { - items.sort_by(|a, b| b.timestamp_ms.cmp(&a.timestamp_ms)); - dump_error.set(None); - entries_signal.set(items); - } - Err(err) => { - dump_error.set(Some(format!("error: {err}"))); - entries_signal.set(Vec::new()); - } - } - loading_signal.set(false); - }); - }) + let refresh_action = { + let context = context.clone(); + move || { + logs_refresh(&context, entries, loading, dump_error); + } }; - let clear = { - let resolve_backends = Rc::clone(&resolve_backends); - let refresh = Rc::clone(&refresh); - Rc::new(move || { - let Some((datastore, key_maps)) = resolve_backends() else { - entries.set(Vec::new()); - dump_error.set(None); - return; - }; - loading.set(true); - let refresh = Rc::clone(&refresh); - spawn_local(async move { - let _ = app_log_entries_clear(datastore.as_ref(), &key_maps).await; - refresh(); - }); - }) + let clear_action = { + let context = context.clone(); + move || { + logs_clear(&context, entries, loading, dump_error); + } }; let copy_dump = { let dump_text = dump_text.clone(); - Rc::new(move || { + move || { let text = dump_text.get(); if text.is_empty() { dump_status.set(Some(String::from("dump_empty"))); @@ -474,11 +498,11 @@ pub fn RadrootsAppLogsPage() -> impl IntoView { dump_status.set(Some(status)); dump_action_running.set(false); }); - }) + } }; let download_dump = { let dump_text = dump_text.clone(); - Rc::new(move || { + move || { let text = dump_text.get(); if text.is_empty() { dump_status.set(Some(String::from("dump_empty"))); @@ -493,25 +517,23 @@ pub fn RadrootsAppLogsPage() -> impl IntoView { dump_status.set(Some(status)); dump_action_running.set(false); }); - }) + } }; - let copy_support = { - Rc::new(move || { - let text = support_instructions_text(); - support_running.set(true); - spawn_local(async move { - let status = match log_dump_copy(text).await { - Ok(()) => String::from("support_copy_ok"), - Err(err) => err, - }; - support_status.set(Some(status)); - support_running.set(false); - }); - }) + let copy_support = move || { + let text = support_instructions_text(); + support_running.set(true); + spawn_local(async move { + let status = match log_dump_copy(text).await { + Ok(()) => String::from("support_copy_ok"), + Err(err) => err, + }; + support_status.set(Some(status)); + support_running.set(false); + }); }; let support_bundle = { let dump_text = dump_text.clone(); - Rc::new(move || { + move || { let text = dump_text.get(); if text.is_empty() { support_status.set(Some(String::from("dump_empty"))); @@ -530,56 +552,64 @@ pub fn RadrootsAppLogsPage() -> impl IntoView { support_status.set(Some(status)); support_running.set(false); }); - }) - }; - let refresh_effect = Rc::clone(&refresh); - let context_effect = Rc::clone(&context); - Effect::new(move || { - let Some(context) = context_effect.as_ref() else { - return; - }; - if did_load.get() { - return; } - let has_backends = context.backends.with(|value| value.is_some()); - if !has_backends { - return; + }; + Effect::new({ + let context = context.clone(); + move || { + let Some(context_ref) = context.as_ref() else { + return; + }; + if did_load.get() { + return; + } + let has_backends = context_ref.backends.with(|value| value.is_some()); + if !has_backends { + return; + } + did_load.set(true); + logs_refresh(&context, entries, loading, dump_error); } - did_load.set(true); - refresh_effect(); }); - let config_effect = Rc::clone(&context); - Effect::new(move || { - let Some(context) = config_effect.as_ref() else { - return; - }; - let config = context - .backends - .with(|value| value.as_ref().map(|backends| backends.config.clone())); - let Some(config) = config else { - return; - }; - dump_config.set(Some(log_dump_config_from_app(&config))); + Effect::new({ + let context = context.clone(); + move || { + let Some(context) = context.as_ref() else { + return; + }; + let config = context + .backends + .with(|value| value.as_ref().map(|backends| backends.config.clone())); + let Some(config) = config else { + return; + }; + dump_config.set(Some(log_dump_config_from_app(&config))); + } }); - let interval_effect = Rc::clone(&refresh); - Effect::new(move || { - if interval_started.get() { - return; + Effect::new({ + let context = context.clone(); + move || { + if interval_started.get() { + return; + } + interval_started.set(true); + let context = context.clone(); + let entries = entries; + let loading = loading; + let dump_error = dump_error; + let (abort_handle, abort_reg) = AbortHandle::new_pair(); + let abort_handle_cleanup = abort_handle.clone(); + spawn_local(async move { + let mut ticks = IntervalStream::new(logs_auto_refresh_ms()); + let task = async move { + while ticks.next().await.is_some() { + logs_refresh(&context, entries, loading, dump_error); + } + }; + let _ = Abortable::new(task, abort_reg).await; + }); + on_cleanup(move || abort_handle_cleanup.abort()); } - interval_started.set(true); - let refresh = Rc::clone(&interval_effect); - let (abort_handle, abort_reg) = AbortHandle::new_pair(); - let abort_handle_cleanup = abort_handle.clone(); - spawn_local(async move { - let mut ticks = IntervalStream::new(logs_auto_refresh_ms()); - let task = async move { - while ticks.next().await.is_some() { - refresh(); - } - }; - let _ = Abortable::new(task, abort_reg).await; - }); - on_cleanup(move || abort_handle_cleanup.abort()); }); let status_label = move || { if loading.get() { @@ -610,11 +640,10 @@ pub fn RadrootsAppLogsPage() -> impl IntoView { total == 0 || page_index.get() + 1 >= total }; view! { - <main id="app-logs" class="app-page app-page-scroll"> + <AppPageChrome title=t!("app.logs.title")> <header id="app-logs-header" style="display:flex;align-items:center;gap:12px;"> - <h1 id="app-logs-title" style="font-size:18px;font-weight:600;">{t!("app.logs.title")}</h1> - <button on:click=move |_| refresh()>{t!("app.logs.action.refresh")}</button> - <button on:click=move |_| clear()>{t!("app.logs.action.clear")}</button> + <button on:click=move |_| refresh_action()>{t!("app.logs.action.refresh")}</button> + <button on:click=move |_| clear_action()>{t!("app.logs.action.clear")}</button> <button on:click=move |_| copy_dump() disabled=dump_action_disabled>{t!("app.logs.action.copy_dump")}</button> <button on:click=move |_| download_dump() disabled=dump_action_disabled>{t!("app.logs.action.download_dump")}</button> <div id="app-logs-status" style="font-size:12px;color:#6b7280;">{status_label}</div> @@ -787,7 +816,7 @@ pub fn RadrootsAppLogsPage() -> impl IntoView { ></textarea> </section> </section> - </main> + </AppPageChrome> } } diff --git a/app/src/settings.rs b/app/src/settings.rs @@ -5,6 +5,7 @@ use leptos::prelude::*; use leptos_router::hooks::use_navigate; use crate::{ + app::AppPageChrome, app_theme_apply_mode, app_theme_mode_from_value, app_theme_read_mode, @@ -285,18 +286,12 @@ pub fn RadrootsAppSettingsPage() -> impl IntoView { styles: None, }; view! { - <main id="app-settings" class="app-page app-page-scroll" style="padding: 16px;"> - <header - id="app-settings-header" - style="font-family: var(--font-sans); font-size: 34px; line-height: 41px; font-weight: 600; letter-spacing: -0.01em; margin-bottom: 12px;" - > - <h1 id="app-settings-title" class="capitalize">{t!("app.settings.title")}</h1> - </header> - <section id="app-settings-content" style="display:flex;flex-direction:column;gap:16px;"> + <AppPageChrome title=t!("app.settings.title")> + <section id="app-settings-content" class="flex flex-col gap-4"> <RadrootsAppUiListView basis=appearance_list /> <RadrootsAppUiListView basis=actions_list /> <RadrootsAppUiListView basis=system_list /> </section> - </main> + </AppPageChrome> } } diff --git a/app/src/settings_status.rs b/app/src/settings_status.rs @@ -5,6 +5,7 @@ use leptos::prelude::*; use leptos::task::spawn_local; use crate::{ + app::AppPageChrome, active_key_label, app_context, app_health_check_delay_ms, @@ -148,44 +149,43 @@ pub fn RadrootsAppSettingsStatusPage() -> impl IntoView { value.unwrap_or_else(|| t!("app.common.unknown")) }; view! { - <main id="app-settings-status" class="app-page app-page-scroll" style="padding: 16px;"> - <header id="app-settings-status-header" style="display:flex;align-items:center;gap:12px;"> - <h1 id="app-settings-status-title" style="font: var(--type-title2);"> - {t!("app.settings.status.title")} - </h1> - <button - on:click=move |_| { - let config = backends.with_untracked(|value| value.as_ref().map(|backends| backends.config.clone())); - let Some(config) = config else { - return; - }; - let setup_required_value = - !matches!(setup_status.get(), RadrootsAppSetupStatus::Configured); - spawn_health_checks( - config, - setup_required_value, - health_report, - health_running, - active_key, - notifications_status, - last_run, - ); - } - disabled=health_disabled - > - {move || { - if health_running.get() { - t!("app.home.health.button.checking") - } else { - t!("app.home.health.button.run") + <AppPageChrome title=t!("app.settings.status.title")> + <header id="app-settings-status-header" class="flex flex-col gap-2"> + <div class="flex flex-row items-center gap-4"> + <button + on:click=move |_| { + let config = backends.with_untracked(|value| value.as_ref().map(|backends| backends.config.clone())); + let Some(config) = config else { + return; + }; + let setup_required_value = + !matches!(setup_status.get(), RadrootsAppSetupStatus::Configured); + spawn_health_checks( + config, + setup_required_value, + health_report, + health_running, + active_key, + notifications_status, + last_run, + ); } - }} - </button> - <div id="app-settings-status-updated" style="font-size:12px;color:var(--text-secondary);"> - {move || format!("{}: {}", t!("app.settings.status.updated"), last_updated_label())} + disabled=health_disabled + > + {move || { + if health_running.get() { + t!("app.home.health.button.checking") + } else { + t!("app.home.health.button.run") + } + }} + </button> + <div id="app-settings-status-updated" class="text-xs text-[var(--text-secondary)]"> + {move || format!("{}: {}", t!("app.settings.status.updated"), last_updated_label())} + </div> </div> </header> - <section id="app-settings-status-content" style="display:flex;flex-direction:column;gap:16px;margin-top:12px;"> + <section id="app-settings-status-content" class="flex flex-col gap-4 mt-3"> {move || { let report = health_report.get(); let active = active_key_label(active_key.get()); @@ -244,6 +244,6 @@ pub fn RadrootsAppSettingsStatusPage() -> impl IntoView { view! { <RadrootsAppUiListView basis=list /> }.into_any() }} </section> - </main> + </AppPageChrome> } } diff --git a/app/src/ui_demo.rs b/app/src/ui_demo.rs @@ -1,6 +1,6 @@ use leptos::prelude::*; -use crate::t; +use crate::{app::AppPageChrome, t}; use radroots_app_ui_components::{ RadrootsAppUiList, RadrootsAppUiListDisplay, @@ -182,10 +182,7 @@ pub fn RadrootsAppUiDemoPage() -> impl IntoView { }), }; view! { - <main id="app-ui-demo" class="app-page app-page-scroll" style="padding: 16px;"> - <header id="app-ui-demo-header" style="font: var(--type-title2); margin-bottom: 12px;"> - <h1 id="app-ui-demo-title">{t!("app.ui_demo.title")}</h1> - </header> + <AppPageChrome title=t!("app.ui_demo.title")> <section id="app-ui-demo-content"> <RadrootsAppUiListView basis=list /> @@ -242,6 +239,6 @@ pub fn RadrootsAppUiDemoPage() -> impl IntoView { </RadrootsAppUiSheetPortal> </RadrootsAppUiSheetRoot> </section> - </main> + </AppPageChrome> } } diff --git a/crates/ui-components/assets/nav_tabs.css b/crates/ui-components/assets/nav_tabs.css @@ -25,6 +25,21 @@ box-shadow: var(--shadow-1); } +.nav-tabs__item { + display: inline-flex; + align-items: center; + justify-content: center; + height: 40px; + width: 40px; + border-radius: 999px; + color: var(--text-secondary); + transition: color var(--dur-1) var(--ease-ios); +} + +.nav-tabs__item[data-active="true"] { + color: var(--text-primary); +} + .nav-tabs[data-hidden="true"] { transform: translateY(calc(100% + 12px)); opacity: 0; diff --git a/crates/ui-components/src/icon.rs b/crates/ui-components/src/icon.rs @@ -1,6 +1,6 @@ #![forbid(unsafe_code)] -use icondata::{Icon, LuChevronRight, LuChevronsUpDown, LuPlus, LuSettings}; +use icondata::{Icon, LuBeaker, LuChevronRight, LuChevronsUpDown, LuHome, LuPlus, LuSettings}; use leptos::prelude::*; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -9,6 +9,8 @@ pub enum RadrootsAppUiIconKey { CaretUpDown, Plus, Settings, + Home, + Beaker, } impl RadrootsAppUiIconKey { @@ -18,6 +20,8 @@ impl RadrootsAppUiIconKey { RadrootsAppUiIconKey::CaretUpDown => "caret-up-down", RadrootsAppUiIconKey::Plus => "plus", RadrootsAppUiIconKey::Settings => "settings", + RadrootsAppUiIconKey::Home => "home", + RadrootsAppUiIconKey::Beaker => "beaker", } } } @@ -28,6 +32,8 @@ pub fn radroots_app_ui_icon_key_from_name(name: &str) -> Option<RadrootsAppUiIco "caret-up-down" | "chevrons-up-down" => Some(RadrootsAppUiIconKey::CaretUpDown), "plus" => Some(RadrootsAppUiIconKey::Plus), "settings" | "gear" => Some(RadrootsAppUiIconKey::Settings), + "home" => Some(RadrootsAppUiIconKey::Home), + "beaker" | "test" => Some(RadrootsAppUiIconKey::Beaker), _ => None, } } @@ -38,6 +44,8 @@ pub fn radroots_app_ui_icon_data(key: RadrootsAppUiIconKey) -> Icon { RadrootsAppUiIconKey::CaretUpDown => LuChevronsUpDown, RadrootsAppUiIconKey::Plus => LuPlus, RadrootsAppUiIconKey::Settings => LuSettings, + RadrootsAppUiIconKey::Home => LuHome, + RadrootsAppUiIconKey::Beaker => LuBeaker, } } @@ -116,6 +124,18 @@ mod tests { radroots_app_ui_icon_key_from_name("gear"), Some(RadrootsAppUiIconKey::Settings) ); + assert_eq!( + radroots_app_ui_icon_key_from_name("home"), + Some(RadrootsAppUiIconKey::Home) + ); + assert_eq!( + radroots_app_ui_icon_key_from_name("beaker"), + Some(RadrootsAppUiIconKey::Beaker) + ); + assert_eq!( + radroots_app_ui_icon_key_from_name("test"), + Some(RadrootsAppUiIconKey::Beaker) + ); assert_eq!(radroots_app_ui_icon_key_from_name("unknown"), None); } diff --git a/crates/ui-components/src/nav_header.rs b/crates/ui-components/src/nav_header.rs @@ -40,12 +40,12 @@ pub enum RadrootsAppUiNavHeaderCollapseMode { #[component] pub fn RadrootsAppUiNavHeader( label: String, - #[prop(optional)] on_label_click: Option<Callback<MouseEvent>>, - #[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>, + on_label_click: Option<Callback<MouseEvent>>, + bg_mode: Option<RadrootsAppUiNavHeaderBgMode>, + collapse_mode: Option<RadrootsAppUiNavHeaderCollapseMode>, + right: Option<ChildrenFn>, + id: Option<String>, + class: Option<String>, ) -> impl IntoView { let bg_mode = bg_mode.unwrap_or(RadrootsAppUiNavHeaderBgMode::AutoBlur); let collapse_mode = collapse_mode.unwrap_or(RadrootsAppUiNavHeaderCollapseMode::Scroll); diff --git a/crates/ui-components/src/scroll.rs b/crates/ui-components/src/scroll.rs @@ -54,17 +54,18 @@ pub fn radroots_app_ui_scroll_velocity(prev_top: f64, next_top: f64, dt_ms: f64) #[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>, + id: Option<String>, + classes: Option<String>, + collapse_range: Option<f64>, + 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 class_value = + classes.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;