diff --git a/Cargo.toml b/Cargo.toml index 8f4e7e55..bdc9e780 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ version = "0.1.0" [dependencies] gloo-timers = { version = "0.1.0", path = "crates/timers" } gloo-console-timer = { version = "0.1.0", path = "crates/console-timer" } +gloo-events = { version = "0.1.0", path = "crates/events" } [features] default = [] diff --git a/crates/events/Cargo.toml b/crates/events/Cargo.toml new file mode 100644 index 00000000..5dcadd9c --- /dev/null +++ b/crates/events/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "gloo-events" +version = "0.1.0" +authors = ["Rust and WebAssembly Working Group"] +edition = "2018" + +[dependencies] +wasm-bindgen = "0.2.37" + +[dependencies.web-sys] +version = "0.3.14" +features = [ + "Event", + "EventTarget", + "AddEventListenerOptions", +] + +[dev-dependencies] +js-sys = "0.3.14" +futures = "0.1.25" +wasm-bindgen-test = "0.2.37" + +[dev-dependencies.web-sys] +features = [ + "HtmlElement", + "Window", + "Document", + "Element", + "MouseEvent", + "ProgressEvent", +] diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs new file mode 100644 index 00000000..9c8d6e3f --- /dev/null +++ b/crates/events/src/lib.rs @@ -0,0 +1,594 @@ +/*! +Using event listeners with [`web-sys`](https://crates.io/crates/web-sys) is hard! This crate provides an [`EventListener`](struct.EventListener.html) type which makes it easy! + +See the documentation for [`EventListener`](struct.EventListener.html) for more information. +*/ +#![deny(missing_docs, missing_debug_implementations)] + +use std::borrow::Cow; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::{JsCast, UnwrapThrowExt}; +use web_sys::{AddEventListenerOptions, Event, EventTarget}; + +/// Specifies whether the event listener is run during the capture or bubble phase. +/// +/// The official specification has [a good explanation](https://www.w3.org/TR/DOM-Level-3-Events/#event-flow) +/// of capturing vs bubbling. +/// +/// # Default +/// +/// ```rust +/// # use gloo_events::EventListenerPhase; +/// # +/// EventListenerPhase::Bubble +/// # ; +/// ``` +#[derive(Debug, Clone, Copy)] +pub enum EventListenerPhase { + #[allow(missing_docs)] + Bubble, + + #[allow(missing_docs)] + Capture, +} + +impl EventListenerPhase { + #[inline] + fn is_capture(&self) -> bool { + match self { + EventListenerPhase::Bubble => false, + EventListenerPhase::Capture => true, + } + } +} + +impl Default for EventListenerPhase { + #[inline] + fn default() -> Self { + EventListenerPhase::Bubble + } +} + +/// Specifies options for [`EventListener::new_with_options`](struct.EventListener.html#method.new_with_options) and +/// [`EventListener::once_with_options`](struct.EventListener.html#method.once_with_options). +/// +/// # Default +/// +/// ```rust +/// # use gloo_events::{EventListenerOptions, EventListenerPhase}; +/// # +/// EventListenerOptions { +/// phase: EventListenerPhase::Bubble, +/// passive: true, +/// } +/// # ; +/// ``` +/// +/// # Examples +/// +/// Sets `phase` to `EventListenerPhase::Capture`, using the default for the rest: +/// +/// ```rust +/// # use gloo_events::EventListenerOptions; +/// # +/// let options = EventListenerOptions::run_in_capture_phase(); +/// ``` +/// +/// Sets `passive` to `false`, using the default for the rest: +/// +/// ```rust +/// # use gloo_events::EventListenerOptions; +/// # +/// let options = EventListenerOptions::enable_prevent_default(); +/// ``` +/// +/// Specifies all options: +/// +/// ```rust +/// # use gloo_events::{EventListenerOptions, EventListenerPhase}; +/// # +/// let options = EventListenerOptions { +/// phase: EventListenerPhase::Capture, +/// passive: false, +/// }; +/// ``` +#[derive(Debug, Clone, Copy)] +pub struct EventListenerOptions { + /// The phase that the event listener should be run in. + pub phase: EventListenerPhase, + + /// If this is `true` then performance is improved, but it is not possible to use + /// [`event.prevent_default()`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.Event.html#method.prevent_default). + /// + /// If this is `false` then performance might be reduced, but now it is possible to use + /// [`event.prevent_default()`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.Event.html#method.prevent_default). + /// + /// You can read more about the performance costs + /// [here](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Improving_scrolling_performance_with_passive_listeners). + pub passive: bool, +} + +impl EventListenerOptions { + /// Returns an `EventListenerOptions` with `phase` set to `EventListenerPhase::Capture`. + /// + /// This is the same as: + /// + /// ```rust + /// # use gloo_events::{EventListenerOptions, EventListenerPhase}; + /// # + /// EventListenerOptions { + /// phase: EventListenerPhase::Capture, + /// ..Default::default() + /// } + /// # ; + /// ``` + #[inline] + pub fn run_in_capture_phase() -> Self { + Self { + phase: EventListenerPhase::Capture, + ..Self::default() + } + } + + /// Returns an `EventListenerOptions` with `passive` set to `false`. + /// + /// This is the same as: + /// + /// ```rust + /// # use gloo_events::EventListenerOptions; + /// # + /// EventListenerOptions { + /// passive: false, + /// ..Default::default() + /// } + /// # ; + /// ``` + #[inline] + pub fn enable_prevent_default() -> Self { + Self { + passive: false, + ..Self::default() + } + } + + #[inline] + fn to_js(&self, once: bool) -> AddEventListenerOptions { + let mut options = AddEventListenerOptions::new(); + + options.capture(self.phase.is_capture()); + options.once(once); + options.passive(self.passive); + + options + } +} + +impl Default for EventListenerOptions { + #[inline] + fn default() -> Self { + Self { + phase: Default::default(), + passive: true, + } + } +} + +// This defaults passive to true to avoid performance issues in browsers: +// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Improving_scrolling_performance_with_passive_listeners +thread_local! { + static NEW_OPTIONS: AddEventListenerOptions = EventListenerOptions::default().to_js(false); + static ONCE_OPTIONS: AddEventListenerOptions = EventListenerOptions::default().to_js(true); +} + +/// RAII type which is used to manage DOM event listeners. +/// +/// When the `EventListener` is dropped, it will automatically deregister the event listener and clean up the closure's memory. +/// +/// Normally the `EventListener` is stored inside of another struct, like this: +/// +/// ```rust +/// # use gloo_events::EventListener; +/// # use wasm_bindgen::UnwrapThrowExt; +/// use futures::Poll; +/// use futures::stream::Stream; +/// use futures::sync::mpsc; +/// use web_sys::EventTarget; +/// +/// pub struct OnClick { +/// receiver: mpsc::UnboundedReceiver<()>, +/// // Automatically removed from the DOM on drop! +/// listener: EventListener, +/// } +/// +/// impl OnClick { +/// pub fn new(target: &EventTarget) -> Self { +/// let (sender, receiver) = mpsc::unbounded(); +/// +/// // Attach an event listener +/// let listener = EventListener::new(&target, "click", move |_event| { +/// sender.unbounded_send(()).unwrap_throw(); +/// }); +/// +/// Self { +/// receiver, +/// listener, +/// } +/// } +/// } +/// +/// impl Stream for OnClick { +/// type Item = (); +/// type Error = (); +/// +/// fn poll(&mut self) -> Poll, Self::Error> { +/// self.receiver.poll().map_err(|_| unreachable!()) +/// } +/// } +/// ``` +#[must_use = "event listener will never be called after being dropped"] +pub struct EventListener { + target: EventTarget, + event_type: Cow<'static, str>, + callback: Option>, + phase: EventListenerPhase, +} + +impl EventListener { + #[inline] + fn raw_new( + target: &EventTarget, + event_type: Cow<'static, str>, + callback: Closure, + options: &AddEventListenerOptions, + phase: EventListenerPhase, + ) -> Self { + target + .add_event_listener_with_callback_and_add_event_listener_options( + &event_type, + callback.as_ref().unchecked_ref(), + options, + ) + .unwrap_throw(); + + Self { + target: target.clone(), + event_type, + callback: Some(callback), + phase, + } + } + + /// Registers an event listener on an [`EventTarget`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.EventTarget.html). + /// + /// For specifying options, there is a corresponding [`EventListener::new_with_options`](#method.new_with_options) method. + /// + /// If you only need the event to fire once, you can use [`EventListener::once`](#method.once) instead, + /// which accepts an `FnOnce` closure. + /// + /// # Event type + /// + /// The event type can be either a `&'static str` like `"click"`, or it can be a dynamically constructed `String`. + /// + /// All event types are supported. Here is a [partial list](https://developer.mozilla.org/en-US/docs/Web/Events) of the available event types. + /// + /// # Passive + /// + /// [For performance reasons](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Improving_scrolling_performance_with_passive_listeners), + /// it is not possible to use [`event.prevent_default()`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.Event.html#method.prevent_default). + /// + /// If you need to use `prevent_default`, you must use [`EventListener::new_with_options`](#method.new_with_options), like this: + /// + /// ```rust,no_run + /// # use gloo_events::{EventListener, EventListenerOptions}; + /// # let target = unimplemented!(); + /// # let event_type = "click"; + /// # let callback = move |e| {}; + /// # + /// let options = EventListenerOptions::enable_prevent_default(); + /// + /// EventListener::new_with_options(target, event_type, options, callback) + /// # ; + /// ``` + /// + /// # Capture + /// + /// By default, event listeners are run in the bubble phase, *not* the capture phase. The official specification has + /// [a good explanation](https://www.w3.org/TR/DOM-Level-3-Events/#event-flow) of capturing vs bubbling. + /// + /// If you want it to run in the capture phase, you must use [`EventListener::new_with_options`](#method.new_with_options), like this: + /// + /// ```rust,no_run + /// # use gloo_events::{EventListener, EventListenerOptions}; + /// # let target = unimplemented!(); + /// # let event_type = "click"; + /// # let callback = move |e| {}; + /// # + /// // This runs the event listener in the capture phase, rather than the bubble phase + /// let options = EventListenerOptions::run_in_capture_phase(); + /// + /// EventListener::new_with_options(target, event_type, options, callback) + /// # ; + /// ``` + /// + /// # Examples + /// + /// Registers a [`"click"`](https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event) event and downcasts it to the correct `Event` subtype + /// (which is [`MouseEvent`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.MouseEvent.html)): + /// + /// ```rust,no_run + /// # use gloo_events::EventListener; + /// # use wasm_bindgen::{JsCast, UnwrapThrowExt}; + /// # let target = unimplemented!(); + /// # + /// let listener = EventListener::new(&target, "click", move |event| { + /// let event = event.dyn_into::().unwrap_throw(); + /// + /// // ... + /// }); + /// ``` + #[inline] + pub fn new(target: &EventTarget, event_type: S, callback: F) -> Self + where + S: Into>, + F: FnMut(Event) + 'static, + { + let callback = Closure::wrap(Box::new(callback) as Box); + + NEW_OPTIONS.with(move |options| { + Self::raw_new( + target, + event_type.into(), + callback, + options, + EventListenerPhase::Bubble, + ) + }) + } + + /// This is exactly the same as [`EventListener::new`](#method.new), except the event will only fire once, + /// and it accepts `FnOnce` instead of `FnMut`. + /// + /// For specifying options, there is a corresponding [`EventListener::once_with_options`](#method.once_with_options) method. + /// + /// # Examples + /// + /// Registers a [`"load"`](https://developer.mozilla.org/en-US/docs/Web/API/FileReader/load_event) event and casts it to the correct type + /// (which is [`ProgressEvent`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.ProgressEvent.html)): + /// + /// ```rust,no_run + /// # use gloo_events::EventListener; + /// # use wasm_bindgen::{JsCast, UnwrapThrowExt}; + /// # let target = unimplemented!(); + /// # + /// let listener = EventListener::once(&target, "load", move |event| { + /// let event = event.dyn_into::().unwrap_throw(); + /// + /// // ... + /// }); + /// ``` + #[inline] + pub fn once(target: &EventTarget, event_type: S, callback: F) -> Self + where + S: Into>, + F: FnOnce(Event) + 'static, + { + let callback = Closure::once(callback); + + ONCE_OPTIONS.with(move |options| { + Self::raw_new( + target, + event_type.into(), + callback, + options, + EventListenerPhase::Bubble, + ) + }) + } + + /// Registers an event listener on an [`EventTarget`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.EventTarget.html). + /// + /// It is recommended to use [`EventListener::new`](#method.new) instead, because it has better performance, and it is more convenient. + /// + /// If you only need the event to fire once, you can use [`EventListener::once_with_options`](#method.once_with_options) instead, + /// which accepts an `FnOnce` closure. + /// + /// # Event type + /// + /// The event type can be either a `&'static str` like `"click"`, or it can be a dynamically constructed `String`. + /// + /// All event types are supported. Here is a [partial list](https://developer.mozilla.org/en-US/docs/Web/Events) + /// of the available event types. + /// + /// # Options + /// + /// See the documentation for [`EventListenerOptions`](struct.EventListenerOptions.html) for more details. + /// + /// # Examples + /// + /// Registers a [`"touchstart"`](https://developer.mozilla.org/en-US/docs/Web/API/Element/touchstart_event) + /// event and uses + /// [`event.prevent_default()`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.Event.html#method.prevent_default): + /// + /// ```rust,no_run + /// # use gloo_events::{EventListener, EventListenerOptions}; + /// # let target = unimplemented!(); + /// # + /// let options = EventListenerOptions::enable_prevent_default(); + /// + /// let listener = EventListener::new_with_options(&target, "touchstart", options, move |event| { + /// event.prevent_default(); + /// + /// // ... + /// }); + /// ``` + /// + /// Registers a [`"click"`](https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event) + /// event in the capturing phase and uses + /// [`event.stop_propagation()`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.Event.html#method.stop_propagation) + /// to stop the event from bubbling: + /// + /// ```rust,no_run + /// # use gloo_events::{EventListener, EventListenerOptions}; + /// # let target = unimplemented!(); + /// # + /// let options = EventListenerOptions::run_in_capture_phase(); + /// + /// let listener = EventListener::new_with_options(&target, "click", options, move |event| { + /// // Stop the event from bubbling + /// event.stop_propagation(); + /// + /// // ... + /// }); + /// ``` + #[inline] + pub fn new_with_options( + target: &EventTarget, + event_type: S, + options: EventListenerOptions, + callback: F, + ) -> Self + where + S: Into>, + F: FnMut(Event) + 'static, + { + let callback = Closure::wrap(Box::new(callback) as Box); + + Self::raw_new( + target, + event_type.into(), + callback, + &options.to_js(false), + options.phase, + ) + } + + /// This is exactly the same as [`EventListener::new_with_options`](#method.new_with_options), except the event will only fire once, + /// and it accepts `FnOnce` instead of `FnMut`. + /// + /// It is recommended to use [`EventListener::once`](#method.once) instead, because it has better performance, and it is more convenient. + /// + /// # Examples + /// + /// Registers a [`"load"`](https://developer.mozilla.org/en-US/docs/Web/API/FileReader/load_event) + /// event and uses + /// [`event.prevent_default()`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.Event.html#method.prevent_default): + /// + /// ```rust,no_run + /// # use gloo_events::{EventListener, EventListenerOptions}; + /// # let target = unimplemented!(); + /// # + /// let options = EventListenerOptions::enable_prevent_default(); + /// + /// let listener = EventListener::once_with_options(&target, "load", options, move |event| { + /// event.prevent_default(); + /// + /// // ... + /// }); + /// ``` + /// + /// Registers a [`"click"`](https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event) + /// event in the capturing phase and uses + /// [`event.stop_propagation()`](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.Event.html#method.stop_propagation) + /// to stop the event from bubbling: + /// + /// ```rust,no_run + /// # use gloo_events::{EventListener, EventListenerOptions}; + /// # let target = unimplemented!(); + /// # + /// let options = EventListenerOptions::run_in_capture_phase(); + /// + /// let listener = EventListener::once_with_options(&target, "click", options, move |event| { + /// // Stop the event from bubbling + /// event.stop_propagation(); + /// + /// // ... + /// }); + /// ``` + #[inline] + pub fn once_with_options( + target: &EventTarget, + event_type: S, + options: EventListenerOptions, + callback: F, + ) -> Self + where + S: Into>, + F: FnOnce(Event) + 'static, + { + let callback = Closure::once(callback); + + Self::raw_new( + target, + event_type.into(), + callback, + &options.to_js(true), + options.phase, + ) + } + + /// Keeps the `EventListener` alive forever, so it will never be dropped. + /// + /// This should only be used when you want the `EventListener` to last forever, otherwise it will leak memory! + #[inline] + pub fn forget(mut self) { + // take() is necessary because of Rust's restrictions about Drop + // This will never panic, because `callback` is always `Some` + self.callback.take().unwrap_throw().forget() + } + + /// Returns the `EventTarget`. + #[inline] + pub fn target(&self) -> &EventTarget { + &self.target + } + + /// Returns the event type. + #[inline] + pub fn event_type(&self) -> &str { + &self.event_type + } + + /// Returns the callback. + #[inline] + pub fn callback(&self) -> &Closure { + // This will never panic, because `callback` is always `Some` + self.callback.as_ref().unwrap_throw() + } + + /// Returns whether the event listener is run during the capture or bubble phase. + /// + /// The official specification has [a good explanation](https://www.w3.org/TR/DOM-Level-3-Events/#event-flow) + /// of capturing vs bubbling. + #[inline] + pub fn phase(&self) -> EventListenerPhase { + self.phase + } +} + +impl Drop for EventListener { + #[inline] + fn drop(&mut self) { + // This will only be None if forget() was called + if let Some(callback) = &self.callback { + self.target + .remove_event_listener_with_callback_and_bool( + self.event_type(), + callback.as_ref().unchecked_ref(), + self.phase.is_capture(), + ) + .unwrap_throw(); + } + } +} + +// TODO Remove this after https://github.com/rustwasm/wasm-bindgen/issues/1387 is fixed +impl std::fmt::Debug for EventListener { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_struct("EventListener") + .field("target", &self.target) + .field("event_type", &self.event_type) + .field("callback", &"Closure { ... }") + .field("phase", &self.phase) + .finish() + } +} diff --git a/crates/events/tests/web.rs b/crates/events/tests/web.rs new file mode 100644 index 00000000..e05942f1 --- /dev/null +++ b/crates/events/tests/web.rs @@ -0,0 +1,217 @@ +//! Test suite for the Web and headless browsers. + +#![cfg(target_arch = "wasm32")] + +use futures::prelude::*; +use futures::sync::mpsc; +use gloo_events::{EventListener, EventListenerOptions, EventListenerPhase}; +use js_sys::Error; +use wasm_bindgen::{JsCast, JsValue, UnwrapThrowExt}; +use wasm_bindgen_test::*; +use web_sys::{window, HtmlElement}; + +wasm_bindgen_test_configure!(run_in_browser); + +fn body() -> HtmlElement { + window() + .unwrap_throw() + .document() + .unwrap_throw() + .body() + .unwrap_throw() +} + +fn is(actual: A, expected: A) -> Result<(), JsValue> +where + A: PartialEq + std::fmt::Debug, +{ + if expected == actual { + Ok(()) + } else { + Err(Error::new(&format!( + "Expected:\n {:#?}\nBut got:\n {:#?}", + expected, actual + )) + .into()) + } +} + +#[derive(Clone)] +struct Sender { + sender: mpsc::UnboundedSender>, +} + +impl Sender { + fn send(&self, f: F) + where + F: FnOnce() -> Result, + { + self.sender.unbounded_send(f()).unwrap_throw(); + } +} + +fn mpsc(f: F) -> impl Future, Error = JsValue> +where + F: FnOnce(Sender), +{ + let (sender, receiver) = futures::sync::mpsc::unbounded(); + + f(Sender { sender }); + + receiver + .then(|x| match x { + Ok(a) => a, + Err(_) => unreachable!(), + }) + .collect() +} + +// ---------------------------------------------------------------------------- +// Tests begin here +// ---------------------------------------------------------------------------- + +#[wasm_bindgen_test(async)] +fn new_with_options() -> impl Future { + mpsc(|sender| { + let body = body(); + + let _handler = EventListener::new_with_options( + &body, + "click", + EventListenerOptions { + phase: EventListenerPhase::Capture, + passive: false, + }, + move |e| { + sender.send(|| { + is(e.dyn_into::().is_ok(), true)?; + + Ok(()) + }); + }, + ); + + body.click(); + body.click(); + }) + .and_then(|results| is(results, vec![(), ()])) +} + +#[wasm_bindgen_test(async)] +fn once_with_options() -> impl Future { + mpsc(|sender| { + let body = body(); + + let _handler = EventListener::once_with_options( + &body, + "click", + EventListenerOptions { + phase: EventListenerPhase::Capture, + passive: false, + }, + move |e| { + sender.send(|| { + is(e.dyn_into::().is_ok(), true)?; + + Ok(()) + }); + }, + ); + + body.click(); + body.click(); + }) + .and_then(|results| is(results, vec![()])) +} + +#[wasm_bindgen_test(async)] +fn new() -> impl Future { + mpsc(|sender| { + let body = body(); + + let _handler = EventListener::new(&body, "click", move |e| { + sender.send(|| { + is(e.dyn_into::().is_ok(), true)?; + + Ok(()) + }); + }); + + body.click(); + body.click(); + }) + .and_then(|results| is(results, vec![(), ()])) +} + +#[wasm_bindgen_test(async)] +fn once() -> impl Future { + mpsc(|sender| { + let body = body(); + + let _handler = EventListener::once(&body, "click", move |e| { + sender.send(|| { + is(e.dyn_into::().is_ok(), true)?; + + Ok(()) + }); + }); + + body.click(); + body.click(); + }) + .and_then(|results| is(results, vec![()])) +} + +// TODO is it possible to somehow cleanup the closure after a timeout? +#[wasm_bindgen_test] +fn forget() { + let target = window() + .unwrap_throw() + .document() + .unwrap_throw() + .create_element("div") + .unwrap_throw(); + + let handler = EventListener::new(&target, "click", move |_| {}); + + handler.forget(); +} + +#[wasm_bindgen_test(async)] +fn dynamic_registration() -> impl Future { + mpsc(|sender| { + let body = body(); + + let handler1 = EventListener::new(&body, "click", { + let sender = sender.clone(); + move |_| sender.send(|| Ok(1)) + }); + + let handler2 = EventListener::new(&body, "click", { + let sender = sender.clone(); + move |_| sender.send(|| Ok(2)) + }); + + body.click(); + + drop(handler1); + + body.click(); + + let handler3 = EventListener::new(&body, "click", { + let sender = sender.clone(); + move |_| sender.send(|| Ok(3)) + }); + + body.click(); + + drop(handler2); + + body.click(); + + drop(handler3); + + body.click(); + }) + .and_then(|results| is(results, vec![1, 2, 2, 2, 3, 3])) +} diff --git a/guide/src/SUMMARY.md b/guide/src/SUMMARY.md index 30b3c191..f8a14a75 100644 --- a/guide/src/SUMMARY.md +++ b/guide/src/SUMMARY.md @@ -4,3 +4,4 @@ - [Using Gloo crates](./using-gloo.md) - [Timers](./timers.md) - [Console-timer](./console-timer.md) +- [Events](./events.md) diff --git a/guide/src/events.md b/guide/src/events.md new file mode 100644 index 00000000..5b8ff0bb --- /dev/null +++ b/guide/src/events.md @@ -0,0 +1 @@ +## Events diff --git a/src/lib.rs b/src/lib.rs index 52c3710b..f6ccbe49 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,4 +5,5 @@ // Re-exports of toolkit crates. pub use gloo_console_timer as console_timer; +pub use gloo_events as events; pub use gloo_timers as timers;