Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add :capture flag for events to handle them during capture phase (closes #3457) #3575

Merged
merged 1 commit into from
Feb 10, 2025
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
65 changes: 51 additions & 14 deletions leptos_macro/src/view/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1179,8 +1179,7 @@ pub(crate) fn event_type_and_handler(
) -> (TokenStream, TokenStream, TokenStream) {
let handler = attribute_value(node, false);

let (event_type, is_custom, is_force_undelegated, is_targeted) =
parse_event_name(name);
let (event_type, is_custom, options) = parse_event_name(name);

let event_name_ident = match &node.key {
NodeName::Punctuated(parts) => {
Expand All @@ -1198,11 +1197,17 @@ pub(crate) fn event_type_and_handler(
}
_ => unreachable!(),
};
let capture_ident = match &node.key {
NodeName::Punctuated(parts) => {
parts.iter().find(|part| part.to_string() == "capture")
}
_ => unreachable!(),
};
let on = match &node.key {
NodeName::Punctuated(parts) => &parts[0],
_ => unreachable!(),
};
let on = if is_targeted {
let on = if options.targeted {
Ident::new("on_target", on.span()).to_token_stream()
} else {
on.to_token_stream()
Expand All @@ -1215,15 +1220,29 @@ pub(crate) fn event_type_and_handler(
event_type
};

let event_type = if is_force_undelegated {
let event_type = quote! {
::leptos::tachys::html::event::#event_type
};
let event_type = if options.captured {
let capture = if let Some(capture) = capture_ident {
quote! { #capture }
} else {
quote! { capture }
};
quote! { ::leptos::tachys::html::event::#capture(#event_type) }
} else {
event_type
};

let event_type = if options.undelegated {
let undelegated = if let Some(undelegated) = undelegated_ident {
quote! { #undelegated }
} else {
quote! { undelegated }
};
quote! { ::leptos::tachys::html::event::#undelegated(::leptos::tachys::html::event::#event_type) }
quote! { ::leptos::tachys::html::event::#undelegated(#event_type) }
} else {
quote! { ::leptos::tachys::html::event::#event_type }
event_type
};

(on, event_type, handler)
Expand Down Expand Up @@ -1429,13 +1448,22 @@ fn is_ambiguous_element(tag: &str) -> bool {
tag == "a" || tag == "script" || tag == "title"
}

fn parse_event(event_name: &str) -> (String, bool, bool) {
let is_undelegated = event_name.contains(":undelegated");
let is_targeted = event_name.contains(":target");
fn parse_event(event_name: &str) -> (String, EventNameOptions) {
let undelegated = event_name.contains(":undelegated");
let targeted = event_name.contains(":target");
let captured = event_name.contains(":capture");
let event_name = event_name
.replace(":undelegated", "")
.replace(":target", "");
(event_name, is_undelegated, is_targeted)
.replace(":target", "")
.replace(":capture", "");
(
event_name,
EventNameOptions {
undelegated,
targeted,
captured,
},
)
}

/// Escapes Rust keywords that are also HTML attribute names
Expand Down Expand Up @@ -1627,8 +1655,17 @@ const TYPED_EVENTS: [&str; 126] = [

const CUSTOM_EVENT: &str = "Custom";

pub(crate) fn parse_event_name(name: &str) -> (TokenStream, bool, bool, bool) {
let (name, is_force_undelegated, is_targeted) = parse_event(name);
#[derive(Debug)]
pub(crate) struct EventNameOptions {
undelegated: bool,
targeted: bool,
captured: bool,
}

pub(crate) fn parse_event_name(
name: &str,
) -> (TokenStream, bool, EventNameOptions) {
let (name, options) = parse_event(name);

let (event_type, is_custom) = TYPED_EVENTS
.binary_search(&name.as_str())
Expand All @@ -1644,7 +1681,7 @@ pub(crate) fn parse_event_name(name: &str) -> (TokenStream, bool, bool, bool) {
} else {
event_type
};
(event_type, is_custom, is_force_undelegated, is_targeted)
(event_type, is_custom, options)
}

fn convert_to_snake_case(name: String) -> String {
Expand Down
87 changes: 82 additions & 5 deletions tachys/src/html/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ where
el: &crate::renderer::types::Element,
cb: Box<dyn FnMut(crate::renderer::types::Event)>,
name: Cow<'static, str>,
// TODO investigate: does passing this as an option
// (rather than, say, having a const DELEGATED: bool)
// add to binary size?
delegation_key: Option<Cow<'static, str>>,
) -> RemoveEventHandler<crate::renderer::types::Element> {
match delegation_key {
Expand Down Expand Up @@ -201,6 +204,39 @@ where
.then(|| self.event.event_delegation_key()),
)
}

/// Attaches the event listener to the element as a listener that is triggered during the capture phase,
/// meaning it will fire before any event listeners further down in the DOM.
pub fn attach_capture(
self,
el: &crate::renderer::types::Element,
) -> RemoveEventHandler<crate::renderer::types::Element> {
fn attach_inner(
el: &crate::renderer::types::Element,
cb: Box<dyn FnMut(crate::renderer::types::Event)>,
name: Cow<'static, str>,
) -> RemoveEventHandler<crate::renderer::types::Element> {
Rndr::add_event_listener_use_capture(el, &name, cb)
}

let mut cb = self.cb.expect("callback removed before attaching").take();

#[cfg(feature = "tracing")]
let span = tracing::Span::current();

let cb = Box::new(move |ev: crate::renderer::types::Event| {
#[cfg(all(debug_assertions, feature = "reactive_graph"))]
let _rx_guard =
reactive_graph::diagnostics::SpecialNonReactiveZone::enter();
#[cfg(feature = "tracing")]
let _tracing_guard = span.enter();

let ev = E::EventType::from(ev);
cb.invoke(ev);
}) as Box<dyn FnMut(crate::renderer::types::Event)>;

attach_inner(el, cb, self.event.name())
}
}

impl<E, F> Debug for On<E, F>
Expand Down Expand Up @@ -250,13 +286,21 @@ where
self,
el: &crate::renderer::types::Element,
) -> Self::State {
let cleanup = self.attach(el);
let cleanup = if E::CAPTURE {
self.attach_capture(el)
} else {
self.attach(el)
};
(el.clone(), Some(cleanup))
}

#[inline(always)]
fn build(self, el: &crate::renderer::types::Element) -> Self::State {
let cleanup = self.attach(el);
let cleanup = if E::CAPTURE {
self.attach_capture(el)
} else {
self.attach(el)
};
(el.clone(), Some(cleanup))
}

Expand All @@ -266,7 +310,11 @@ where
if let Some(prev) = prev_cleanup.take() {
(prev.into_inner())(el);
}
*prev_cleanup = Some(self.attach(el));
*prev_cleanup = Some(if E::CAPTURE {
self.attach_capture(el)
} else {
self.attach(el)
});
}

fn into_cloneable(self) -> Self::Cloneable {
Expand Down Expand Up @@ -334,10 +382,13 @@ pub trait EventDescriptor: Clone {
/// Indicates if this event bubbles. For example, `click` bubbles,
/// but `focus` does not.
///
/// If this is true, then the event will be delegated globally,
/// otherwise, event listeners will be directly attached to the element.
/// If this is true, then the event will be delegated globally if the `delegation`
/// feature is enabled. Otherwise, event listeners will be directly attached to the element.
const BUBBLES: bool;

/// Indicates if this event should be handled during the capture phase.
const CAPTURE: bool = false;

/// The name of the event, such as `click` or `mouseover`.
fn name(&self) -> Cow<'static, str>;

Expand All @@ -352,6 +403,32 @@ pub trait EventDescriptor: Clone {
}
}

/// A wrapper that tells the framework to handle an event during the capture phase.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Capture<E> {
inner: E,
}

/// Wraps an event to indicate that it should be handled during the capture phase.
pub fn capture<E>(event: E) -> Capture<E> {
Capture { inner: event }
}

impl<E: EventDescriptor> EventDescriptor for Capture<E> {
type EventType = E::EventType;

const CAPTURE: bool = true;
const BUBBLES: bool = E::BUBBLES;

fn name(&self) -> Cow<'static, str> {
self.inner.name()
}

fn event_delegation_key(&self) -> Cow<'static, str> {
self.inner.event_delegation_key()
}
}

/// A custom event.
#[derive(Debug)]
pub struct Custom<E: FromWasmAbi = web_sys::Event> {
Expand Down
40 changes: 39 additions & 1 deletion tachys/src/renderer/dom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use once_cell::unsync::Lazy;
use rustc_hash::FxHashSet;
use std::{any::TypeId, borrow::Cow, cell::RefCell};
use wasm_bindgen::{intern, prelude::Closure, JsCast, JsValue};
use web_sys::{Comment, HtmlTemplateElement};
use web_sys::{AddEventListenerOptions, Comment, HtmlTemplateElement};

/// A [`Renderer`](crate::renderer::Renderer) that uses `web-sys` to manipulate DOM elements in the browser.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
Expand Down Expand Up @@ -245,6 +245,44 @@ impl Dom {
})
}

pub fn add_event_listener_use_capture(
el: &Element,
name: &str,
cb: Box<dyn FnMut(Event)>,
) -> RemoveEventHandler<Element> {
let cb = wasm_bindgen::closure::Closure::wrap(cb);
let name = intern(name);
let options = AddEventListenerOptions::new();
options.set_capture(true);
or_debug!(
el.add_event_listener_with_callback_and_add_event_listener_options(
name,
cb.as_ref().unchecked_ref(),
&options
),
el,
"addEventListenerUseCapture"
);

// return the remover
RemoveEventHandler::new({
let name = name.to_owned();
// safe to construct this here, because it will only run in the browser
// so it will always be accessed or dropped from the main thread
let cb = send_wrapper::SendWrapper::new(cb);
move |el: &Element| {
or_debug!(
el.remove_event_listener_with_callback(
intern(&name),
cb.as_ref().unchecked_ref()
),
el,
"removeEventListener"
)
}
})
}

pub fn event_target<T>(ev: &Event) -> T
where
T: CastFrom<Element>,
Expand Down
Loading