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}