app

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

commit 3c52983fbc3dd7f4c6e9a78e6eb91d6e50b7dfd7
parent 3d49c420f8047b9ebb700afd01ea2a32e36b9865
Author: triesap <triesap@radroots.dev>
Date:   Wed, 21 Jan 2026 20:06:49 +0000

ui: add presence primitive

- add presence state machine with open and exit states

- add presence component with transition end handling

- add unit tests for presence state transitions

- export presence helpers from ui-primitives

Diffstat:
MCargo.lock | 6++++++
Mcrates/ui-primitives/src/lib.rs | 6++++++
Acrates/ui-primitives/src/presence.rs | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 149 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1719,12 +1719,18 @@ dependencies = [ [[package]] name = "radroots-app-ui-core" version = "0.1.0" +dependencies = [ + "wasm-bindgen", + "web-sys", +] [[package]] name = "radroots-app-ui-primitives" version = "0.1.0" dependencies = [ + "leptos", "radroots-app-ui-core", + "web-sys", ] [[package]] diff --git a/crates/ui-primitives/src/lib.rs b/crates/ui-primitives/src/lib.rs @@ -1,5 +1,11 @@ #![forbid(unsafe_code)] mod portal; +mod presence; pub use portal::{RadrootsAppUiPortal, RadrootsAppUiPortalMount}; +pub use presence::{ + radroots_app_ui_presence_state_next, + RadrootsAppUiPresence, + RadrootsAppUiPresenceState, +}; diff --git a/crates/ui-primitives/src/presence.rs b/crates/ui-primitives/src/presence.rs @@ -0,0 +1,137 @@ +use leptos::ev::{AnimationEvent, TransitionEvent}; +use leptos::prelude::*; +use std::sync::Arc; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RadrootsAppUiPresenceState { + Mounted, + Exiting, + Unmounted, +} + +pub fn radroots_app_ui_presence_state_next( + current: RadrootsAppUiPresenceState, + present: bool, + exit_complete: bool, +) -> RadrootsAppUiPresenceState { + match (current, present, exit_complete) { + (_, true, _) => RadrootsAppUiPresenceState::Mounted, + (RadrootsAppUiPresenceState::Mounted, false, true) => { + RadrootsAppUiPresenceState::Unmounted + } + (RadrootsAppUiPresenceState::Mounted, false, false) => { + RadrootsAppUiPresenceState::Exiting + } + (RadrootsAppUiPresenceState::Exiting, false, true) => { + RadrootsAppUiPresenceState::Unmounted + } + (RadrootsAppUiPresenceState::Exiting, false, false) => { + RadrootsAppUiPresenceState::Exiting + } + (RadrootsAppUiPresenceState::Unmounted, false, _) => { + RadrootsAppUiPresenceState::Unmounted + } + } +} + +#[component] +pub fn RadrootsAppUiPresence( + #[prop(into)] present: Signal<bool>, + #[prop(optional)] on_exit_complete: Option<Callback<()>>, + children: ChildrenFn, +) -> impl IntoView { + let state = RwSignal::new(if present.get() { + RadrootsAppUiPresenceState::Mounted + } else { + RadrootsAppUiPresenceState::Unmounted + }); + + Effect::new(move || { + let next = radroots_app_ui_presence_state_next(state.get(), present.get(), false); + if next != state.get() { + state.set(next); + } + }); + + let on_exit_complete = on_exit_complete.clone(); + let end_handler = Arc::new(move || { + let next = radroots_app_ui_presence_state_next(state.get(), present.get(), true); + if next != state.get() { + state.set(next); + if next == RadrootsAppUiPresenceState::Unmounted { + if let Some(callback) = on_exit_complete.as_ref() { + callback.run(()); + } + } + } + }); + + let render = move || -> AnyView { + if state.get() == RadrootsAppUiPresenceState::Unmounted { + ().into_any() + } else { + let transition_end = { + let end_handler = Arc::clone(&end_handler); + move |_event: TransitionEvent| { + end_handler(); + } + }; + let animation_end = { + let end_handler = Arc::clone(&end_handler); + move |_event: AnimationEvent| { + end_handler(); + } + }; + view! { + <div + data-state=move || if present.get() { "open" } else { "closed" } + on:transitionend=transition_end + on:animationend=animation_end + > + {children()} + </div> + } + .into_any() + } + }; + + view! { {render} } +} + +#[cfg(test)] +mod tests { + use super::{ + radroots_app_ui_presence_state_next, + RadrootsAppUiPresenceState, + }; + + #[test] + fn presence_state_moves_to_exiting_on_close() { + let next = radroots_app_ui_presence_state_next( + RadrootsAppUiPresenceState::Mounted, + false, + false, + ); + assert_eq!(next, RadrootsAppUiPresenceState::Exiting); + } + + #[test] + fn presence_state_unmounts_after_exit() { + let next = radroots_app_ui_presence_state_next( + RadrootsAppUiPresenceState::Exiting, + false, + true, + ); + assert_eq!(next, RadrootsAppUiPresenceState::Unmounted); + } + + #[test] + fn presence_state_mounts_when_present() { + let next = radroots_app_ui_presence_state_next( + RadrootsAppUiPresenceState::Unmounted, + true, + false, + ); + assert_eq!(next, RadrootsAppUiPresenceState::Mounted); + } +}