app

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

commit 80ee42a89d11524b196180066026df1f546ca900
parent 479b1a84a11891ba34bcbaeb9a3122acc5c186a3
Author: triesap <triesap@radroots.dev>
Date:   Thu, 22 Jan 2026 04:48:25 +0000

app: add list row label and display components

- add list label rendering for left/right values with icon support
- add display value component with click handler and icon mapping
- add list class merge helper for stable styling
- cover class merge behavior with unit test

Diffstat:
Mcrates/ui-components/src/lib.rs | 2++
Mcrates/ui-components/src/list.rs | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 158 insertions(+), 0 deletions(-)

diff --git a/crates/ui-components/src/lib.rs b/crates/ui-components/src/lib.rs @@ -23,7 +23,9 @@ pub use list::{ radroots_app_ui_list_row_trailing_data_ui_value, radroots_app_ui_list_section_data_ui_value, RadrootsAppUiListGroup, + RadrootsAppUiListRowDisplayValue, RadrootsAppUiListRow, + RadrootsAppUiListRowLabel, RadrootsAppUiListRowLeading, RadrootsAppUiListRowTrailing, RadrootsAppUiListSection, diff --git a/crates/ui-components/src/list.rs b/crates/ui-components/src/list.rs @@ -1,7 +1,18 @@ #![forbid(unsafe_code)] +use leptos::ev::MouseEvent; use leptos::prelude::*; +use crate::{ + radroots_app_ui_list_icon_key, + RadrootsAppUiIcon, + RadrootsAppUiListDisplay, + RadrootsAppUiListDisplayValue, + RadrootsAppUiListLabel, + RadrootsAppUiListLabelValue, + RadrootsAppUiListLabelValueKind, +}; + pub fn radroots_app_ui_list_group_data_ui_value() -> &'static str { "list-group" } @@ -117,9 +128,143 @@ pub fn RadrootsAppUiListRowTrailing( } } +fn radroots_app_ui_list_class_merge(parts: &[Option<&str>]) -> String { + let mut result = String::new(); + for part in parts { + if let Some(value) = part { + if value.is_empty() { + continue; + } + if !result.is_empty() { + result.push(' '); + } + result.push_str(value); + } + } + result +} + +fn radroots_app_ui_list_label_value_view( + value: RadrootsAppUiListLabelValue, + is_right: bool, + hide_active: bool, +) -> AnyView { + let RadrootsAppUiListLabelValue { + classes_wrap, + hide_truncate, + value, + } = value; + let wrap_class = radroots_app_ui_list_class_merge(&[ + Some("flex flex-row h-full items-center"), + if hide_truncate { None } else { Some("truncate") }, + classes_wrap.as_deref(), + ]); + let active_class = if hide_active { None } else { Some("opacity-active") }; + let view = match value { + RadrootsAppUiListLabelValueKind::Text(value) => { + let text_class = radroots_app_ui_list_class_merge(&[ + Some("text-line_d"), + if is_right { Some("ui-text-secondary") } else { None }, + active_class, + if hide_truncate { None } else { Some("truncate") }, + value.classes.as_deref(), + ]); + view! { <p class=text_class>{value.value}</p> }.into_any() + } + RadrootsAppUiListLabelValueKind::Icon(icon) => { + let icon_key = radroots_app_ui_list_icon_key(&icon); + let icon_class = radroots_app_ui_list_class_merge(&[ + if is_right { Some("ui-text-secondary") } else { None }, + active_class, + icon.class.as_deref(), + ]); + if let Some(icon_key) = icon_key { + view! { <RadrootsAppUiIcon key=icon_key class=icon_class size=16 /> }.into_any() + } else { + view! { <div></div> }.into_any() + } + } + }; + view! { <div class=wrap_class>{view}</div> }.into_any() +} + +#[component] +pub fn RadrootsAppUiListRowLabel( + basis: RadrootsAppUiListLabel, + #[prop(optional)] hide_active: bool, +) -> impl IntoView { + let left_values = basis.left; + let right_values = basis.right; + let left_view = left_values + .into_iter() + .map(|value| radroots_app_ui_list_label_value_view(value, false, hide_active)) + .collect_view(); + let right_view = right_values + .into_iter() + .rev() + .map(|value| radroots_app_ui_list_label_value_view(value, true, hide_active)) + .collect_view(); + view! { + <div class="flex flex-row h-full w-full items-center justify-between"> + <div class="flex flex-row h-full items-center truncate"> + {left_view} + </div> + <div class="flex flex-row h-full items-center justify-end pr-4"> + {right_view} + </div> + </div> + } +} + +#[component] +pub fn RadrootsAppUiListRowDisplayValue( + basis: RadrootsAppUiListDisplay, + #[prop(optional)] hide_active: bool, +) -> impl IntoView { + let on_click = basis.on_click; + let display = match basis.value { + RadrootsAppUiListDisplayValue::Icon(icon) => { + let icon_key = radroots_app_ui_list_icon_key(&icon); + let icon_class = radroots_app_ui_list_class_merge(&[ + Some("ui-text-secondary"), + if hide_active { None } else { Some("opacity-active") }, + icon.class.as_deref(), + ]); + if let Some(icon_key) = icon_key { + view! { <RadrootsAppUiIcon key=icon_key class=icon_class size=18 /> }.into_any() + } else { + view! { <div></div> }.into_any() + } + } + RadrootsAppUiListDisplayValue::Label(label) => { + let text_class = radroots_app_ui_list_class_merge(&[ + Some("text-line_d_e ui-text-secondary line-clamp-1"), + if hide_active { None } else { Some("opacity-active") }, + label.classes.as_deref(), + ]); + view! { <p class=text_class>{label.value}</p> }.into_any() + } + }; + view! { + <button + type="button" + class="z-10 flex flex-grow justify-end" + on:click=move |ev: MouseEvent| { + ev.stop_propagation(); + if let Some(callback) = &on_click { + callback.run(ev); + } + } + > + {display} + </button> + } +} + #[cfg(test)] mod tests { use super::{ + radroots_app_ui_list_class_merge, radroots_app_ui_list_group_data_ui_value, radroots_app_ui_list_row_data_ui_value, radroots_app_ui_list_row_leading_data_ui_value, @@ -141,4 +286,15 @@ mod tests { "list-row-trailing" ); } + + #[test] + fn list_class_merge_skips_empty_values() { + let merged = radroots_app_ui_list_class_merge(&[ + Some("alpha"), + Some(""), + None, + Some("beta"), + ]); + assert_eq!(merged, "alpha beta"); + } }