diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index 278b8fc6..b38660bd 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -42,6 +42,7 @@ web-sys = { version = "0.3", features = [ "CustomEvent", "CustomEventInit", "History", "DomRect", "Notification", "NotificationPermission", + "TransitionEvent", "TransitionEventInit", ] } js-sys = "0.3" wasm-bindgen-futures = "0.4" diff --git a/crates/web/src/components/bottom_sheet.rs b/crates/web/src/components/bottom_sheet.rs index bf2686bf..564e59fe 100644 --- a/crates/web/src/components/bottom_sheet.rs +++ b/crates/web/src/components/bottom_sheet.rs @@ -12,9 +12,13 @@ //! - 100 vw wide, top corners `--radius-l`, 4 × 36 px centred handle. //! - translateY(100% → 0) over `--motion-slow`, backdrop fade. +use leptos::ev::TransitionEvent; +use leptos::html::Div; use leptos::prelude::*; use wasm_bindgen::JsCast; +use crate::components::lifecycle::{advance, is_zero_duration, LifecycleState}; + /// Reusable bottom-sheet primitive. #[component] pub fn BottomSheet( @@ -66,6 +70,66 @@ pub fn BottomSheet( } }; + // Four-phase data-state lifecycle on the inner .bottom-sheet div. + // Driving property: transform (per components.css `.bottom-sheet` + // declares `transition: transform var(--motion-slow) var(--motion-ease)` + // — translateY 100% → 0 on .open). The reduced-motion media query + // swaps the transition to opacity, but the JS shortcut handles that + // path by snapping to terminal when computed transition-duration is + // zero (which it is in the reduced-motion CSS once the transition + // finishes). + // + // The existing data-open attribute on .bottom-sheet-root remains + // (additive) — it gates pointer-events + the backdrop's opacity, + // and removing it would break those CSS selectors. The new + // data-state lives on the inner .bottom-sheet (the element that + // actually carries the transform transition). + // + // See docs/specs/2026-04-27-event-based-waits-design.md + // §`data-state` attribute pattern. + let sheet_ref: NodeRef
= NodeRef::new(); + let lifecycle = RwSignal::new(if open.get_untracked() { + LifecycleState::Open + } else { + LifecycleState::Closed + }); + + Effect::new(move |prev: Option| { + let now_open = open.get(); + if prev.is_none() || prev == Some(now_open) { + lifecycle.set(if now_open { + LifecycleState::Open + } else { + LifecycleState::Closed + }); + return now_open; + } + lifecycle.set(if now_open { + LifecycleState::Opening + } else { + LifecycleState::Closing + }); + if let Some(el) = sheet_ref.get_untracked() { + if is_zero_duration(el.as_ref()) { + lifecycle.set(advance(lifecycle.get_untracked())); + } + } + now_open + }); + + let on_transition_end = move |ev: TransitionEvent| { + // Accept either driving property: `.bottom-sheet` slides via + // `transform` by default, but the prefers-reduced-motion media + // query in components.css swaps the transition to + // `opacity var(--motion-slow) linear`. Both paths must advance + // the lifecycle; `is_zero_duration` cannot short-circuit because + // the reduced-motion duration is non-zero. + let prop = ev.property_name(); + if prop == "transform" || prop == "opacity" { + lifecycle.update(|s| *s = advance(*s)); + } + }; + view! {