app

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

commit 6c3ede3ca52dcc3ca1a417db874671d4a670b684
parent 10d2e77800a620cb4c456acd033cd2cd3bfd5535
Author: triesap <triesap@radroots.dev>
Date:   Wed, 21 Jan 2026 20:40:10 +0000

ui: add dialog components

- add dialog root, trigger, portal, overlay, content, title, description, close
- wire focus trapping, dismiss handling, scroll lock, and aria hidden helpers
- update portal to accept dynamic ChildrenFn content
- add dialog state helper test and lockfile update

Diffstat:
MCargo.lock | 1+
Acrates/ui-components/src/dialog.rs | 346+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/ui-components/src/lib.rs | 12++++++++++++
Mcrates/ui-primitives/src/portal.rs | 2+-
4 files changed, 360 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1712,6 +1712,7 @@ dependencies = [ name = "radroots-app-ui-components" version = "0.1.0" dependencies = [ + "leptos", "radroots-app-ui-core", "radroots-app-ui-primitives", ] diff --git a/crates/ui-components/src/dialog.rs b/crates/ui-components/src/dialog.rs @@ -0,0 +1,346 @@ +use leptos::ev::MouseEvent; +use leptos::html; +use leptos::prelude::*; +use std::sync::{Arc, Mutex}; + +use radroots_app_ui_core::RadrootsAppUiId; +use radroots_app_ui_primitives::{ + RadrootsAppUiDismissableLayer, + RadrootsAppUiFocusScope, + RadrootsAppUiModalGuard, + RadrootsAppUiPresence, + RadrootsAppUiPortal, + RadrootsAppUiScrollLockGuard, +}; + +#[cfg(target_arch = "wasm32")] +use radroots_app_ui_primitives::{ + radroots_app_ui_modal_hide_siblings, + radroots_app_ui_scroll_lock_acquire, +}; + +#[derive(Clone)] +struct RadrootsAppUiDialogContext { + open: Signal<bool>, + set_open: Callback<bool>, + modal: bool, + content_id: String, + title_id: RwSignal<Option<String>>, + description_id: RwSignal<Option<String>>, +} + +pub fn radroots_app_ui_dialog_state_value(open: bool) -> &'static str { + if open { + "open" + } else { + "closed" + } +} + +#[component] +pub fn RadrootsAppUiDialogRoot( + #[prop(optional)] open: Option<ReadSignal<bool>>, + #[prop(optional)] default_open: bool, + #[prop(optional)] modal: Option<bool>, + #[prop(optional)] on_open_change: Option<Callback<bool>>, + children: ChildrenFn, +) -> impl IntoView { + let open_state = RwSignal::new(default_open); + let open_prop = open; + let is_controlled = open_prop.is_some(); + let open_signal = match open_prop { + Some(open) => Signal::derive(move || open.get()), + None => open_state.into(), + }; + let on_open_change = on_open_change.clone(); + let set_open = Callback::new(move |value| { + if !is_controlled { + open_state.set(value); + } + if let Some(callback) = on_open_change.as_ref() { + callback.run(value); + } + }); + let content_id = RadrootsAppUiId::next().prefixed("dialog-content"); + let modal = modal.unwrap_or(true); + let title_id = RwSignal::new(None::<String>); + let description_id = RwSignal::new(None::<String>); + provide_context(RadrootsAppUiDialogContext { + open: open_signal, + set_open, + modal, + content_id, + title_id, + description_id, + }); + view! { <>{children()}</> } +} + +#[component] +pub fn RadrootsAppUiDialogTrigger( + #[prop(optional)] disabled: bool, + #[prop(optional)] class: Option<String>, + #[prop(optional)] id: Option<String>, + #[prop(optional)] style: Option<String>, + children: Children, +) -> impl IntoView { + let context = use_context::<RadrootsAppUiDialogContext>() + .expect("dialog context"); + let content_id = context.content_id.clone(); + let on_click = move |_event: MouseEvent| { + if disabled { + return; + } + context.set_open.run(true); + }; + view! { + <button + type="button" + id=id + class=class + style=style + disabled=disabled + aria-haspopup="dialog" + aria-expanded=move || if context.open.get() { "true" } else { "false" } + aria-controls=content_id + data-ui="dialog-trigger" + data-state=move || radroots_app_ui_dialog_state_value(context.open.get()) + on:click=on_click + > + {children()} + </button> + } +} + +#[component] +pub fn RadrootsAppUiDialogPortal(children: ChildrenFn) -> impl IntoView { + let context = use_context::<RadrootsAppUiDialogContext>() + .expect("dialog context"); + let present = Signal::derive(move || context.open.get()); + let children = StoredValue::new(children); + view! { + <RadrootsAppUiPresence present=present> + <RadrootsAppUiPortal> + {(children.get_value())()} + </RadrootsAppUiPortal> + </RadrootsAppUiPresence> + } +} + +#[component] +pub fn RadrootsAppUiDialogOverlay( + #[prop(optional)] close_on_click: Option<bool>, + #[prop(optional)] class: Option<String>, + #[prop(optional)] id: Option<String>, + #[prop(optional)] style: Option<String>, +) -> impl IntoView { + let context = use_context::<RadrootsAppUiDialogContext>() + .expect("dialog context"); + let close_on_click = close_on_click.unwrap_or(true); + let on_click = move |_event: MouseEvent| { + if close_on_click { + context.set_open.run(false); + } + }; + view! { + <div + id=id + class=class + style=style + data-ui="dialog-overlay" + data-state=move || radroots_app_ui_dialog_state_value(context.open.get()) + on:click=on_click + ></div> + } +} + +#[component] +pub fn RadrootsAppUiDialogContent( + #[prop(optional)] disable_outside_pointer_events: bool, + #[prop(optional)] class: Option<String>, + #[prop(optional)] id: Option<String>, + #[prop(optional)] style: Option<String>, + children: ChildrenFn, +) -> impl IntoView { + let context = use_context::<RadrootsAppUiDialogContext>() + .expect("dialog context"); + let node_ref = NodeRef::<html::Div>::new(); + let content_id = context.content_id.clone(); + let scroll_guard = Arc::new(Mutex::new(None::<RadrootsAppUiScrollLockGuard>)); + let modal_guard = Arc::new(Mutex::new(None::<RadrootsAppUiModalGuard>)); + let modal = context.modal; + + #[cfg(target_arch = "wasm32")] + { + let node_ref = node_ref.clone(); + let scroll_guard = Arc::clone(&scroll_guard); + let modal_guard = Arc::clone(&modal_guard); + on_mount(move || { + if modal { + if let Ok(guard) = radroots_app_ui_scroll_lock_acquire() { + let mut state = scroll_guard + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *state = Some(guard); + } + if let Some(root) = node_ref.get() { + if let Ok(guard) = radroots_app_ui_modal_hide_siblings(&root) { + let mut state = modal_guard + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *state = Some(guard); + } + } + } + }); + } + + let scroll_guard_cleanup = Arc::clone(&scroll_guard); + let modal_guard_cleanup = Arc::clone(&modal_guard); + on_cleanup(move || { + let _ = scroll_guard_cleanup + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + let _ = modal_guard_cleanup + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + }); + + let on_dismiss = { + let set_open = context.set_open.clone(); + Callback::new(move |_reason| { + set_open.run(false); + }) + }; + + let labelled_by = move || context.title_id.get(); + let described_by = move || context.description_id.get(); + let aria_modal = StoredValue::new(if modal { Some("true".to_string()) } else { None }); + let id_value = StoredValue::new(id.unwrap_or_else(|| content_id.clone())); + let class_value = StoredValue::new(class); + let style_value = StoredValue::new(style); + let children = StoredValue::new(children); + + view! { + <RadrootsAppUiDismissableLayer + on_dismiss=on_dismiss + disable_outside_pointer_events=disable_outside_pointer_events + > + <RadrootsAppUiFocusScope trapped=modal auto_focus=true return_focus=true> + <div + node_ref=node_ref + id=move || id_value.get_value() + class=move || class_value.get_value() + style=move || style_value.get_value() + role="dialog" + aria-modal=move || aria_modal.get_value() + aria-labelledby=labelled_by + aria-describedby=described_by + data-ui="dialog" + data-state=move || radroots_app_ui_dialog_state_value(context.open.get()) + > + {(children.get_value())()} + </div> + </RadrootsAppUiFocusScope> + </RadrootsAppUiDismissableLayer> + } +} + +#[component] +pub fn RadrootsAppUiDialogTitle( + #[prop(optional)] class: Option<String>, + #[prop(optional)] id: Option<String>, + #[prop(optional)] style: Option<String>, + children: Children, +) -> impl IntoView { + let context = use_context::<RadrootsAppUiDialogContext>() + .expect("dialog context"); + let title_id = id.unwrap_or_else(|| RadrootsAppUiId::next().prefixed("dialog-title")); + context.title_id.set(Some(title_id.clone())); + let title_id_cleanup = title_id.clone(); + let title_signal = context.title_id; + on_cleanup(move || { + if title_signal.get_untracked().as_deref() == Some(&title_id_cleanup) { + title_signal.set(None); + } + }); + view! { + <h2 + id=title_id + class=class + style=style + data-ui="dialog-title" + > + {children()} + </h2> + } +} + +#[component] +pub fn RadrootsAppUiDialogDescription( + #[prop(optional)] class: Option<String>, + #[prop(optional)] id: Option<String>, + #[prop(optional)] style: Option<String>, + children: Children, +) -> impl IntoView { + let context = use_context::<RadrootsAppUiDialogContext>() + .expect("dialog context"); + let description_id = id.unwrap_or_else(|| RadrootsAppUiId::next().prefixed("dialog-desc")); + context.description_id.set(Some(description_id.clone())); + let desc_id_cleanup = description_id.clone(); + let desc_signal = context.description_id; + on_cleanup(move || { + if desc_signal.get_untracked().as_deref() == Some(&desc_id_cleanup) { + desc_signal.set(None); + } + }); + view! { + <p + id=description_id + class=class + style=style + data-ui="dialog-description" + > + {children()} + </p> + } +} + +#[component] +pub fn RadrootsAppUiDialogClose( + #[prop(optional)] class: Option<String>, + #[prop(optional)] id: Option<String>, + #[prop(optional)] style: Option<String>, + children: Children, +) -> impl IntoView { + let context = use_context::<RadrootsAppUiDialogContext>() + .expect("dialog context"); + let on_click = move |_event: MouseEvent| { + context.set_open.run(false); + }; + view! { + <button + type="button" + id=id + class=class + style=style + data-ui="dialog-close" + on:click=on_click + > + {children()} + </button> + } +} + +#[cfg(test)] +mod tests { + use super::radroots_app_ui_dialog_state_value; + + #[test] + fn dialog_state_value_matches_open() { + assert_eq!(radroots_app_ui_dialog_state_value(true), "open"); + assert_eq!(radroots_app_ui_dialog_state_value(false), "closed"); + } +} diff --git a/crates/ui-components/src/lib.rs b/crates/ui-components/src/lib.rs @@ -3,6 +3,7 @@ mod button; mod label; mod separator; +mod dialog; pub use button::RadrootsAppUiButton; pub use label::RadrootsAppUiLabel; @@ -11,3 +12,14 @@ pub use separator::{ RadrootsAppUiSeparator, RadrootsAppUiSeparatorOrientation, }; +pub use dialog::{ + radroots_app_ui_dialog_state_value, + RadrootsAppUiDialogClose, + RadrootsAppUiDialogContent, + RadrootsAppUiDialogDescription, + RadrootsAppUiDialogOverlay, + RadrootsAppUiDialogPortal, + RadrootsAppUiDialogRoot, + RadrootsAppUiDialogTitle, + RadrootsAppUiDialogTrigger, +}; diff --git a/crates/ui-primitives/src/portal.rs b/crates/ui-primitives/src/portal.rs @@ -9,7 +9,7 @@ pub type RadrootsAppUiPortalMount = (); #[component] pub fn RadrootsAppUiPortal( #[prop(into, optional)] mount: Option<RadrootsAppUiPortalMount>, - children: Children, + children: ChildrenFn, ) -> impl IntoView { #[cfg(target_arch = "wasm32")] {