2

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.

Luke Vo
  • 17,859
  • 21
  • 105
  • 181

4 Answers4

4

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.

Jordan Breton
  • 1,167
  • 10
  • 17
  • *being able to use a custom component as any other DOM tag* ShadowDOM doesn't raise any obstacles here. As with any other DOM element, **you use that element's API**. As with font-awesome, use CSS inheritance to solve the problem. The font itself is available in any shadowDOM without anything you need to do; declaring the icons as CSS custom properties also makes them available in any shadow DOM, like `--fa-icon-whatever: '\f70b'`. – connexo Sep 13 '22 at 15:16
  • @connexo Yes, you can use slots, yes you can declare manually everything everytime you use a component. Yes, you can create your templates with all CSS links related to your current project, but you lose in flexibility, and you just repeat yourself again, and again. It becomes very very tedious and erase the pros of using components to compose your UI. And, no, you can't do `myComponent.querySelector('div')` if the tag is in the shadowRoot. You will have to treat that node differently. If you need to traverse your DOM tree at some point, shadowDOM force you to write unneeded complex logic. – Jordan Breton Sep 13 '22 at 15:23
  • 1
    *And, no, you can't do myComponent.querySelector('div') if the tag is in the shadowRoot* This is exactly what my example allows; yet I would **never** offer this since `#a` and `#div` are component internals which must stay invisible for the outside, and controlled **only via the component's API**. If you don't stick to this principle you can never change the implementation later without breaking stuff; and your component can never rely on it's own internals, since it just cannot be aware of outside DOM manipulation via e.g. `el.querySelector('div').remove()`. – connexo Sep 13 '22 at 15:27
  • *If you need to traverse your DOM tree at some point, shadowDOM force you to write unneeded complex logic.* Disagreed, again. Your inside code/shadowDOM is not relevant to traversal, ever. Just think about elements like `textarea` which have their own internal shadow DOM which you can not even access, at all. Ever had any problems traversing with those? – connexo Sep 13 '22 at 15:30
  • Fontawesome even provides the necessary custom properties: https://fontawesome.com/docs/web/style/custom – connexo Sep 13 '22 at 15:39
1

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);
Danny '365CSI' Engelman
  • 16,526
  • 2
  • 32
  • 49
  • `shadowDOM` wouldn't be ideal due to a few differences. In fact we don't even need a shadowDOM at all. I forgot to mention I even have a "hanging" DOM before connecting. While it works, it "upsets" the code a bit because you can no longer do `this.querySelector` for example. I will add this to the question. – Luke Vo Sep 13 '22 at 14:19
  • I added a non shadowDOM approach. You can't do ``this.querySelector`` when ``this`` is not a DOM Element – Danny '365CSI' Engelman Sep 13 '22 at 14:24
  • Sorry your example won't work when there are more properties or the component has more complicated data. See how I solved it using non-attached DOM in the question. But I understand both of us use the same approach. – Luke Vo Sep 13 '22 at 14:27
  • Maybe too fancy use a ``proxy`` (although I can't come up with a code sample easily). But basically you have to do some magic because you want to stuff content in a box, when there is no box (yet). – Danny '365CSI' Engelman Sep 13 '22 at 14:37
  • Right, I guess your way (for simple components) or mine (more "organized"?) are the easiest so far. – Luke Vo Sep 13 '22 at 14:55
0

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);
connexo
  • 53,704
  • 14
  • 91
  • 128
  • If you use shadow DOM then yes it's simple. Sometimes you may not want it (for example you want outside CSS/outside access). – Luke Vo Sep 13 '22 at 14:50
  • 1
    Web components are honestly not meant to be used without shadow DOM; a component should encapsulate what it does. Encapsulation is one of the basic principles of componentization. – connexo Sep 13 '22 at 14:58
  • Yeah after seeing all the approaches, I agree with you. – Luke Vo Sep 13 '22 at 14:59
  • If you want outside CSS, use a `slot`. If you want outside access, provide it via your component's API. – connexo Sep 13 '22 at 14:59
  • 1
    The whole idea of web components imo is that **they** are in charge about their internals; outside access should always happen in a controlled way by **you** providing exactly what you want to expose. – connexo Sep 13 '22 at 15:15
  • 2
    @connexo I don't think so. The fact is that they are called "customElements" and allow to attach a custom behavior to a new tag. The encapsulation process should be optionnal, for many reasons. You may want to use WebComponents to... compose your UI with meaningfull tags that offer an API to easily manipulate them. Just think about an advanced help tooltip that could show(), hide(), etc. – Jordan Breton Sep 13 '22 at 15:18
  • 1
    *compose your UI with meaningfull tags that offer an API to easily manipulate them. Just think about an advanced help tooltip that could show(), hide(), etc.* All this is not related to shadowDOM vs no shadowDOM? And also it is in no way an argument against encapsulation? The opposite is true and is what you are asking. e.g. `show` and `hide` is API that your component controls, not that someone else should be able to manipulate by access you internals via the general DOM API. – connexo Sep 13 '22 at 15:22
  • @connexo If you need to specify in the slot your css links for it to inherit your theme, and if you need to re-link everytimes your fontaweosme/i18n library or whatever, you juste repeat yourself uncessarily, or you need to specify them inside your component template, and then you loose in flexibility. And if you use 30 ore more components in your app, you prey to never have to add a new CSS file, a new library or to have to refactor anything. – Jordan Breton Sep 13 '22 at 15:28
  • @Ariart I explained under your answer how to deal with icon fonts. With modern design systems, these are based on design tokens (CSS custom properties for the web) and they work in any shadow DOM as well. All you need to do is adapt to a world that has components with shadow DOM and not try to stubbornly apply the **old way** of doing things. – connexo Sep 13 '22 at 15:32
  • I've been working commercially on component libraries based on web components and shadow DOM since early 2018. The only CSS we ever provided for including in a file comprised of font definitions, and CSS custom property definitions. Everything else is unnecessary. – connexo Sep 13 '22 at 15:37
  • @connexo could you link me a few of them please? I'd love to learn from them – Luke Vo Sep 13 '22 at 16:55
  • None of them are open source. – connexo Sep 18 '22 at 08:18
0

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.

Luke Vo
  • 17,859
  • 21
  • 105
  • 181