Zach's Mugspideyclick logo

GitHub

GitLab

Linkedin

Instagram

Youtube

SoundCloud

Email

Custom Element Template

Simple Template

This is a custom element that does not use the shadow DOM. In many ways, it's a lot more simple and can work great if you need a reusable element with some custom methods.

<template id="my-custom-element-template">
    <!-- Template contents here -->
</template>

<script>
    class myCustomElement extends HTMLElement {
        constructor() {
            super();
            // Set initial properties and defaults, etc.
        };
        static get observedAttributes() {
            return ['readonly', 'edit'];
        }
        buildHtml() {
            let template = document.querySelector('#my-custom-element-template').cloneNode(true);
            this.innerHTML = "";
            Array.from(template.content.children).forEach(el => {
                this.appendChild(el);
            });
            if (this.hasAttribute("readonly")) {
                // May want to change contents of element depending on
                // attributes, up to you. This is just an example.
            };
        };
        addEventListeners() {
            // Just another example, specify your events here
            this.querySelector('.fa-edit').addEventListener("click", function () {
                this.parentNode.parentNode.toggleClass('readonly');
            });
        };
        connectedCallback() {
            this.buildHtml();
            this.addEventListeners();
        }
        disconnectedCallback() {
            // You would want to remove event listeners here.
        }
        attributeChangedCallback() {
            this.connectedCallback();
        }
        // Define your own methods, etc.
        export() {
            let output = {};
            return output;
        };
    };
    customElements.define("my-custom-element", myCustomElement);
    let newElement = new myCustomElement;
    document.querySelector('myContainer').appendChild(newElement);
</script>

Shadow DOM Template

Using the Shadow DOM introduces a few key challenges, but provides true isolation for the contents of the custom element. Things like event listeners and styles need to be handled with this isolation in mind, but it's really nice to be able to nest custom elements and have them interact with each other dynamically.

  • Elements in the Shadow DOM can't communicate to the host, this gets changed from the host to the event target, and the event object itself changes from the shadow DOM element's perspective. The only way I've found so far to trigger things is by adding a listener to the entire custom element and checking the event's path attribute.
  • To take advantage of the isolated Shadow DOM styling, include a <link> tag in the template. The linked stylesheet should be isolated from the rest of the page, allowing for use of basic names (#title, #header, etc.) without worry of conflict.
<template id="my-custom-element-template">
    <!-- If using Shadow DOM, you'll probably want to import some styles. -->
    <link href="/static/css/myCustomElement.css" rel="stylesheet">
    <div id="title"></div>
    <div id="header"></div>
    <div id="rows"></div>
    <!-- Make sure to use some kind of indicator (data-callback here) pointing to the desired callback. -->
    <div id="controls">
        <button data-callback="addRow">Add Row</button>
        <button data-callback="removeRow">Remove Row</button>
    </div>
</template>

<script>
    class myCustomElement extends HTMLElement {
        constructor() {
            super()
            this.attachShadow({ mode: 'open' });
            this._columns = [];
            let template = document.querySelector('#my-custom-element-template').cloneNode(true);
            Array.from(template.content.children).forEach(el => {
                this.shadowRoot.appendChild(el);
            });
        }
        static get observedAttributes() {
            return ['readonly'];
        }
        buildHtml() {
            // Couple examples here
            this.shadowRoot.querySelector('#title').innerText = this.title;
            this.shadowRoot.querySelector('#controls').style.display = this.hasAttribute("readonly") ? 'none' : 'block';
        }
        handleClick() {
            // Only way I can find to track the event origin from shadow host
            let thisCallBack = event.path[0].dataset.callback;
            if (thisCallBack === 'addRow') { this.addRow(); }
            else if (thisCallBack === 'removeRow') { this.removeRow(); };
        }
        connectedCallback() {
            this.buildHtml();
            this.addEventListener('click', this.handleClick);
        }
        attributeChangedCallback() {
            this.connectedCallback();
        }
        // Define your own methods here! Here I've included a couple examples.
        addColumn(col) {
            this._columns.push(col);
            this.buildHtml();
        }
        addRow() {
            let thisRow = document.createElement('div');
            thisRow.classList.add('row');
            this._columns.forEach(col => { thisRow.append(col.cloneNode(true))});
            this.shadowRoot.querySelector('#rows').append(thisRow);
        }
        removeRow(position) {
            this.shadowRoot.querySelector('#rows .row:last-child').remove();
        }
    }
    customElements.define("custom-table-input", customTableInput);
</script>

Okay, so with all that out of the way, what does this cool shadow-dom stuff let us do?

Nested Custom Elements

Here's where it all comes together: By using the shadow DOM, suddenly nesting your custom elements becomes an option, because you're not overwriting the contents of your custom elements!

Here's a basic example of some of the possibilities (this was done with a Django template, similar to Jinja2 syntax):

<div id="customInputs">
    {% for fieldName, field in template.fields.items %}
        {% if field.type == 'table' %}
            <custom-table-input
                data-key="{{ fieldName }}"
                title="{{ field.name }}" 
                {% if not edit %}readonly{% endif %}>
                {% for columnName, column in field.fields.items %}
                    <custom-input
                        data-key="{{ columnName }}"
                        data-name="{{ column.name }}"
                        data-type="{{ column.type }}"
                        {% if column.type == "multiple-choice" %}
                            data-options="{% for opt in column.options %}~{{opt}}{% endfor %}"
                        {% elif column.type == "file" %}
                            data-filecontent=""
                        {% endif %}"
                        {% if not edit %}readonly=""{% endif %}
                        data-value=""
                    ></custom-input>
                {% endfor %}
            </custom-table-input>
        {% else %}
            <custom-input
                data-key="{{ fieldName }}"
                data-name="{{ field.name }}"
                data-type="{{ field.type }}"
                {% if field.type == "multiple-choice" %}
                    data-options="{% for opt in field.options %}~{{opt}}{% endfor %}"
                {% elif field.type == "file" %}
                    data-filecontent=""
                {% endif %}
                data-value="{{ instance.fields|get:fieldName }}"
                {% if not edit %}readonly=""{% endif %}
            ></custom-input>
        {% endif %}
    {% endfor %}
</div>

In this example, our custom-input element can be one of many types. But what if we want to group some of those inputs together in a table? That table should be able to add/remove whole rows of these custom-inputs at a time. That's where the custom-table-input object comes in: It contains many custom-input objects as child nodes, and treats each node as a column. It has to use a shadow DOM in order to retain those child nodes and build a bunch of custom HTML.

Keep in mind: Communication between custom elements only goes one way (at least on first page load). See, my first attempt was to have the parent custom element call buildHtml() on all the child elements, but that doesn't work because when the parent's connectedCallback() function fires, the children haven't been initialized yet, so their buildHtml() functions don't exist at all. Consequently, I have the children nodes call addColumn(this) on their parent when it comes their turn to be initialized. Probably the biggest issue with that, is that if I run buildHtml() at the end of the addColumn() command, I'm rebuilding the parent node way more times than I need to. I don't really have a good solution for that right now.