custom_elements/
lib.rs

1//! The Web Components standard creates a browser feature that allows you to create reusable components, called Custom Elements.
2//!
3//! While web_sys exposes the browser’s CustomElementRegistry interface, it can be hard to use. Creating a Custom Element requires calling customElements.define() and passing it an ES2015 class that extends HTMLElement, which is not currently possible to do directly from Rust.
4//!
5//! This crate provides a [CustomElement][CustomElement] trait that, when implemented, allows you to encapsulate any Rust structure as a reusable web component without writing any JavaScript. In theory it should be usable with any Rust front-end framework.
6//! ```rust
7//! impl CustomElement for MyWebComponent {
8//!   fn inject_children(&mut self, this: &HtmlElement) {
9//!       inject_style(&this, "p { color: green; }");
10//!       let node = self.view();
11//!       this.append_child(&node).unwrap_throw();
12//!   }
13//!
14//!   fn observed_attributes() -> &'static [&'static str] {
15//!       &["name"]
16//!   }
17//!
18//!   fn attribute_changed_callback(
19//!       &mut self,
20//!       _this: &HtmlElement,
21//!       name: String,
22//!       _old_value: Option<String>,
23//!       new_value: Option<String>,
24//!   ) {
25//!       if name == "name" {
26//!           /* do something... */
27//!       }
28//!   }
29//!
30//!   fn connected_callback(&mut self, _this: &HtmlElement) {
31//!       log("connected");
32//!   }
33//!
34//!   fn disconnected_callback(&mut self, _this: &HtmlElement) {
35//!       log("disconnected");
36//!   }
37//!
38//!   fn adopted_callback(&mut self, _this: &HtmlElement) {
39//!       log("adopted");
40//!   }
41//! }
42//!
43//! #[wasm_bindgen]
44//! pub fn define_custom_elements() {
45//!     MyWebComponent::define("my-component");
46//! }
47//! ```
48
49use std::sync::{Arc, Mutex};
50
51use wasm_bindgen::prelude::*;
52use wasm_bindgen::UnwrapThrowExt;
53use web_sys::{window, HtmlElement};
54
55/// A custom DOM element that can be reused via the Web Components/Custom Elements standard.
56///
57/// Note that your component should implement [Default][std::default::Default], which allows the
58/// browser to initialize a “default” blank component when a new custom element node is created.
59pub trait CustomElement: Default + 'static {
60    /// Appends children to the root element, either to the shadow root in shadow mode or to the custom element itself.
61    /// Per the [Web Components spec](https://html.spec.whatwg.org/multipage/custom-elements.html#custom-element-conformance),
62    /// this is deferred to the first invocation of `connectedCallback()`.
63    /// It will run before [connected_callback](CustomElement::connected_callback).
64    fn inject_children(&mut self, this: &HtmlElement);
65
66    /// Whether a [Shadow root](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM)
67    /// should be attached to the element or not. Shadow DOM encapsulates styles, but makes some DOM manipulation more difficult.
68    ///
69    /// Defaults to `true`.
70    fn shadow() -> bool {
71        true
72    }
73
74    /// The names of the attributes whose changes should be observed. If an attribute name is in this list,
75    /// [attribute_changed_callback](CustomElement::attribute_changed_callback) will be invoked when it changes.
76    /// If it is not, nothing will happen when the DOM attribute changes.
77    fn observed_attributes() -> &'static [&'static str] {
78        &[]
79    }
80
81    /// Invoked when the custom element is instantiated. This can be used to inject any code into the `constructor`,
82    /// immediately after it calls `super()`.
83    fn constructor(&mut self, _this: &HtmlElement) {}
84
85    /// Invoked each time the custom element is appended into a document-connected element.
86    /// This will happen each time the node is moved, and may happen before the element's contents have been fully parsed.
87    fn connected_callback(&mut self, _this: &HtmlElement) {}
88
89    /// Invoked each time the custom element is disconnected from the document's DOM.
90    fn disconnected_callback(&mut self, _this: &HtmlElement) {}
91
92    /// Invoked each time the custom element is moved to a new document.
93    fn adopted_callback(&mut self, _this: &HtmlElement) {}
94
95    /// Invoked each time one of the custom element's attributes is added, removed, or changed.
96    /// To observe an attribute, include it in [observed_attributes](CustomElement::observed_attributes).
97    fn attribute_changed_callback(
98        &mut self,
99        _this: &HtmlElement,
100        _name: String,
101        _old_value: Option<String>,
102        _new_value: Option<String>,
103    ) {
104    }
105
106    /// Specifies the built-in element your element inherits from, if any, by giving its tag name and constructor.
107    /// This is only relevant to customized built-in elements, not autonomous custom elements.
108    /// [Browser support is inconsistent](https://caniuse.com/custom-elementsv1).
109    ///
110    /// Defaults to the equivalent of `extends HTMLElement`, which makes for an autonomous custom element.
111    ///
112    /// To specify your own superclass, import it using `wasm_bindgen`:
113    /// ```
114    /// #[wasm_bindgen]
115    /// extern "C" {
116    ///     #[wasm_bindgen(js_name = HTMLParagraphElement, js_namespace = window)]
117    ///     pub static HtmlParagraphElementConstructor: js_sys::Function;
118    /// }
119    /// impl CustomElement for MyComponent {
120    ///     fn superclass() -> (Option<&'static str>, &'static js_sys::Function) {
121    ///         (Some("p"), &HtmlParagraphElementConstructor)
122    ///     }
123    /// }
124    /// ```
125    fn superclass() -> (Option<&'static str>, &'static js_sys::Function) {
126        (None, &HtmlElementConstructor)
127    }
128
129    /// Must be called somewhere to define the custom element and register it with the DOM Custom Elements Registry.
130    ///
131    /// Note that custom element names must contain a hyphen.
132    ///
133    /// ```rust
134    /// impl CustomElement for MyCustomElement { /* ... */  */}
135    /// #[wasm_bindgen]
136    /// pub fn define_elements() {
137    ///     MyCustomElement::define("my-component");
138    /// }
139    /// ```
140    fn define(tag_name: &'static str) {
141        // constructor function will be called for each new instance of the component
142        let constructor = Closure::wrap(Box::new(move |this: HtmlElement| {
143            let component = Arc::new(Mutex::new(Self::default()));
144
145            // constructor
146            let cmp = component.clone();
147            let constructor = Closure::wrap(Box::new({
148                move |el| {
149                    let mut lock = cmp.lock().unwrap_throw();
150                    lock.constructor(&el);
151                }
152            }) as Box<dyn FnMut(HtmlElement)>);
153            js_sys::Reflect::set(
154                &this,
155                &JsValue::from_str("_constructor"),
156                &constructor.into_js_value(),
157            )
158            .unwrap_throw();
159
160            // inject_children
161            let cmp = component.clone();
162            let inject_children = Closure::wrap(Box::new({
163                move |el| {
164                    let mut lock = cmp.lock().unwrap_throw();
165                    lock.inject_children(&el);
166                }
167            }) as Box<dyn FnMut(HtmlElement)>);
168            js_sys::Reflect::set(
169                &this,
170                &JsValue::from_str("_injectChildren"),
171                &inject_children.into_js_value(),
172            )
173            .unwrap_throw();
174
175            // connectedCallback
176            let cmp = component.clone();
177            let connected = Closure::wrap(Box::new({
178                move |el| {
179                    let mut lock = cmp.lock().unwrap_throw();
180                    lock.connected_callback(&el);
181                }
182            }) as Box<dyn FnMut(HtmlElement)>);
183            js_sys::Reflect::set(
184                &this,
185                &JsValue::from_str("_connectedCallback"),
186                &connected.into_js_value(),
187            )
188            .unwrap_throw();
189
190            // disconnectedCallback
191            let cmp = component.clone();
192            let disconnected = Closure::wrap(Box::new(move |el| {
193                let mut lock = cmp.lock().unwrap_throw();
194                lock.disconnected_callback(&el);
195            }) as Box<dyn FnMut(HtmlElement)>);
196            js_sys::Reflect::set(
197                &this,
198                &JsValue::from_str("_disconnectedCallback"),
199                &disconnected.into_js_value(),
200            )
201            .unwrap_throw();
202
203            // adoptedCallback
204            let cmp = component.clone();
205            let adopted = Closure::wrap(Box::new(move |el| {
206                let mut lock = cmp.lock().unwrap_throw();
207                lock.adopted_callback(&el);
208            }) as Box<dyn FnMut(HtmlElement)>);
209            js_sys::Reflect::set(
210                &this,
211                &JsValue::from_str("_adoptedCallback"),
212                &adopted.into_js_value(),
213            )
214            .unwrap_throw();
215
216            // attributeChangedCallback
217            let cmp = component;
218            let attribute_changed = Closure::wrap(Box::new(move |el, name, old_value, new_value| {
219                let mut lock = cmp.lock().unwrap_throw();
220                lock.attribute_changed_callback(&el, name, old_value, new_value);
221            })
222                as Box<dyn FnMut(HtmlElement, String, Option<String>, Option<String>)>);
223            js_sys::Reflect::set(
224                &this,
225                &JsValue::from_str("_attributeChangedCallback"),
226                &attribute_changed.into_js_value(),
227            )
228            .unwrap_throw();
229        }) as Box<dyn FnMut(HtmlElement)>);
230
231        // observedAttributes is static and needs to be known when the class is defined
232        let attributes = Self::observed_attributes();
233        let observed_attributes = JsValue::from(
234            attributes
235                .iter()
236                .map(|attr| JsValue::from_str(attr))
237                .collect::<js_sys::Array>(),
238        );
239
240        // call out to JS to define the Custom Element
241        let (super_tag, super_constructor) = Self::superclass();
242        make_custom_element(
243            super_constructor,
244            tag_name,
245            Self::shadow(),
246            constructor.into_js_value(),
247            observed_attributes,
248            super_tag,
249        );
250    }
251}
252
253/// Attaches a `<style>` element with the given content to the element,
254/// either to its shadow root (if it exists) or to the custom element itself.
255///
256/// This is an optional helper function; if you use it, you probably want it somewhere
257/// in your [inject_children](CustomElement::inject_children) function.
258pub fn inject_style(this: &HtmlElement, style: &str) {
259    let style_el = window()
260        .unwrap_throw()
261        .document()
262        .unwrap_throw()
263        .create_element("style")
264        .unwrap_throw();
265    style_el.set_inner_html(style);
266    match this.shadow_root() {
267        Some(shadow_root) => shadow_root.append_child(&style_el).unwrap_throw(),
268        None => this.append_child(&style_el).unwrap_throw(),
269    };
270}
271
272/// Attaches a `<link rel="stylesheet">` element with the given URL to the custom element,
273/// either to its shadow root (if it exists) or to the custom element itself.
274///
275/// This is an optional helper function; if you use it, you probably want it somewhere
276/// in your [inject_children](CustomElement::inject_children) function.
277pub fn inject_stylesheet(this: &HtmlElement, url: &str) {
278    let style_el = window()
279        .unwrap_throw()
280        .document()
281        .unwrap_throw()
282        .create_element("link")
283        .unwrap_throw();
284    style_el.set_attribute("rel", "stylesheet").unwrap_throw();
285    style_el.set_attribute("href", url).unwrap_throw();
286    match this.shadow_root() {
287        Some(shadow_root) => shadow_root.append_child(&style_el).unwrap_throw(),
288        None => this.append_child(&style_el).unwrap_throw(),
289    };
290}
291
292// JavaScript shim
293#[wasm_bindgen(module = "/src/make_custom_element.js")]
294extern "C" {
295    fn make_custom_element(
296        superclass: &js_sys::Function,
297        tag_name: &str,
298        shadow: bool,
299        constructor: JsValue,
300        observed_attributes: JsValue,
301        superclass_tag: Option<&str>,
302    );
303}
304
305#[wasm_bindgen]
306extern "C" {
307    #[wasm_bindgen(js_name = HTMLElement, js_namespace = window)]
308    pub static HtmlElementConstructor: js_sys::Function;
309}