Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/web/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
67 changes: 67 additions & 0 deletions crates/web/src/components/bottom_sheet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<Div> = NodeRef::new();
let lifecycle = RwSignal::new(if open.get_untracked() {
LifecycleState::Open
} else {
LifecycleState::Closed
});

Effect::new(move |prev: Option<bool>| {
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! {
<div
class="bottom-sheet-root"
Expand All @@ -79,6 +143,9 @@ pub fn BottomSheet(
<div
class=move || if open.get() { "bottom-sheet open".to_string() }
else { "bottom-sheet".to_string() }
node_ref=sheet_ref
data-state=move || lifecycle.get().as_str()
on:transitionend=on_transition_end
role="dialog"
aria-modal="true"
aria-label=label.clone()
Expand Down
51 changes: 51 additions & 0 deletions crates/web/src/components/confirm_dialog.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
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 modal confirmation dialog with Cancel / Confirm buttons.
///
/// Shows an overlay with backdrop blur and a centered card. The confirm
Expand Down Expand Up @@ -39,6 +43,50 @@ pub fn ConfirmDialog(
let confirm_button_ref = NodeRef::<leptos::html::Button>::new();
let cancel_button_ref = NodeRef::<leptos::html::Button>::new();

// Four-phase data-state lifecycle on the .confirm-overlay root.
// Driving property: opacity (the dialog fades in/out). The element
// is conditionally rendered, so the Closing phase is observable only
// for the brief moment between visible.set(false) and the subtree
// being unmounted; tests should gate on Open / Closed (or absence).
//
// See docs/specs/2026-04-27-event-based-waits-design.md
// §`data-state` attribute pattern.
let overlay_ref: NodeRef<Div> = NodeRef::new();
let lifecycle = RwSignal::new(if visible.get_untracked() {
LifecycleState::Open
} else {
LifecycleState::Closed
});

Effect::new(move |prev: Option<bool>| {
let now_visible = visible.get();
if prev.is_none() || prev == Some(now_visible) {
lifecycle.set(if now_visible {
LifecycleState::Open
} else {
LifecycleState::Closed
});
return now_visible;
}
lifecycle.set(if now_visible {
LifecycleState::Opening
} else {
LifecycleState::Closing
});
if let Some(el) = overlay_ref.get_untracked() {
if is_zero_duration(el.as_ref()) {
lifecycle.set(advance(lifecycle.get_untracked()));
}
}
now_visible
});

let on_transition_end = move |ev: TransitionEvent| {
if ev.property_name() == "opacity" {
lifecycle.update(|s| *s = advance(*s));
}
};

// Auto-focus the confirm button when the dialog becomes visible so
// keyboard users are pulled into the modal and Escape/Tab work as
// expected per WAI-ARIA APG.
Expand Down Expand Up @@ -67,6 +115,9 @@ pub fn ConfirmDialog(
Some(view! {
<div
class="confirm-overlay"
node_ref=overlay_ref
data-state=move || lifecycle.get().as_str()
on:transitionend=on_transition_end
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
Expand Down
69 changes: 69 additions & 0 deletions crates/web/src/components/grove_drawer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@
//! Close triggers: backdrop tap, grove select, swipe-left > 60 px,
//! Escape. Swipe gesture wiring lands in task 8.

use leptos::ev::TransitionEvent;
use leptos::html::Aside;
use leptos::prelude::*;
use wasm_bindgen::JsCast;

use crate::components::lifecycle::{advance, is_zero_duration, LifecycleState};
use crate::components::{PeerStatusLabel, StatusDot, StatusDotBorder, StatusDotSize};
use crate::icons;
use crate::state::AppState;
Expand Down Expand Up @@ -65,6 +68,69 @@ pub fn GroveDrawer(
closure.forget();
}

// Four-phase data-state lifecycle on the inner <aside> (.grove-drawer).
// The aside owns the `transform` transition (.grove-drawer.open
// translates from -100% to 0); the root div only carries pointer-events
// gating via the existing `data-open` attribute.
//
// - Effect mirrors `open` into `lifecycle`. On initial mount (prev.is_none())
// we snap to a terminal state (Open/Closed) so we don't fire a
// spurious Opening/Closing animation phase.
// - on_transition_end filters on property_name() == "transform" so
// stray transitionend events from box-shadow / opacity / etc. are
// ignored (the spec's "ignore unrelated transitionend" failure mode).
// - Reduced-motion shortcut: if computed transition-duration is 0s
// we snap to the terminal phase synchronously, since no
// transitionend will ever fire under prefers-reduced-motion: reduce.
//
// See docs/specs/2026-04-27-event-based-waits-design.md
// §`data-state` attribute pattern.
let drawer_ref: NodeRef<Aside> = NodeRef::new();
let lifecycle = RwSignal::new(if open.get_untracked() {
LifecycleState::Open
} else {
LifecycleState::Closed
});

Effect::new(move |prev: Option<bool>| {
let now_open = open.get();
// First run, or no change — snap to terminal state. Don't fire
// Opening/Closing on initial mount.
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
});
// Reduced-motion shortcut: snap straight to terminal if no animation.
if let Some(el) = drawer_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: `.grove-drawer` 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! {
<div
class="grove-drawer-root"
Expand All @@ -76,8 +142,11 @@ pub fn GroveDrawer(
on:click=move |_| on_close.run(())
></div>
<aside
node_ref=drawer_ref
class=move || if open.get() { "grove-drawer open".to_string() }
else { "grove-drawer".to_string() }
data-state=move || lifecycle.get().as_str()
on:transitionend=on_transition_end
role="dialog"
aria-modal="true"
aria-label="groves"
Expand Down
119 changes: 119 additions & 0 deletions crates/web/src/components/lifecycle.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// crates/web/src/components/lifecycle.rs
//
// Four-phase lifecycle helpers for animated components. See
// docs/specs/2026-04-27-event-based-waits-design.md §`data-state` attribute
// pattern. Apply via `RwSignal<LifecycleState>` + a `transitionend` closure
// on the component's root element (filtered on the component's specific
// driving CSS property).
//
// `data-state` lifecycle is reserved for the four animated phases
// (closed/opening/open/closing). For categorical states (online/offline/away
// on `status_dot.rs`, idle/loading/connected on `grove_rail.rs`,
// presence labels in `peer_status_label.rs`) keep using `data-state` with
// custom strings — those usages are orthogonal and pre-date this module.

use web_sys::{Element, HtmlElement};

/// Animated component's transition phase.
///
/// `Opening` and `Closing` are set imperatively when the user triggers the
/// transition; `Open` and `Closed` are flipped by the component's
/// `transitionend` listener (or the reduced-motion shortcut).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LifecycleState {
Closed,
Opening,
Open,
Closing,
}

impl LifecycleState {
/// String form for the `data-state` attribute. Keep in sync with e2e
/// tests asserting `toHaveAttribute('data-state', ...)`.
pub const fn as_str(self) -> &'static str {
match self {
Self::Closed => "closed",
Self::Opening => "opening",
Self::Open => "open",
Self::Closing => "closing",
}
}
}

/// Advance the lifecycle on `transitionend`.
///
/// `Opening` → `Open`, `Closing` → `Closed`. Other states are unchanged
/// (a stray `transitionend` while already terminal is a no-op, not an error).
pub const fn advance(state: LifecycleState) -> LifecycleState {
match state {
LifecycleState::Opening => LifecycleState::Open,
LifecycleState::Closing => LifecycleState::Closed,
terminal => terminal,
}
}

/// Returns true if the element has no animation duration (reduced motion or
/// untransitioned). When this returns true, callers must snap to the
/// terminal lifecycle state synchronously without waiting for `transitionend`
/// — otherwise the test hangs because no event will fire.
///
/// Reads `getComputedStyle(el).transition-duration`. Empty string and "0s"
/// both count as zero. Multi-property transitions (comma-separated values)
/// are conservatively treated as non-zero unless every component is "0s",
/// matching the spec's "if computed-zero, skip wait" semantics.
pub fn is_zero_duration(element: &Element) -> bool {
let Some(window) = web_sys::window() else {
return false;
};
let Ok(Some(computed)) = window.get_computed_style(element) else {
return false;
};
let Ok(duration) = computed.get_property_value("transition-duration") else {
return false;
};
if duration.is_empty() {
return true;
}
duration
.split(',')
.all(|d| matches!(d.trim(), "" | "0s" | "0ms"))
}

/// Convenience: convert an `&HtmlElement` to `&Element` for `is_zero_duration`.
pub fn is_zero_duration_html(html: &HtmlElement) -> bool {
is_zero_duration(html.as_ref())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn advance_opening_to_open() {
assert_eq!(advance(LifecycleState::Opening), LifecycleState::Open);
}

#[test]
fn advance_closing_to_closed() {
assert_eq!(advance(LifecycleState::Closing), LifecycleState::Closed);
}

#[test]
fn advance_terminal_states_idempotent() {
assert_eq!(advance(LifecycleState::Open), LifecycleState::Open);
assert_eq!(advance(LifecycleState::Closed), LifecycleState::Closed);
}

#[test]
fn as_str_round_trip() {
for state in [
LifecycleState::Closed,
LifecycleState::Opening,
LifecycleState::Open,
LifecycleState::Closing,
] {
// No round-trip parser by design — `as_str` is for the DOM attribute.
assert!(!state.as_str().is_empty());
}
}
}
Loading