This might be a bit confusing. I am trying to access innerHTML or childNodes from my custom element. Is it possible to get access to the original DOM structure from the web component import file? BEFORE attachShadow?
In the below example I am trying to get access to the src of the two jookah-images from my jookah-gallery import file.
Disclaimer: Im a total noob when it comes to shadow DOM and web components so if there are any major mistakes I'd love to understand why. Thanks for any help!
index.html
<jookah-gallery>
//here
<jookah-image class="gallery-image" src="http://merehead.com/blog/wp-content/uploads/gradient-design.jpeg">
<jookah-image class="gallery-image" src="https://webgradients.com/public/webgradients_png/035%20Itmeo%20Branding.png">
</jookah-gallery>
import file for jookah-gallery:
(function(owner) {
class jookahGallery extends HTMLElement {
constructor() {
super();
//this returns false
if (this.hasChildNodes()) {
console.log('there are nodes')
}else{
console.log('NO nodes')
}
//shadow DOM attach
const shadowRoot = this.attachShadow({mode: 'open'});
const template = owner.querySelector('#jookah-gallery-template');
const instance = template.content.cloneNode(true);
shadowRoot.appendChild(instance);
}
// ---------------- object events -------------------------//
connectedCallback() {
}
render(){
}
disconnectedCallback(){
}
attributeChangedCallback(){
}
// ---------------- methods ------------------------//
}
customElements.define('jookah-gallery', jookahGallery);
})(document.currentScript.ownerDocument);
According to the Spec you are not supposed to inspect, change, add and children in the constructor of your Web Component.
https://w3c.github.io/webcomponents/spec/custom/#custom-element-conformance
Instead you need to move the reading of the children into your connected callback:
class jookahGallery extends HTMLElement {
constructor() {
super();
this._childrenRead = false;
const shadowRoot = this.attachShadow({mode: 'open'});
const template = document.createElement('template');
template.innerHtml = `Place your template here`;
const instance = template.content.cloneNode(true);
shadowRoot.appendChild(instance);
}
connectedCallback() {
if (!this._childrenRead) {
this._childrenRead = true;
if (this.hasChildNodes()) {
console.log('there are nodes')
}else{
console.log('NO nodes')
}
}
}
}
customElements.define('jookah-gallery', jookahGallery);
You can also use <slot> to embed your children. But there are some CSS issues you need to be aware of when using slots.
Remember that shadowDOM is not supported in all browsers and is not a simple polyfill. So if you are only working on Chrome and Safari, go for it. If you are planning to support a broader range of browsers then you might not want to use ShadowDOM just yet.
https://alligator.io/web-components/composing-slots-named-slots/
Also read more here: How to use child elements in Web Components
Related
I have a Custom Element that should have many HTML children. I had this problem when initializing it in class' constructor (The result must not have children). I understand why and know how to fix it. But exactly how I should design my class around it now? Please consider this code:
class MyElement extends HTMLElement {
constructor() {
super();
}
// Due to the problem, these codes that should be in constructor are moved here
connectedCallback() {
// Should have check for first time connection as well but ommited here for brevity
this.innerHTML = `<a></a><div></div>`;
this.a = this.querySelector("a");
this.div = this.querySelector("div");
}
set myText(v) {
this.a.textContent = v;
}
set url(v) {
this.a.href = v;
}
}
customElements.define("my-el", MyElement);
const frag = new DocumentFragment();
const el = document.createElement("my-el");
frag.append(el); // connectedCallback is not called yet since it's not technically connected to the document.
el.myText = "abc"; // Now this wouldn't work because connectedCallback isn't called
el.url = "https://www.example.com/";
Since MyElement would be used in a list, it's set up beforehand and inserted into a DocumentFragment. How do you handle this?
Currently I am keeping a list of pre-connected properties and set them when it's actually connected but I can't imagine this to be a good solution. I also thought of another solution: have an init method (well I just realized nothing prevents you from invoking connectedCallback yourself) that must be manually called before doing anything but I myself haven't seen any component that needs to do that and it's similar to the upgrade weakness mentioned in the above article:
The element's attributes and children must not be inspected, as in the non-upgrade case none will be present, and relying on upgrades makes the element less usable.
Custom elements are tricky to work with.
The shadowDOM
if the shadowDOM features and restrictions suits your needs, you should go for it, it's straightforward :
customElements.define('my-test', class extends HTMLElement{
constructor(){
super();
this.shadow = this.attachShadow({mode: 'open'});
const div = document.createElement('div');
div.innerText = "Youhou";
this.shadow.appendChild(div);
}
});
const myTest = document.createElement('my-test');
console.log(myTest.shadow.querySelector('div')); //Outputs your div.
More about it there
Without shadowDOM
Sometimes, the shadowDOM is too restrictive. It provides a really great isolation, but if your components are designed to be used in an application and not be distributed to everyone to be used in any project, it can really be a nightmare to manage.
Keep in mind that the solution I provide below is just an idea of how to solve this problem, you may want to manage much more than that, especialy if you work with attributeChangedCallback, if you need to support component reloading or many other use cases not covered by this answer.
If, like me, you don't want the ShadowDOM features, and there is many reasons not to want it (cascading CSS, using a library like fontawesome without having to redeclare the link in every component, global i18n mechanism, being able to use a custom component as any other DOM tag, and so on), there is some clue :
Create a base class that will handle it in the same way for all components, let's call it BaseWebComponent.
class BaseWebComponent extends HTMLElement{
//Will store the ready promise, since we want to always return
//the same
#ready = null;
constructor(){
super();
}
//Must be overwritten in child class to create the dom, read/write attributes, etc.
async init(){
throw new Error('Must be implemented !');
}
//Will call the init method and await for it to resolve before resolving itself.
//Always return the same promise, so several part of the code can
//call it safely
async ready(){
//We don't want to call init more that one time
//and we want every call to ready() to return the same promise.
if(this.#ready) return this.#ready
this.#ready = new Promise(resolve => resolve(this.init()));
return this.#ready;
}
connectedCallback(){
//Will init the component automatically when attached to the DOM
//Note that you can also call ready to init your component before
//if you need to, every subsequent call will just resolve immediately.
this.ready();
}
}
Then I create a new component :
class MyComponent extends BaseWebComponent{
async init(){
this.setAttribute('something', '54');
const div = document.createElement('div');
div.innerText = 'Initialized !';
this.appendChild(div);
}
}
customElements.define('my-component', MyComponent);
/* somewhere in a javascript file/tag */
customElements.whenDefined('my-component').then(async () => {
const component = document.createElement('my-component');
//Optional : if you need it to be ready before doing something, let's go
await component.ready();
console.log("attribute value : ", component.getAttribute('something'));
//otherwise, just append it
document.body.appendChild(component);
});
I do not know any approach, without shdowDOM, to init a component in a spec compliant way that do not imply to automaticaly call a method.
You should be able to call this.ready() in the constructor instead of connectedCallback, since it's async, document.createElement should create your component before your init function starts to populate it. But it can be error prone, and you must await that promise to resolve anyway to execute code that needs your component to be initialized.
You need (a) DOM to assign content to it
customElements.define("my-el", class extends HTMLElement {
constructor() {
super().attachShadow({mode:"open"}).innerHTML=`<a></a>`;
this.a = this.shadowRoot.querySelector("a");
}
set myText(v) {
this.a.textContent = v;
}
});
const frag = new DocumentFragment();
const el = document.createElement("my-el");
frag.append(el);
el.myText = "abc";
document.body.append(frag);
Without shadowDOM you could store the content and process it in the connectedCallback
customElements.define("my-el", class extends HTMLElement {
constructor() {
super().atext = "";
}
connectedCallback() {
console.log("connected");
this.innerHTML = `<a>${this.atext}</a>`;
this.onclick = () => this.myText = "XYZ";
}
set myText(v) {
if (this.isConnected) {
console.warn("writing",v);
this.querySelector("a").textContent = v;
} else {
console.warn("storing value!", v);
this.atext = v;
}
}
});
const frag = new DocumentFragment();
const el = document.createElement("my-el");
frag.append(el);
el.myText = "abc";
document.body.append(frag);
Since there are many great answers, I am moving my approach into a separate answer here. I tried to use "hanging DOM" like this:
class MyElement extends HTMLElement {
constructor() {
super();
const tmp = this.tmp = document.createElement("div"); // Note in a few cases, div wouldn't work
this.tmp.innerHTML = `<a></a><div></div>`;
this.a = tmp.querySelector("a");
this.div = tmp.querySelector("div");
}
connectedCallback() {
// Should have check for first time connection as well but ommited here for brevity
// Beside attaching tmp as direct descendant, we can also move all its children
this.append(this.tmp);
}
set myText(v) {
this.a.textContent = v;
}
set url(v) {
this.a.href = v;
}
}
customElements.define("my-el", MyElement);
const frag = new DocumentFragment();
const el = document.createElement("my-el");
frag.append(el); // connectedCallback is not called yet since it's not technically connected to the document.
el.myText = "abc"; // Now this wouldn't work because connectedCallback isn't called
el.url = "https://www.example.com/";
document.body.append(frag);
It "works" although it "upsets" my code a lot, for example, instead of this.querySelector which is more natural, it becomes tmp.querySelector. Same in methods, if you do a querySelector, you have to make sure tmp is pointing to the correct Element that the children are in. I have to admit this is probably the best solution so far.
I'm not exactly sure about what makes your component so problematic, so I'm just adding what I would do:
class MyElement extends HTMLElement {
#a = document.createElement('a');
#div = document.createElement('div');
constructor() {
super().attachShadow({mode:'open'}).append(this.#a, this.#div);
console.log(this.shadowRoot.innerHTML);
}
set myText(v) { this.#a.textContent = v; }
set url(v) { this.#a.href = v; }
}
customElements.define("my-el", MyElement);
const frag = new DocumentFragment();
const el = document.createElement("my-el");
el.myText = 'foo'; el.url= 'https://www.example.com/';
frag.append(el);
document.body.append(el);
My understanding is that data is passed to a custom html element via its attributes and sent out by dispatching a CustomEvent.
JavaScript objects can obviously be sent out in the event's detail field, but what if the element needs a lot of data passed into it. Is there a way to provide it with an object in JavaScript.
What if the element for instance contains a variable number of parts that needs to be initialized or changed dynamically (e.g. a table with a variable number of rows)? I can imagine setting and modifying an attribute consisting of a JSON string that is parsed inside the component, but it does not feel like an elegant way to proceed:
<my-element tableRowProperties="[{p1:'v1', p2:'v2'}, {p1:'v1',p2:'v2'}, {p1:'v1',p2:'v2'}]"></my-element>
Or can you make the element listen to events from the outside that contains a payload of data?
Passing Data In
If you really want/need to pass large amounts of data into your component then you can do it four different ways:
1) Use a property. This is the simplest since you just pass in the Object by giving the value to the element like this: el.data = myObj;
2) Use an attribute. Personally I hate this way of doing it this way, but some frameworks require data to be passed in through attributes. This is similar to how you show in your question. <my-el data="[{a:1},{a:2}....]"></my-el>. Be careful to follow the rules related to escaping attribute values. If you use this method you will need to use JSON.parse on your attribute and that may fail. It can also get very ugly in the HTML to have the massive amount of data showing in a attribute.
3 Pass it in through child elements. Think of the <select> element with the <option> child elements. You can use any element type as children and they don't even need to be real elements. In your connectedCallback function your code just grabs all of the children and convert the elements, their attributes or their content into the data your component needs.
4 Use Fetch. Provide a URL for your element to go get its own data. Think of <img src="imageUrl.png"/>. If your already has the data for your component then this might seem like a poor option. But the browser provides a cool feature of embedding data that is similar to option 2, above, but is handled automatically by the browser.
Here is an example of using embedded data in an image:
img {
height: 32px;
width: 32px;
}
<img src="data:image/svg+xml;charset=utf8,%3C?xml version='1.0' encoding='utf-8'?%3E%3Csvg version='1.1' xmlns='http://www.w3.org/2000/svg' x='0px' y='0px' viewBox='0 0 314.7 314.7'%3E%3Cstyle type='text/css'%3E .st0{fill:transparent;stroke:%23231F20;stroke-width:12;} .st1{fill:%23231F20;stroke:%23231F20;stroke-width:10;stroke-linejoin:round;stroke-miterlimit:10;} %3C/style%3E%3Cg%3E%3Ccircle class='st0' cx='157.3' cy='157.3' r='150.4'/%3E%3Cpolygon class='st1' points='108,76.1 248.7,157.3 108,238.6'/%3E%3C/g%3E%3C/svg%3E">
And here is an example of using embedded data in a web component:
function readSrc(el, url) {
var fetchHeaders = new Headers({
Accept: 'application/json'
});
var fetchOptions = {
cache: 'default',
headers: fetchHeaders,
method: 'GET',
mode: 'cors'
};
return fetch(url, fetchOptions).then(
(resp) => {
if (resp.ok) {
return resp.json();
}
else {
return {
error: true,
status: resp.status
}
}
}
).catch(
(err) => {
console.error(err);
}
);
}
class MyEl extends HTMLElement {
static get observedAttributes() {
return ['src'];
}
attributeChangedCallback(attrName, oldVal, newVal) {
if (oldVal !== newVal) {
this.innerHtml = '';
readSrc(this, newVal).then(
data => {
this.innerHTML = `<pre>
${JSON.stringify(data,0,2)}
</pre>`;
}
);
}
}
}
// Define our web component
customElements.define('my-el', MyEl);
<!--
This component would go load its own data from "data.json"
<my-el src="data.json"></my-el>
<hr/>
The next component uses embedded data but still calls fetch as if it were a URL.
-->
<my-el src="data:json,[{"a":9},{"a":8},{"a":7}]"></my-el>
You can do that same this using XHR, but if your browser supports Web Components then it probably supports fetch. And there are several good fetch polyfills if you really need one.
The best advantage to using option 4 is that you can get your data from a URL and you can directly embed your data. And this is exactly how most pre-defined HTML elements, like <img> work.
UPDATE
I did think of a 5th way to get JSON data into an object. That is by using a <template> tag within your component. This still required you to call JSON.parse but it can clean up your code because you don't need to escape the JSON as much.
class MyEl extends HTMLElement {
connectedCallback() {
var data;
try {
data = JSON.parse(this.children[0].content.textContent);
}
catch(ex) {
console.error(ex);
}
this.innerHTML = '';
var pre = document.createElement('pre');
pre.textContent = JSON.stringify(data,0,2);
this.appendChild(pre);
}
}
// Define our web component
customElements.define('my-el', MyEl);
<my-el>
<template>[{"a":1},{"b":"<b>Hi!</b>"},{"c":"</template>"}]</template>
</my-el>
Passing Data Out
There are three ways to get data out of the component:
1) Read the value from a property. This is ideal since a property can be anything and would normally be in the format of the data you want. A property can return a string, an object, a number, etc.
2) Read an attribute. This requires the component to keep the attribute up to date and may not be optimal since all attributes are strings. So your user would need to know if they need to call JSON.parse on your value or not.
3) Events. This is probably the most important thing to add to a component. Events should trigger when state changes in the component. Events should trigger based on user interactions and just to alert the user that something has happened or that something is available. Traditionally you would include the relevant data in your event. This reduces the amount of code the user of your component needs to write. Yes, they can still read properties or attributes, but if your events include all relevant data then they probably won't need to do anything extra.
There is a 6th way that is really similar to #Intervalia's answer above but uses a <script> tag instead of a <template> tag.
This is the same approach used by a Markdown Element.
class MyEl extends HTMLElement {
connectedCallback() {
var data;
try {
data = JSON.parse(this.children[0].innerHTML);
}
catch(ex) {
console.error(ex);
}
this.innerHTML = '';
var pre = document.createElement('pre');
pre.textContent = JSON.stringify(data,0,2);
this.appendChild(pre);
}
}
// Define our web component
customElements.define('my-el', MyEl);
<my-el>
<script type="application/json">[{"a":1},{"b":"<b>Hi!</b>"},{"c":"</template>"}]</script>
</my-el>
If you are using Polymer based web components, the passing of data could be done by data binding. Data could be stored as JSON string within attribute of and passed via context variable.
<p>JSON Data passed via HTML attribute into context variable of and populating the variable into combobox.</p>
<dom-bind><template>
<iron-ajax url='data:text/json;charset=utf-8,
[{"label": "Hydrogen", "value": "H"}
,{"label": "Oxygen" , "value": "O"}
,{"label": "Carbon" , "value": "C"}
]'
last-response="{{lifeElements}}" auto handle-as="json"></iron-ajax>
<vaadin-combo-box id="cbDemo"
label="Label, value:[[cbDemoValue]]"
placeholder="Placeholder"
items="[[lifeElements]]"
value="{{ cbDemoValue }}"
>
<template>
[[index]]: [[item.label]] <b>[[item.value]]</b>
</template>
</vaadin-combo-box>
<vaadin-combo-box label="Disabled" disabled value="H" items="[[lifeElements]]"></vaadin-combo-box>
<vaadin-combo-box label="Read-only" readonly value="O" items="[[lifeElements]]"></vaadin-combo-box>
<web-elemens-loader selection="
#polymer/iron-ajax,
#vaadin/vaadin-element-mixin/vaadin-element-mixin,
#vaadin/vaadin-combo-box,
"></web-elemens-loader>
</template></dom-bind>
<script src="https://cdn.xml4jquery.com/web-elements-loader/build/esm-unbundled/node_modules/#webcomponents/webcomponentsjs/webcomponents-loader.js"></script><script type="module" src="https://cdn.xml4jquery.com/web-elements-loader/build/esm-unbundled/src/web-elemens-loader.js"></script>
Using a tiny lib such as Lego would allow you to write the following:
<my-element :tableRowProperties="[{p1:'v1', p2:'v2'}, {p1:'v1',p2:'v2'}, {p1:'v1',p2:'v2'}]"></my-element>
and within your my-element.html web-component:
<template>
<table>
<tr :for="row in state.tableRowProperties">
<td>${row.p1}</td>
<td>${row.p2}</td>
</tr>
</template>
<script>
this.init() {
this.state = { tableRowPropoerties: [] }
}
</script>
I know this has been answered, but here is an approach I took. I know it's not rocket science and there are probably reasons not to do it this way; however, for me, this worked great.
This is an indirect approach to pass in data where an attribute called wc_data is passed in the custom element which is a 'key' that can be used one time.
You can obviously do whatever with the wc-data like callbacks and "callins" into the custom-tag.
link to codesandbox
files:
wc_data.ts
export const wc_data: {
[name: string]: any,
get(key: string): any,
set(key: string, wc_data: any): any
} = {
get(key: string): any {
const wc_data = this[key];
delete this[key];
return wc_data;
},
set(p_key: string, wc_data: any) {
this[p_key] = wc_data;
}
}
CustomTag.ts
import { wc_data } from './wc_data';
const template = document.createElement('template');
template.innerHTML = `
<style>
.custom-tag {
font-size: 1.6em;
}
</style>
<button class="custom-tag">Hello <span name="name"></span>, I am your <span name="relation"></span></button>
`;
class CustomTag extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
callin() {
console.log('callin called');
}
connectedCallback() {
const v_wc_data = wc_data.get(this.getAttribute('wc-data'));
console.log('wc_data', v_wc_data);
const v_name = this.shadowRoot.querySelector('[name="name"]');
const v_relation = this.shadowRoot.querySelector('[name="relation"]');
v_name.innerHTML = v_wc_data.name;
v_relation.innerHTML = v_wc_data.relation;
const v_button = this.shadowRoot.querySelector('button');
v_button.style.color = v_wc_data.color;
v_wc_data.element = this;
v_button.addEventListener('click', () => v_wc_data.callback?.());
}
disconnectedCallback() {
}
}
window.customElements.define('custom-tag', CustomTag);
console.log('created custom-tag element');
export default {};
SomeTsFile.ts
wc_data.set('tq', {
name: 'Luke',
relation: 'father',
color: 'blue',
element: undefined,
callback() {
console.log('the callback worked');
const v_tq_element = this.element;
console.log(this.element);
v_tq_element.callin();
},
});
some html..
<div>stuff before..</div>
<custom-tag wc_data="tq" />
<div>stuff after...</div>
Thanks to the other contributors, I came up with this solution which seems somewhat simpler. No json parsing. I use this example to wrap the entire component in a-href to make the block clickable:
customElements.define('ish-marker', class extends HTMLElement {
constructor() {
super()
const template = document.getElementById('ish-marker-tmpl').content
const wrapper = document.createElement("a")
wrapper.appendChild( template.cloneNode(true) )
wrapper.setAttribute('href', this.getAttribute('href'))
const shadowRoot = this.attachShadow({mode: 'open'}).appendChild( wrapper )
}
})
<ish-marker href="https://go-here.com">
...
// other things, images, buttons.
<span slot='label'>Click here to go-here</span>
</ish-marker>
I'm using custom elements, which are very nice.
But I'm facing a problem :
When the connectedCallback() function is called, it seems that the node is not yet at its place in the DOM, thus I cannot access its parents - and I need them.
class myElement extends HTMLElement{
constructor() {
super();
this.tracklist = undefined;
}
connectedCallback(){
this.render();
}
render(){
this.tracklist = this.closest('section');
// following code requires this.tracklist!
// ...
}
window.customElements.define('my-element', myElement);
How could I be sure the parent nodes are accessible before calling render() ?
Thanks !
It is a known issue:
connectedCallback does not mean your element is or is not fully parsed.
Custom Elements is lacking a parsedCallback method
See all the answers at:
textContent empty in connectedCallback() of a custom HTMLElement
connectedcallback-of-a-custom-htmlelement
How to have a 'connectedCallback' for when all child custom elements have been connected
TL;DR;
The accepted method is to delay your render method:
connectedCallback(){
setTimeout(this.render);
}
It seems that the connectedCallback cannot access other elements in relation to itself when it hasn't been parsed yet. This kind of makes sense if you consider that a custom-element has to be able to live anywhere in the DOM without being dependent on another element. So if there were no parent to be selected, the element would probably not work properly.
A way to do this is to modify the render method to take an argument which will set the tracklist property dynamically to the custom element. Then select the my-element element from the DOM and look for the section.
Then use the customElements.whenDefined method to connect the section and my-element together whenever the custom element is ready. This method returns a Promise that resolves whenever the custom element is defined and gives you the ability to execute a callback.
See example below:
// Do something whenever the element is ready.
window.addEventListener('load', function() {
// Wait for the document to load so the DOM has been parsed.
window.customElements.whenDefined('my-element').then(() => {
const myElement = document.querySelector('my-element');
// Only do something if the element exists on the page.
if (myElement !== null) {
const tracklist = myElement.closest('section');
myElement.render(tracklist);
console.log(myElement.tracklist);
}
});
});
// Create element.
class myElement extends HTMLElement{
constructor() {
super();
this.tracklist = null;
}
render(tracklist){
this.tracklist = tracklist;
// following code requires this.tracklist!
// ...
}
}
// Define element.
window.customElements.define('my-element', myElement);
<section>
<my-element></my-element>
</section>
If I have been unclear or you have questions, please let me know.
Have a nice day!
I haven't tested this out but seems like a Promise might work:
// DomReady.js
class DomReady extends HTMLElement {
constructor() {
super();
this.domReadyPromise = new Promise(resolve => (this.domReadyResolve = resolve));
}
connectedCallback() {
this.domReadyResolve();
}
domReady() { return this.domReadyPromise; }
}
// ParentCustom.js
class ParentCustom extends DomReady {
connectedCallback() {
super.connectedCallback();
...
}
}
// ChildCustom.js
class ChildCustom extends HTMLElement {
async connectedCallback() {
await document.querySelector('parent-custom').domReady();
}
}
When creating an HTML custom element with a JSON string embedded by the user (though the type of string is not relevant here) ...
<my-elem>
{ "some":"content" }
</my-elem>
I would like to JSON.parse it like this ...
class MyElement extends HTMLElement {
constructor() {
super();
this.root = this.attachShadow({ mode:'open' });
this.root.appendChild(template.content.cloneNode(true));
}
connectedCallback() {
JSON.parse(this.innerHTML);
}
}
customElements.define('my-elem', MyElement);
const template = document.createElement('template');
template.innerHTML = `irrelevant`;
... and get a perfect result with Firefox v.63.
But running this with Chrome v.71 I get
Uncaught SyntaxError: Unexpected end of JSON input
due to this.innerHTML returning an empty string.
I also tried other DOM methods to access the textual content, but all of them failed also.
Now I'm rather clueless, how to get this to work with Chrome.
Btw: Using <slot> is of no help, since I do not want to render the textual content ... only access it for parsing.
Resolved:
Put the template definition before the class definition.
Ensure having the script defining the custom element inserted behind all custom element instances in the HTML document.
Why not make it more flexible and support both a src attribute and a data property?
class MyElement extends HTMLElement {
static get observedAttributes() {
return ['src'];
}
constructor() {
super();
this.attachShadow({ mode:'open' });
this._data = {
name: '',
address: ''
};
}
attributeChangedCallback(attrName, oldVal, newVal) {
if (oldVal !== newVal) {
const el = this;
fetch(newVal).then(resp => resp.json()).then(
data => {
el._data = data;
el.render();
}
);
}
}
render() {
this.shadowRoot.innerHTML = `
<div>
<div>${this._data.name}</div>
<div>${this._data.address}</div>
</div>`;
}
set address(val) {
this._data.address = val;
this.render();
}
set name(val) {
this._data.name = val;
this.render();
}
}
customElements.define('my-elem', MyElement);
setTimeout(() => {
let el = document.querySelector('my-elem');
el.name = 'The Joker';
el.address = 'Gothem';
setTimeout(() => {
el.setAttribute('src', 'data:application/json,%7B%22name%22:%22Thanos%22,%22address%22:%22Titan%22%7D')
}, 1500);
}, 1500);
<my-elem src="data:application/json,%7B%22name%22:%22Darth%20Vader%22,%22address%22:%22Death%20Star%22%7D"></my-elem>
The advantage to using a src attribute is that you can pass in JSON or you can pass in a URL that will return the JSON.
The properties allow you to change individual values in your DOM.
Changing the entire innerHTML may not be the right thing to do, but with small amounts of DOM it could be. You can also change individual values on the DOM or use something like LitHtml.
You should wait for the content to be present.
In most cases a simple delay could resolve the problem:
connectedCallback() {
setTimeout( () => JSON.parse(this.innerHTML) )
}
Alternatly, actually <slot> could help with the slotchange event. You can hide the rendering or remove the content if you don't want it.
How can I iterate over instances of one custom element within the shadow dom of another custom element? HTMLCollections don't seem to behave as expected. (I'm a jQuerian and a novice when it comes to vanilla js, so I'm sure I'm making an obvious error somewhere).
HTML
<spk-root>
<spk-input></spk-input>
<spk-input></spk-input>
</spk-root>
Custom Element Definitions
For spk-input:
class SpektacularInput extends HTMLElement {
constructor() {
super();
}
}
window.customElements.define('spk-input', SpektacularInput);
For spk-root:
let template = document.createElement('template');
template.innerHTML = `
<canvas id='spektacular'></canvas>
<slot></slot>
`;
class SpektacularRoot extends HTMLElement {
constructor() {
super();
let shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.appendChild(template.content.cloneNode(true));
}
update() {
let inputs = this.getElementsByTagName('spk-input')
}
connectedCallback() {
this.update();
}
}
window.customElements.define('spk-root', SpektacularRoot);
Here's the part I don't understand. Inside the update() method:
console.log(inputs) returns an HTMLCollection:
console.log(inputs)
// output
HTMLCollection []
0: spk-input
1: spk-input
length: 2
__proto__: HTMLCollection
However, the HTMLCollection is not iterable using a for loop, because it has no length.
console.log(inputs.length)
// output
0
Searching SO revealed that HTMLCollections are array-like, but not arrays. Trying to make it an array using Array.from(inputs) or the spread operator results in an empty array.
What's going on here? How can I iterate over the spk-input elements within spk-root from the update() method?
I'm using gulp-babel and gulp-concat and using Chrome. Let me know if more info is needed. Thanks in advance.
Edit: To clarify, calling console.log(inputs.length) from within the update() outputs 0 instead of 2.
The reason will be that connectedCallback() of a custom element in certain cases will be called as soon as the browser meets the opening tag of the custom element, with children not being parsed, and thus, unavailable. This does e.g. happen in Chrome if you define the elements up front and the browser then parses the HTML.
That is why let inputs = this.getElementsByTagName('spk-input') in your update() method of the outer <spk-root> cannot find any elements. Don't let yourself be fooled by misleading console.log output there.
I've just recently taken a deep dive into this topic, and suggested a solution using a HTMLBaseElement class:
https://gist.github.com/franktopel/5d760330a936e32644660774ccba58a7
Andrea Giammarchi (the author of document-register-element polyfill for custom elements in non-supporting browsers) has taken on that solution suggestion and created an npm package from it:
https://github.com/WebReflection/html-parsed-element
As long as you don't need dynamic creation of your custom elements, the easiest and most reliable fix is to create the upgrade scenario by putting your element defining scripts at the end of the body.
If you're interested in the discussion on the topic (long read!):
https://github.com/w3c/webcomponents/issues/551
Here's the full gist:
HTMLBaseElement class solving the problem of connectedCallback being called before children are parsed
There is a huge practical problem with web components spec v1:
In certain cases connectedCallback is being called when the element's child nodes are not yet available.
This makes web components dysfunctional in those cases where they rely on their children for setup.
See https://github.com/w3c/webcomponents/issues/551 for reference.
To solve this, we have created a HTMLBaseElement class in our team which serves as the new class to extend autonomous custom elements from.
HTMLBaseElement in turn inherits from HTMLElement (which autonomous custom elements must derive from at some point in their prototype chain).
HTMLBaseElement adds two things:
a setup method that takes care of the correct timing (that is, makes sure child nodes are accessible) and then calls childrenAvailableCallback() on the component instance.
a parsed Boolean property which defaults to false and is meant to be set to true when the components initial setup is done. This is meant to serve as a guard to make sure e.g. child event listeners are never attached more than once.
HTMLBaseElement
class HTMLBaseElement extends HTMLElement {
constructor(...args) {
const self = super(...args)
self.parsed = false // guard to make it easy to do certain stuff only once
self.parentNodes = []
return self
}
setup() {
// collect the parentNodes
let el = this;
while (el.parentNode) {
el = el.parentNode
this.parentNodes.push(el)
}
// check if the parser has already passed the end tag of the component
// in which case this element, or one of its parents, should have a nextSibling
// if not (no whitespace at all between tags and no nextElementSiblings either)
// resort to DOMContentLoaded or load having triggered
if ([this, ...this.parentNodes].some(el=> el.nextSibling) || document.readyState !== 'loading') {
this.childrenAvailableCallback();
} else {
this.mutationObserver = new MutationObserver(() => {
if ([this, ...this.parentNodes].some(el=> el.nextSibling) || document.readyState !== 'loading') {
this.childrenAvailableCallback()
this.mutationObserver.disconnect()
}
});
this.mutationObserver.observe(this, {childList: true});
}
}
}
Example component extending the above:
class MyComponent extends HTMLBaseElement {
constructor(...args) {
const self = super(...args)
return self
}
connectedCallback() {
// when connectedCallback has fired, call super.setup()
// which will determine when it is safe to call childrenAvailableCallback()
super.setup()
}
childrenAvailableCallback() {
// this is where you do your setup that relies on child access
console.log(this.innerHTML)
// when setup is done, make this information accessible to the element
this.parsed = true
// this is useful e.g. to only ever attach event listeners once
// to child element nodes using this as a guard
}
}
The HTMLCollection inputs does have a length property, and if you log it inside the update function you will see it's value is 2. You can also iterate through the inputs collection in a for loop so long as it's inside the update() function.
If you want to access the values in a loop outside of the update function, you can store the HTMLCollection in a variable declared outside of the scope of the SpektacularInput class.
I suppose there are other ways to store the values depending on what you're trying to accomplish, but hopefully this answers your initial question "How can I iterate over the spk-input elements within spk-root from the update() method?"
class SpektacularInput extends HTMLElement {
constructor() {
super();
}
}
window.customElements.define('spk-input', SpektacularInput);
let template = document.createElement('template');
template.innerHTML = `
<canvas id='spektacular'></canvas>
<slot></slot>
`;
// declare outside variable
let inputsObj = {};
class SpektacularRoot extends HTMLElement {
constructor() {
super();
let shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.appendChild(template.content.cloneNode(true));
}
update() {
// store on outside variable
inputsObj = this.getElementsByTagName('spk-input');
// use in the function
let inputs = this.getElementsByTagName('spk-input');
console.log("inside length: " + inputs.length)
for(let i = 0; i < inputs.length; i++){
console.log("inside input " + i + ": " + inputs[i]);
}
}
connectedCallback() {
this.update();
}
}
window.customElements.define('spk-root', SpektacularRoot);
console.log("outside length: " + inputsObj.length);
for(let i = 0; i < inputsObj.length; i++){
console.log("outside input " + i + ": " + inputsObj[i]);
}
<spk-root>
<spk-input></spk-input>
<spk-input></spk-input>
</spk-root>
Hope it helps,
Cheers!