Created
May 21, 2024 01:10
-
-
Save gpollo/dd190c4b67849e7f9538b4c268b179b2 to your computer and use it in GitHub Desktop.
Leptos component that replicates Vue's <Transition>
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
use std::cell::{Cell, RefCell}; | |
use std::ops::Deref; | |
use std::rc::Rc; | |
use leptos::leptos_dom::helpers::AnimationFrameRequestHandle; | |
use leptos::*; | |
use web_sys::EventTarget; | |
/// Possible states of the transition. | |
#[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
enum State { | |
/// State when children are hidden. | |
Hidden, | |
/// State when the children are inserted. | |
EnteringStart { handle: AnimationFrameRequestHandle }, | |
/// State when `l-enter-from` is applied. | |
EnteringFrom { handle: AnimationFrameRequestHandle }, | |
/// State when `l-enter-to` is applied. | |
EnteringTo, | |
/// State when entering transition is cancelled and rolled back. | |
EnteringCancel, | |
/// State when children are visible. | |
Visible, | |
/// State when `l-leave-from` is applied. | |
ExitingFrom { handle: AnimationFrameRequestHandle }, | |
/// State when `l-leave-to` is applied. | |
ExitingTo, | |
/// State when exiting transition is cancelled and rolled back. | |
ExitingCancel, | |
} | |
impl State { | |
fn new(initial_when: bool) -> Self { | |
if initial_when { | |
State::Visible | |
} else { | |
State::Hidden | |
} | |
} | |
fn classes(&self) -> Vec<&'static str> { | |
let mut classes = Vec::new(); | |
if matches!(self, State::EnteringFrom { .. } | State::EnteringCancel) { | |
classes.push("transition-enter-from"); | |
} | |
if matches!(self, State::EnteringTo) { | |
classes.push("transition-enter-to"); | |
} | |
if matches!( | |
self, | |
State::EnteringFrom { .. } | State::EnteringTo | State::EnteringCancel | |
) { | |
classes.push("transition-enter-active"); | |
} | |
if matches!(self, State::ExitingFrom { .. } | State::ExitingCancel) { | |
classes.push("transition-leave-from"); | |
} | |
if matches!(self, State::ExitingTo) { | |
classes.push("transition-leave-to"); | |
} | |
if matches!( | |
self, | |
State::ExitingFrom { .. } | State::ExitingTo | State::ExitingCancel | |
) { | |
classes.push("transition-leave-active"); | |
} | |
classes | |
} | |
} | |
/// State machine for controlling the animation. | |
/// | |
/// TODO: Add a timeout in case there are no animation and the state machine gets stuck waiting. | |
/// | |
#[doc = svgbobdoc::transform!( | |
/// ```svgbob | |
/// .---------------. .-------------. .---------------. | |
/// | | | |---------->| | | |
/// .---------------------->| Hidden |<------------------| ExitingTo | @show | ExitingCancel | | |
/// | @transition | | @transition | |<----------| | | |
/// | '------------+--' '-------------' @hide '-------+-------' | |
/// | ^ @hide | ^ @frame | | |
/// | | v @show | | | |
/// | .--+------------. .------+------. | | |
/// | | | | | | | |
/// | | EnteringStart | | ExitingFrom | | | |
/// | | | | | | | |
/// | '-------+-------' '----------+--' | | |
/// | | ^ @hide | | | |
/// | v @frame | | | | |
/// | .---------------. | | | | |
/// | | | | | | | |
/// | .----------------| EnteringFrom | | | | | |
/// | | | | | | | | |
/// | | '-------+-------' | | | | |
/// | | | | | | | |
/// | v @hide v @frame | v @show | | |
/// .----+-----------. .---------------. .--+-------+--. | | |
/// | |<----------| | | | | | |
/// | EnteringCancel | @hide | EnteringTo |------------------>| Visible |<------------------' | |
/// | |---------->| | @transition | | @transition | |
/// '----------------' @show '---------------' '-------------' | |
/// ``` | |
)] | |
struct StateMachine { | |
state: ReadSignal<State>, | |
set_state: WriteSignal<State>, | |
set_visible: WriteSignal<bool>, | |
} | |
impl StateMachine { | |
fn new(initial_when: bool) -> (Rc<Self>, ReadSignal<State>, ReadSignal<bool>) { | |
let (state, set_state) = create_signal(State::new(initial_when)); | |
let (visible, set_visible) = create_signal(initial_when); | |
let state_machine = Rc::new(Self { | |
state: state.clone(), | |
set_state: set_state.clone(), | |
set_visible: set_visible.clone(), | |
}); | |
(state_machine, state, visible) | |
} | |
fn configure_animation_frame(self: &Rc<Self>) -> AnimationFrameRequestHandle { | |
let this = self.clone(); | |
request_animation_frame_with_handle(move || this.on_animation_frame()).unwrap() | |
} | |
fn on_show(self: &Rc<Self>) { | |
match self.state.get_untracked() { | |
State::Hidden => { | |
self.set_visible.set(true); | |
self.set_state.set(State::EnteringStart { | |
handle: self.configure_animation_frame(), | |
}); | |
} | |
State::ExitingFrom { handle } => { | |
handle.cancel(); | |
self.set_visible.set(false); | |
self.set_state.set(State::Visible); | |
} | |
State::ExitingTo => { | |
self.set_state.set(State::ExitingCancel); | |
} | |
State::EnteringCancel => { | |
self.set_state.set(State::EnteringTo); | |
} | |
_ => (), | |
} | |
} | |
fn on_hide(self: &Rc<Self>) { | |
match self.state.get_untracked() { | |
State::EnteringStart { handle } => { | |
handle.cancel(); | |
self.set_visible.set(false); | |
self.set_state.set(State::Hidden); | |
} | |
State::EnteringFrom { handle } => { | |
handle.cancel(); | |
self.set_state.set(State::EnteringCancel); | |
} | |
State::EnteringTo => { | |
self.set_state.set(State::EnteringCancel); | |
} | |
State::Visible => { | |
self.set_state.set(State::ExitingFrom { | |
handle: self.configure_animation_frame(), | |
}); | |
} | |
State::ExitingCancel => { | |
self.set_state.set(State::ExitingTo); | |
} | |
_ => (), | |
} | |
} | |
fn on_command(self: &Rc<Self>, when: bool) { | |
if when { | |
self.on_show(); | |
} else { | |
self.on_hide(); | |
} | |
} | |
fn on_animation_frame(self: &Rc<Self>) { | |
match self.state.get_untracked() { | |
State::EnteringStart { .. } => { | |
self.set_state.set(State::EnteringFrom { | |
handle: self.configure_animation_frame(), | |
}); | |
} | |
State::EnteringFrom { .. } => { | |
self.set_state.set(State::EnteringTo); | |
} | |
State::ExitingFrom { .. } => { | |
self.set_state.set(State::ExitingTo); | |
} | |
_ => (), | |
} | |
} | |
fn on_transition_end(self: &Rc<Self>) { | |
match self.state.get_untracked() { | |
State::EnteringTo => { | |
self.set_state.set(State::Visible); | |
} | |
State::EnteringCancel => { | |
self.set_visible.set(false); | |
self.set_state.set(State::Hidden); | |
} | |
State::ExitingTo => { | |
self.set_visible.set(false); | |
self.set_state.set(State::Hidden); | |
} | |
State::ExitingCancel => { | |
self.set_state.set(State::Visible); | |
} | |
_ => (), | |
} | |
} | |
} | |
/// Keeps track of ongoing transitions in HTML elements. | |
/// | |
/// For each HTML element that is configured, it adds event handler for the [`transitionstart`], | |
/// [`transitioncancel`] and [`transitionend`] events. When all animations are finished, it executes | |
/// [`StateMachine::on_transition_end`]. | |
/// | |
/// [`transitionstart`]: https://developer.mozilla.org/en-US/docs/Web/API/Element/transitionstart_event | |
/// [`transitioncancel`]: https://developer.mozilla.org/en-US/docs/Web/API/Element/transitioncancel_event | |
/// [`transitionend`]: https://developer.mozilla.org/en-US/docs/Web/API/Element/transitionend_event | |
struct TransitionWatcher { | |
targets: RefCell<Vec<EventTarget>>, | |
counter: Cell<u32>, | |
state: Rc<StateMachine>, | |
} | |
impl TransitionWatcher { | |
pub fn new(state: &Rc<StateMachine>) -> Rc<Self> { | |
Rc::new(Self { | |
targets: RefCell::new(vec![]), | |
counter: Cell::new(0), | |
state: state.clone(), | |
}) | |
} | |
fn configure( | |
self: &Rc<Self>, | |
element: HtmlElement<html::AnyElement>, | |
) -> HtmlElement<html::AnyElement> { | |
match self.targets.try_borrow_mut() { | |
Ok(mut targets) => { | |
let element: web_sys::HtmlElement = element.deref().clone(); | |
targets.push(element.into()); | |
} | |
Err(e) => { | |
leptos::logging::error!("{}", e.to_string()); | |
return element; | |
} | |
} | |
let this_start = self.clone(); | |
let this_cancel = self.clone(); | |
let this_end = self.clone(); | |
element | |
.on::<ev::transitionstart>(ev::transitionstart, move |event| { | |
this_start.on_transition_start(event.target()) | |
}) | |
.on::<ev::transitioncancel>(ev::transitioncancel, move |event| { | |
this_cancel.on_transition_cancel(event.target()) | |
}) | |
.on::<ev::transitionend>(ev::transitionend, move |event| { | |
this_end.on_transition_end(event.target()) | |
}) | |
} | |
fn on_transition_start(&self, target: Option<EventTarget>) { | |
if let Some(target) = target { | |
if self.targets.borrow().contains(&target) { | |
self.counter.set(self.counter.get() + 1); | |
} | |
} | |
} | |
fn on_transition_cancel(&self, target: Option<EventTarget>) { | |
if let Some(target) = target { | |
if self.targets.borrow().contains(&target) { | |
self.counter.set(self.counter.get() - 1); | |
} | |
} | |
} | |
fn on_transition_end(&self, target: Option<EventTarget>) { | |
if let Some(target) = target { | |
if self.targets.borrow().contains(&target) { | |
self.counter.set(self.counter.get() - 1); | |
if self.counter.get() == 0 { | |
self.state.on_transition_end(); | |
} | |
} | |
} | |
} | |
} | |
/// Configure the command callback for the state machine. | |
/// | |
/// This callback is triggered when the user want to show or hide the children. | |
fn configure_command_callback(state_machine: &Rc<StateMachine>, when: &Memo<bool>) { | |
let state_machine = state_machine.clone(); | |
let when = when.clone(); | |
create_effect(move |_| state_machine.on_command(when.get())); | |
} | |
/// Configure the transition end callback for the state machine. | |
/// | |
/// This callback is triggered when all animations of the lower-level children are finished. | |
/// Internally, it uses `TransitionWatch` type of keep track of the ongoing animations. | |
fn configure_transition_callback( | |
state_machine: Rc<StateMachine>, | |
state: ReadSignal<State>, | |
children: ChildrenFn, | |
class: String, | |
) -> impl Fn() -> View { | |
move || { | |
let class = class.clone(); | |
let watcher = TransitionWatcher::new(&state_machine); | |
children() | |
.nodes | |
.iter() | |
.map(move |node| { | |
let class = class.clone(); | |
match node { | |
View::Element(element) => watcher | |
.configure(element.clone().into_html_element()) | |
.dyn_classes(move || state.get().classes()) | |
.classes(class) | |
.into_view(), | |
view => view.clone(), | |
} | |
}) | |
.collect::<Vec<_>>() | |
.into_view() | |
} | |
} | |
/// A component that replicates the behavior of Vue's [`<Transition>`]. | |
/// | |
/// Unlike Vue, we don't have named transitions and we use the following | |
/// class names: | |
/// | |
/// * `transition-enter-from`, | |
/// * `transition-enter-to`, | |
/// * `transition-enter-active` | |
/// * `transition-leave-from`, | |
/// * `transition-leave-to` and | |
/// * `transition-leave-active`. | |
/// | |
/// This component will apply CSS classes upon entering and leaving transitions | |
/// for every direct children of the `<VueTransition>`. For example, let's say | |
/// we have a background and a sheet that we wish to animate. | |
/// | |
/// ```html | |
/// <VueTransition when=show> | |
/// <div class="background"></div> | |
/// <div class="sheet"> | |
/// ... | |
/// </div> | |
/// </VueTransition> | |
/// ``` | |
/// | |
/// To apply different transitions between the background and the sheet, we | |
/// can simply use different CSS selectors. In our case, we want to animate | |
/// the background opacity and the sheet position. | |
/// | |
/// ```css | |
/// .background.transition-enter-active, | |
/// .background.transition-leave-active { | |
/// transition: opacity 200ms ease; | |
/// } | |
/// | |
/// .background.transition-enter-from, | |
/// .background.transition-leave-to { | |
/// opacity: 0; | |
/// } | |
/// | |
/// .background.transition-enter-to, | |
/// .background.transition-leave-from { | |
/// opacity: 1; | |
/// } | |
/// | |
/// .sheet.transition-enter-active, | |
/// .sheet.transition-leave-active { | |
/// transition: transform 500ms ease; | |
/// } | |
/// | |
/// .sheet.transition-enter-from, | |
/// .sheet.transition-leave-to { | |
/// transform: translateY(100%); | |
/// } | |
/// | |
/// .sheet.transition-enter-to, | |
/// .sheet.transition-leave-from { | |
/// transform: translateY(0%); | |
/// } | |
/// ``` | |
/// | |
/// [`<Transition>`]: https://vuejs.org/guide/built-ins/transition | |
#[component] | |
pub fn VueTransition( | |
/// The components that we want to show. | |
children: ChildrenFn, | |
/// If the component should show or not. | |
when: ReadSignal<bool>, | |
/// Optional CSS class to apply, useful for scoped CSS. | |
#[prop(optional, default = "".to_string())] class: String, | |
) -> impl IntoView { | |
let (state_machine, state, visible) = StateMachine::new(when.get_untracked()); | |
let when = create_memo(move |_| when()); | |
configure_command_callback(&state_machine, &when); | |
let children = configure_transition_callback(state_machine, state, children, class); | |
view! { | |
{ | |
move || { | |
match visible.get() { | |
false => view! {}.into_view(), | |
true => children(), | |
} | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment