diff --git a/Cargo.toml b/Cargo.toml index 0d387c1..afaf819 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,8 @@ features = [ "Event", "EventTarget", "Element", + "Node", + "Text", "HtmlBaseElement", "HtmlElement", "HtmlTemplateElement", diff --git a/src/lib.rs b/src/lib.rs index f966ab5..f74fb55 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,75 +1,167 @@ use js_sys::Function; use wasm_bindgen::{convert::IntoWasmAbi, prelude::*, JsCast, JsValue}; -use web_sys::window; +use web_sys::{window, Element, HtmlElement}; type Result = std::result::Result; -pub trait CustomElementImpl: IntoWasmAbi { - fn class_name() -> &'static str; - fn element_name() -> &'static str; +// TODO(jwall): Trait methods can't be exported out to js yet so we'll need a wrapper object or we'll need to `Derive` this api in a prop-macro. +pub trait CustomElementImpl: IntoWasmAbi {} - fn construct() -> Self; -} - -pub struct WebComponentHandle { +pub struct WebComponentHandle { pub impl_handle: Closure T>, pub element_constructor: Function, } -pub fn define_web_component() -> Result> -where - T: CustomElementImpl + 'static, -{ - let body = format!( - "class {name} extends HTMLElement {{ - constructor() {{ - super(); - //this.impl = impl(); - }} -}} -customElements.define(\"{element_name}\", {name}); -var element = customElements.get(\"{element_name}\"); -return element;", - name = T::class_name(), - element_name = T::element_name(), - ); - let fun = Function::new_with_args("impl", &body); - let f: Box T> = Box::new(|| T::construct()); - // TODO(jwall): Check the lifetimes on this guy. - let constructor_handle = Closure::wrap(f); - let element = fun - .call1( - &window().unwrap(), - constructor_handle.as_ref().unchecked_ref::(), - )? - .dyn_into()?; - Ok(WebComponentHandle { - element_constructor: element, - impl_handle: constructor_handle, - }) -} - #[cfg(test)] mod tests { use super::*; use wasm_bindgen::JsCast; use wasm_bindgen_test::wasm_bindgen_test; + use web_sys::Text; wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); #[wasm_bindgen] - pub struct MyElementImpl(); + #[derive(Default, Debug)] + pub struct MyElementImpl {} - impl CustomElementImpl for MyElementImpl { - fn class_name() -> &'static str { + impl CustomElementImpl for MyElementImpl {} + + impl MyElementImpl { + pub fn class_name() -> &'static str { "MyElement" } - fn element_name() -> &'static str { + pub fn element_name() -> &'static str { "my-element" } - fn construct() -> Self { - Self() + pub fn define() -> Result> { + let registry = window().unwrap().custom_elements(); + let maybe_element = registry.get(Self::element_name()); + if maybe_element.is_truthy() { + return Err("Custom Element has already been defined".into()); + } + let body = format!( + "class {name} extends HTMLElement {{ + constructor() {{ + super(); + this._impl = impl(); + }} + + connectedCallback() {{ + this._impl.connected_impl(this); + }} + + disconnectedCallback() {{ + this._impl.disconnected_impl(this); + }} + + static get observedAttributes() {{ + console.log('observed attributes: ', attrs); + return attrs; + }} + + adoptedCallback() {{ + console.log('In adoptedCallback'); + this._impl.adopted_impl(this); + }} + + attributeChangedCallback(name, oldValue, newValue) {{ + this._impl.attribute_changed_impl(this, name, oldValue, newValue); + }} +}} +customElements.define(\"{element_name}\", {name}); +var element = customElements.get(\"{element_name}\"); +return element;", + name = Self::class_name(), + element_name = Self::element_name(), + ); + let fun = Function::new_with_args("impl, attrs", &body); + let f: Box Self> = Box::new(|| { + let obj = Self::new(); + obj + }); + let constructor_handle = Closure::wrap(f); + let element = fun + .call2( + &window().unwrap(), + constructor_handle.as_ref().unchecked_ref::(), + &Self::observed_attributes(), + )? + .dyn_into()?; + Ok(WebComponentHandle { + element_constructor: element, + impl_handle: constructor_handle, + }) + } + } + + #[wasm_bindgen] + impl MyElementImpl { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self::default() + } + + #[wasm_bindgen] + pub fn create() -> Element { + window() + .unwrap() + .document() + .unwrap() + .create_element(Self::element_name()) + .unwrap() + } + + #[wasm_bindgen] + pub fn connected_impl(&self, element: &HtmlElement) { + log("Firing connected call back".to_owned()); + let node = Text::new().unwrap(); + node.set_text_content(Some("Added a text node on connect".into())); + element.append_child(&node).unwrap(); + log_with_val("element: ".to_owned(), element); + } + + #[wasm_bindgen] + pub fn disconnected_impl(&self, element: &HtmlElement) { + log("Firing discconnected call back".to_owned()); + let node = element.first_child().unwrap(); + element.remove_child(&node).unwrap(); + } + + #[wasm_bindgen] + pub fn adopted_impl(&self, element: &HtmlElement) { + log("Firing adopted call back".to_owned()); + let node = Text::new().unwrap(); + node.set_text_content(Some("Added a text node on adopt".into())); + element.append_child(&node).unwrap(); + log_with_val("element: ".to_owned(), element); + } + + pub fn observed_attributes() -> js_sys::Array { + let attrs = js_sys::Array::new(); + attrs.push(&JsValue::from_str("class")); + attrs + } + + #[wasm_bindgen] + pub fn attribute_changed_impl( + &self, + element: &HtmlElement, + name: JsValue, + old_value: JsValue, + new_value: JsValue, + ) { + log("Firing attribute changed callback".to_owned()); + let node = element.first_child().unwrap(); + node.set_text_content(Some(&format!( + "Setting {} from {} to {}", + name.as_string().unwrap_or("None".to_owned()), + old_value.as_string().unwrap_or("None".to_owned()), + new_value.as_string().unwrap_or("None".to_owned()), + ))); + element.append_child(&node).unwrap(); + log_with_val("element: ".to_owned(), element); } } @@ -81,21 +173,47 @@ mod tests { pub fn log_with_val(message: String, val: &JsValue); } + // NOTE(jwall): We can only construct the web component once and since the lifetime of the component internals is tied + // to the handle we run this all in one single function. #[wasm_bindgen_test] - fn test_component_definition() { - let obj = define_web_component::().expect("Failed to define web component"); + fn test_component() { + let obj = MyElementImpl::define().expect("Failed to define web component"); let fun = obj.element_constructor.dyn_ref::().unwrap(); assert_eq!(fun.name(), MyElementImpl::class_name()); - - let element = window() - .unwrap() - .document() - .unwrap() - .create_element(MyElementImpl::element_name()) - .unwrap(); + let element = MyElementImpl::create(); assert_eq!( element.tag_name().to_uppercase(), MyElementImpl::element_name().to_uppercase() ); + let document = window().unwrap().document().unwrap(); + let body = document.body().unwrap(); + + // Test the connected callback + let node = body.append_child(element.as_ref()).unwrap(); + assert_eq!( + element.text_content().unwrap(), + "Added a text node on connect" + ); + + // Test the disconnected callback + body.remove_child(&node).unwrap(); + assert_eq!(element.text_content().unwrap(), ""); + + body.append_child(element.as_ref()).unwrap(); + element.set_attribute("class", "foo").unwrap(); + assert_eq!( + element.text_content().unwrap(), + "Setting class from None to foo" + ); + + // Test the adopted callback + // First we need a new window with a new document to perform the adoption with. + let new_window = window().unwrap().open().unwrap().unwrap(); + // Then we can have the new document adopt this node. + new_window.document().unwrap().adopt_node(&element).unwrap(); + assert_eq!( + element.text_content().unwrap(), + "Added a text node on adopt" + ); } }