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:
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);
+ }
+}