javascriptweb-component

Create WebComponent through createElement


I'm having an issue creating a Web Component using createElement. I'm getting this error:

Uncaught DOMException: Failed to construct 'CustomElement': The result must not have children at appendTodo

class TodoCard extends HTMLElement {
    constructor() {
        super()

        this.innerHTML = `
            <li>
                <div class="card">
                    <span class="card-content">${this.getAttribute('content')}</span>
                    <i class="fa fa-circle-o" aria-hidden="true"></i>
                    <i class="fa fa-star-o" aria-hidden="true"></i>
                </div>
            </li>
        `
    }
}

window.customElements.define('todo-card', TodoCard)

const todoList = document.getElementById('todo-list')
const todoForm = document.getElementById('todo-form')
const todoInput = document.getElementById('todo-input')

function appendTodo(content) {
    const todo = document.createElement('todo-card')
    todo.setAttribute('content', content)
    todoList.appendChild(todo)
}

todoForm.addEventListener('submit', e => {
    e.preventDefault()
    appendTodo(todoInput.value)
    todoInput.value = ''
})

any ideas? Thanks.


Solution

  • A Custom Element that sets DOM content in the constructor
    can never be created with document.createElement()

    You will see many examples (including from me) where DOM content is set in the constructor.
    Those Elements can never be created with document.createElement

    Explanation (HTML DOM API):

    When you use:

      <todo-card content=FOO></todo-card>
    

    The element (extended from HTMLElement) has all the HTML interfaces (it is in a HTML DOM),
    and you can set the innerHTML in the constructor

    But, when you do:

      document.createElement("todo-card");
    

    The constructor runs, without HTML interfaces (the element may have nothing to do with a DOM),
    thus setting innerHTML in the constructor produces the error:

    Uncaught DOMException: Failed to construct 'CustomElement': The result must not have children

    From https://html.spec.whatwg.org/multipage/custom-elements.html#custom-element-conformance:

    The element must not gain any attributes or children, as this violates the expectations of consumers who use the createElement or createElementNS methods. In general, work should be deferred to connectedCallback as much as possible

    shadowDOM is a DOM

    When using shadowDOM you can set shadowDOM content in the constructor:

      constructor(){
        super().attachShadow({mode:"open"})
               .innerHTML = `...`;
      }
    

    Correct code (no shadowDOM): use the connectedCallback:

    <todo-card content=FOO></todo-card>
    
    <script>
      customElements.define(
        "todo-card",
        class extends HTMLElement {
          constructor() {
            super();
            //this.innerHTML = this.getAttribute("content");
          }
          connectedCallback() {
            this.innerHTML = this.getAttribute("content");
          }
        }
      );
    
      try {
        const todo = document.createElement("todo-card");
        todo.setAttribute("content", "BAR");
        document.body.appendChild(todo);
      } catch (e) {
        console.error(e);
      }
    </script>

    You have another minor issue: content was a default attribute, and FireFox won't stop warning you:

    Or don't use createElement

      const todo = document.createElement("todo-card");
      todo.setAttribute("content", "BAR");
      document.body.appendChild(todo);
    

    can be written as:

      const html = `<todo-card content="BAR"></todo-card`;
      document.body.insertAdjacentHTML("beforeend" , html); 
    

    The connectedCallback can run multiple times!

    When you move DOM nodes around:

    <div id=DO_Learn>
      <b>DO Learn: </b><todo-card todo="Custom Elements API"></todo-card>
    </div>
    <div id="DONT_Learn">
      <b>DON'T Learn!!! </b><todo-card todo="React"></todo-card>
    </div>
    <script>
      customElements.define(
        "todo-card",
        class extends HTMLElement {
          connectedCallback() {
            let txt = this.getAttribute("todo");
            this.append(txt);// and appended again on DOM moves
            console.log("qqmp connectedCallback\t", this.parentNode.id, this.innerHTML);
          }
          disconnectedCallback() {
            console.log("disconnectedCallback\t", this.parentNode.id , this.innerHTML);
          }
        }
      );
      const LIT = document.createElement("todo-card");
      LIT.setAttribute("todo", "Lit");
      DO_Learn.append(LIT);
      DONT_Learn.append(LIT);
    </script>

    It is up to you the programmer how your component/application must handle this

    Web Component Libraries

    Libraries like Lit, HyperHTML and Hybrids have extra callbacks implemented that help with all this.

    I advice to learn the Custom Elements API first, otherwise you are learning a tool and not the technology.

    And a Fool with a Tool, is still a Fool

    Also read my Dev.to post on the connectedCallback: https://dev.to/dannyengelman/web-component-developers-do-not-connect-with-the-connectedcallback-yet-4jo7