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:
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,