app

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

commit dd201d63d5cf22cd29af303edfb2251ab60599b7
parent 8b421f3f4d90342188caba15a5af27b4d7e59f4e
Author: triesap <tyson@radroots.org>
Date:   Tue, 27 Jan 2026 03:01:28 +0000

ui: add reusable bottom button layout with back navigation

- introduce RadrootsAppUiButtonLayout components and action structs
- refactor setup footer to use button layout pair for Continue/Back
- add setup step prev() transition and wire rewind callback in UI
- add unit test coverage for prev() step rewind behavior

Diffstat:
Mapp/src/app.rs | 58++++++++++++++++++++++++++++++++++++++++++----------------
Mapp/src/setup.rs | 19+++++++++++++++++++
Acrates/ui-components/src/button_layout.rs | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/ui-components/src/lib.rs | 8++++++++
4 files changed, 241 insertions(+), 16 deletions(-)

diff --git a/app/src/app.rs b/app/src/app.rs @@ -9,6 +9,9 @@ use leptos_router::path; use radroots_app_core::datastore::RadrootsClientDatastore; use radroots_app_core::idb::IDB_CONFIG_LOGS; use radroots_app_ui_components::{ + RadrootsAppUiButtonLayoutAction, + RadrootsAppUiButtonLayoutBackAction, + RadrootsAppUiButtonLayoutPair, RadrootsAppUiList, RadrootsAppUiListIcon, RadrootsAppUiListItem, @@ -274,16 +277,27 @@ fn SetupPage() -> impl IntoView { navigate_guard("/", Default::default()); } }); - let advance_step = move |_| { - setup_step.update(|step| { - *step = step.next(); - }); + let advance_step: Callback<MouseEvent> = { + let setup_step = setup_step.clone(); + Callback::new(move |_| { + setup_step.update(|step| { + *step = step.next(); + }); + }) + }; + let rewind_step: Callback<MouseEvent> = { + let setup_step = setup_step.clone(); + Callback::new(move |_| { + setup_step.update(|step| { + *step = step.prev(); + }); + }) }; view! { <main id="app-setup" data-app-scroll - class="min-h-[100dvh] h-[100dvh] w-full flex flex-col" + class="relative min-h-[100dvh] h-[100dvh] w-full flex flex-col" > {move || match setup_step.get() { RadrootsAppSetupStep::Intro => { @@ -325,17 +339,6 @@ fn SetupPage() -> impl IntoView { </div> </div> </div> - <div class="z-10 absolute bottom-10 left-0 flex flex-col w-full justify-center items-center"> - <button - type="button" - class="group flex flex-row h-touch_guide w-lo_ios0 ios1:w-lo_ios1 justify-center items-center bg-ly1 rounded-touch ly1-active-surface ly1-active-raise-less ly1-active-ring-less el-re" - on:click=advance_step - > - <span class="font-sans font-[600] tracking-wide text-ly1-gl-shade group-active:text-ly1-gl/40 el-re"> - "Continue" - </span> - </button> - </div> </section> } .into_any() @@ -360,6 +363,29 @@ fn SetupPage() -> impl IntoView { </section> }.into_any(), }} + <div class="z-10 absolute bottom-10 left-0 flex flex-col w-full justify-center items-center"> + {move || { + let step = setup_step.get(); + let continue_action = RadrootsAppUiButtonLayoutAction { + label: "Continue".to_string(), + disabled: step.is_terminal(), + loading: false, + on_click: advance_step.clone(), + }; + let back_action = RadrootsAppUiButtonLayoutBackAction { + visible: !matches!(step, RadrootsAppSetupStep::Intro), + label: Some("Back".to_string()), + disabled: false, + on_click: rewind_step.clone(), + }; + view! { + <RadrootsAppUiButtonLayoutPair + continue_action=continue_action + back=Some(back_action) + /> + } + }} + </div> </main> } } diff --git a/app/src/setup.rs b/app/src/setup.rs @@ -56,6 +56,13 @@ impl RadrootsAppSetupStep { } } + pub const fn prev(self) -> Self { + match self { + RadrootsAppSetupStep::Intro => RadrootsAppSetupStep::Intro, + RadrootsAppSetupStep::KeyChoice => RadrootsAppSetupStep::Intro, + } + } + pub const fn is_terminal(self) -> bool { matches!(self, RadrootsAppSetupStep::KeyChoice) } @@ -317,6 +324,18 @@ mod tests { } #[test] + fn setup_step_prev_rewinds_once() { + assert_eq!( + RadrootsAppSetupStep::Intro.prev(), + RadrootsAppSetupStep::Intro + ); + assert_eq!( + RadrootsAppSetupStep::KeyChoice.prev(), + RadrootsAppSetupStep::Intro + ); + } + + #[test] fn setup_step_terminal_matches_key_choice() { assert!(!RadrootsAppSetupStep::Intro.is_terminal()); assert!(RadrootsAppSetupStep::KeyChoice.is_terminal()); diff --git a/crates/ui-components/src/button_layout.rs b/crates/ui-components/src/button_layout.rs @@ -0,0 +1,172 @@ +#![forbid(unsafe_code)] + +use leptos::ev::MouseEvent; +use leptos::prelude::*; + +use crate::RadrootsAppUiSpinner; + +fn radroots_app_ui_button_class_merge(parts: &[Option<&str>]) -> String { + let mut result = String::new(); + for part in parts { + let Some(value) = part else { + continue; + }; + if value.is_empty() { + continue; + } + if !result.is_empty() { + result.push(' '); + } + result.push_str(value); + } + result +} + +#[derive(Clone)] +pub struct RadrootsAppUiButtonLayoutAction { + pub label: String, + pub disabled: bool, + pub loading: bool, + pub on_click: Callback<MouseEvent>, +} + +#[derive(Clone)] +pub struct RadrootsAppUiButtonLayoutBackAction { + pub visible: bool, + pub label: Option<String>, + pub disabled: bool, + pub on_click: Callback<MouseEvent>, +} + +#[component] +pub fn RadrootsAppUiButtonLayout( + label: String, + on_click: Callback<MouseEvent>, + #[prop(optional)] disabled: bool, + #[prop(optional)] loading: bool, + #[prop(optional)] class: Option<String>, + #[prop(optional)] class_label: Option<String>, + #[prop(optional)] hide_active: bool, +) -> impl IntoView { + let base_class = if hide_active { + "flex flex-row h-touch_guide w-lo_ios0 ios1:w-lo_ios1 justify-center items-center bg-ly1 rounded-touch el-re disabled:opacity-60" + } else { + "button-layout" + }; + let button_class = radroots_app_ui_button_class_merge(&[ + Some("group"), + Some(base_class), + class.as_deref(), + ]); + let label_class = radroots_app_ui_button_class_merge(&[ + Some("button-layout-label"), + class_label.as_deref(), + ]); + view! { + <button + type="button" + class=button_class + disabled=disabled + on:click=move |ev| { + ev.stop_propagation(); + if disabled { + return; + } + on_click.run(ev); + } + > + {move || { + if loading { + view! { <RadrootsAppUiSpinner class="text-[18px]".to_string() /> }.into_any() + } else { + view! { <span class=label_class.clone()>{label.clone()}</span> }.into_any() + } + }} + </button> + } +} + +#[component] +pub fn RadrootsAppUiButtonLayoutPair( + continue_action: RadrootsAppUiButtonLayoutAction, + #[prop(optional)] back: Option<RadrootsAppUiButtonLayoutBackAction>, + #[prop(optional)] class: Option<String>, +) -> impl IntoView { + let back_visible = back + .as_ref() + .map(|value| value.visible) + .unwrap_or(false); + let wrapper_class = radroots_app_ui_button_class_merge(&[ + Some("flex flex-col gap-1 justify-center items-center el-re"), + if back_visible { Some("-translate-y-8") } else { None }, + class.as_deref(), + ]); + view! { + <div class=wrapper_class> + <RadrootsAppUiButtonLayout + label=continue_action.label + disabled=continue_action.disabled + loading=continue_action.loading + on_click=continue_action.on_click + /> + {back.map(|back_action| { + view! { + <div class="flex flex-col justify-center items-center el-re"> + {if back_action.visible { + let back_label = back_action.label.clone().unwrap_or_default(); + let back_disabled = back_action.disabled; + let back_on_click = back_action.on_click.clone(); + let back_text_class = radroots_app_ui_button_class_merge(&[ + Some("font-sans font-[600] tracking-wide text-ly1-gl-shade el-re"), + if back_disabled { None } else { Some("group-active:text-ly1-gl/40") }, + ]); + view! { + <button + type="button" + class="group flex flex-row h-12 w-lo_ios0 ios1:w-lo_ios1 justify-center items-center fade-in el-re" + disabled=back_disabled + on:click=move |ev| { + ev.stop_propagation(); + if back_disabled { + return; + } + back_on_click.run(ev); + } + > + <span class=back_text_class>{back_label}</span> + </button> + }.into_any() + } else { + view! { + <div class="flex flex-row h-4 w-full justify-start items-center"> + <div class="flex-fluid"></div> + </div> + }.into_any() + }} + </div> + } + })} + </div> + } +} + +#[component] +pub fn RadrootsAppUiButtonLayoutBottom( + #[prop(optional)] hidden: bool, + #[prop(optional)] class: Option<String>, + children: Children, +) -> impl IntoView { + if hidden { + view! { <></> }.into_any() + } else { + let wrapper_class = radroots_app_ui_button_class_merge(&[ + Some("z-10 absolute bottom-0 h-lo_bottom_button_ios0 ios1:h-lo_bottom_button_ios1 flex flex-col w-full px-4 gap-1 justify-start items-center"), + class.as_deref(), + ]); + view! { + <div class=wrapper_class> + {children()} + </div> + }.into_any() + } +} diff --git a/crates/ui-components/src/lib.rs b/crates/ui-components/src/lib.rs @@ -1,6 +1,7 @@ #![forbid(unsafe_code)] mod button; +mod button_layout; mod icon; mod label; mod list; @@ -11,6 +12,13 @@ mod dialog; mod sheet; pub use button::RadrootsAppUiButton; +pub use button_layout::{ + RadrootsAppUiButtonLayout, + RadrootsAppUiButtonLayoutAction, + RadrootsAppUiButtonLayoutBackAction, + RadrootsAppUiButtonLayoutBottom, + RadrootsAppUiButtonLayoutPair, +}; pub use icon::{ radroots_app_ui_icon_data, radroots_app_ui_icon_key_from_name,