Skip to content

Instantly share code, notes, and snippets.

@getflourish
Last active June 23, 2024 15:01
Show Gist options
  • Save getflourish/a87b990265db8fc243c897f34d32f860 to your computer and use it in GitHub Desktop.
Save getflourish/a87b990265db8fc243c897f34d32f860 to your computer and use it in GitHub Desktop.
Import CustomElements from .html files
import { importCustomElements } from "./stecker.js";
importCustomElements().then(() => {
console.log("imported");
});

Import CustomElements from .html files

This is the technical implementation of the idea and concept described in my article “Why don’t we use HTML to author web components?

Instead of using template literals, constructors and other specifics to define CustomElements, this is a proposal to just use standard HTML to define CustomElements.

The goal is to import CustomElements like this:

<head>
  <link rel="import" href="custom-search.html">
</head>
<body>
  <custom-search></custom-search>
</body>

Or alternatively like this (similar to how native elements like <img>, <iframe> or <video> load external resources):

<body>
  <custom-element src="custom-search.html" name="custom-search"></custom-element>  
</body>

Both techniques have been implemented and can be tested in these demos: #demos


Proposal: Import with link tag

To achieve the goal of importing CustomElements from HTML files, we’ll need a small runtime (see below), a CustomElement (HTML file) and an import syntax.

Here, we use the proposed, but not implemented, HTML Import syntax in the head of the document and add the runtime at the end of the body tag.

<html>

<head>
  <link rel="import" href="custom-search.html">
</head>

<body>
  
  <input type="search" placeholder="Search value">
  <custom-search value="dog food"></custom-search>
  
  <script src="runtime.js" type="text/javascript"></script>
  
</body>
    
</html>

Example: CustomElement defined with standard HTML

HTML already offers syntax for everything that a CustomElement definition needs:

  • <head>: Here we can import external assets including CSS, fonts, etc.
  • <title>: We’ll use the title as the default name for the CustomElement
  • <body>: This is our template for the CustomElement
  • <style>: CSS is defined in a style tag
  • <script>: Functionality for the CustomElement can be authored as a regular script tag.

What makes CustomElements special is that they are isolated and need to provide mechanisms to communicate with the outside world. So while we can author CustomElements as standalone HTML pages, in order to make them interoperable, they need to define their "API", which is done through attributes.

Usually, CustomElements require us to watch attributes an define getters/setters for each attribute.

static get observedAttributes() {
  return ["value", "placeholder"];
}

Since our goal is to just use normal HTML and DOM JavaScript for our CustomElements, we won’t be able to define those through a JavaScript Class. Instead, the attributes are defined through standard HTML.

One idea is to use these attributes on the body tag":

  <body value="Hello" placeholder="Search"></body>

While these attributes are irrelevant when the HTML document is viewed in isolation, they could serve as the information we’re looking for. They also provide default values. Wether we should use standard attributes or custom ones is an open question.

Another idea would be to use a meta tag:

<meta name="attributes" content="value, placeholder">
<html>
<head>
  <title>custom-search</title>
</head>

<body value placeholder>
  <form>
    <label>
      <span id="search-label">Custom Search</span>
      <input type="search" />
    </label>
  </form>

  <style>
    figure {
      max-width: 100%;
    }
  </style>
  <script>
    if (attributes) {
      let input = document.querySelector("input");

      /**
       * We intend to use this document as a CustomElement.
       * Since we’ll then be using ShadowDOM, we need to expose relevant
       * data to the outside world (the implementing document). 
       * When the value of the <input type="search"> changes, we send 
       * a CustomEvent of type "attribute.changed". The implementing document
       * can then listen for those changes and update the CustomElement’s
       * attributes accordingly.
       * 
       */
      
      if (attributes) {
        if (attributes.value) {
          input.value = attributes.value.value;
          input.addEventListener("input", (event) => {
            document.dispatchEvent(
              new CustomEvent("attribute.changed", {
                bubbles: true,
                composed: true,
                detail: {
                  value: event.target.value
                }
              })
            );
          });
        }
      }

      /**
       * For the case that this document is used as a CustomElement, 
       * we can react to attribute changes. Here, we can map the "value" 
       * of the CustomElement instance to the value of the <input type="search">
       */
      

      document.addEventListener("attribute.changed", (event) => {
        input.setAttribute(event.detail.name, event.detail.value);
        if (event.detail.name == "value") {
          input.value = event.detail.value;
        }
      });
    }
  </script>
</body>
</html>

Runtime (782 bytes, gzipped)

The runtime takes care of converting a standard HTML document into a CustomElement. Our goal is to make this process fully automatic and rely on some conventions.

async function init() {
Array.from(document.querySelectorAll("link[rel='import']")).forEach((link) => {
  fetch(link.getAttribute("href")).then((response) => {
    response.text().then(async (html) => {
      await mountComponentFromHTML(html);
      postUpgrade();
    });
  });
});
}

init();

// We can take the HTML, parse it, extract parts and re-assemble it inside the CustomElement.
async function mountComponentFromHTML(html) {
let dom = new DOMParser().parseFromString(html, "text/html");

// We use the <title> of the HTML as the name for the component
let name = dom.head.querySelector("title").innerText;

// We get the attributes from the <body> tag
let namedAttributesMap = dom.body.attributes;

let attributes = [];
for (let attribute of namedAttributesMap) {
  attributes.push(`"${attribute.name}"`);
}
attributes = `[${attributes}]`;

// We will inject the <head> into the Shadow DOM so that external resources like fonts are loaded
let headText = dom.head.innerHTML;

// We will later inject the script (this demo assumes only a one script tag per file)
let script = dom.body.querySelector("script");
let scriptText = script.innerText;

// We will later inject the style (this demo assumes only a one style tag per file)
let style = dom.body.querySelector("style");
let styleText = style.innerText;

// In order to get raw "template", we’ll remove the style and script tags.
// This is a limitation / convention of this demo.
script.remove();
style.remove();

// The <body> is our template
let template = dom.body.outerHTML;

let construct = `customElements.define(
  '${name}',
  class HTMLComponent extends HTMLElement {
    constructor() {
      super();

      var shadow = this.attachShadow({ mode: "open" });

      let head = document.createElement("head");
      head.innerHTML = \`${headText}\`;
      shadow.appendChild(head);

      let body = document.createElement("body");
      body.innerHTML = \`${template}\`;
      shadow.appendChild(body);

      let style = document.createElement("style");
      style.innerText = \`${styleText}\`;
      body.appendChild(style);

      new Function("document", "attributes", \`${scriptText}\`)(
        this.shadowRoot,
        this.attributes
      );
    }
    static get observedAttributes() {
      return ${attributes};
    }

    attributeChangedCallback(name, oldValue, newValue) {
      this.shadowRoot.dispatchEvent(
        new CustomEvent("attribute.changed", {
          composed: true,
          detail: { name, oldValue, newValue, value: newValue }
        })
      );
    }
  }
);
`;

await import(`data:text/javascript;charset=utf-8,${encodeURIComponent(construct)}`);
}

Communication between CustomElements and the hosting document

We can use the CustomEvent attribute.changed to listen for events from the CustomElement. When we make changes to the attributes of a CustomElement’s instance, we can also listen for those (see above).

<script>
function postUpgrade() {
  let customSearch = document.querySelector("custom-search");
  let nativeSearch = document.querySelector("input[type='search']");

  // Forward inputs to the outer search field to the CustomElement
  nativeSearch
    .addEventListener("input", (event) => {
      customSearch.setAttribute("value", event.target.value);
    });

  // Listen for changes from the CustomElement
  customSearch.addEventListener("attribute.changed", (event) => {
    nativeSearch.value = event.detail.value;
  });
}
</script>

Known Issue: JavaScript inside ShadowDOM

While we can load stylesheets within ShadowDOM using link tags, we can’t simply use script tags to load JavaScript and only execute it inside the ShadowDOM. That means that if we want to use external libraries (e.g. petite-vue) we will need to move scripts from our custom element to the main document.

Right now, the runtime extracts all script tags from the CustomElement and move it to the main document (updated version in the Demos). This approach seems to work and the benefit is that duplicate dependencies will only ever load once (in contrast to 3rd party components that might bundle a framework).

There might be some incompatibilities: if a library expects a selector string, e.g. #counter to mount and use document.querySelector() on the global scope, it won’t find the element within the ShadowDOM. Instead, selectors need to be provided as real DOM Elements within the CustomElement’s script. Because there, document actually refers to the ShadowRoot. This is an ongoing implementation detail.

For now, a prototype implementation mounts the CustomElement after the first script it depends on is loaded. In the future, a proper loading queue needs to be implemented that ensures that all required dependencies are loaded.

Alternative Syntax

We could also think about a syntax that borrows from native elements such as image, video, iframe, … all of those elements use a src attribute to refer to external resource.

<custom-element src="./custom-search.html"></custom-element>

Then there would be two possible implementations:

a) A general purpose "custom-element" CustomElement that handles the fetching of the external resource and mounts a CustomElement named "Component". The problem here would be that it might not be possible to dynamically register many different "Component" CustomElements with different observedAttributes.

b) A placeholder "custom-element" CustomElement that will be replaced with dynamically defined CustomElements (name derived from the external resource as described above). This has the advantage that the actual CustomElements will show up in the DOM and attributes can be observed.

This seems to be the way to go.


Demo #1: Import with Events

https://codepen.io/getflourish/pen/fadd1a447759c193ce39b82baea986d6

Demo #2: Import with Slots

https://codepen.io/getflourish/pen/7252f447941886de27674b5854d0662c

Demo #3: Using "petite-vue" inside the imported CustomElement

https://codepen.io/getflourish/pen/7252f447941886de27674b5854d0662c

Demo #4: <custom-element> with src attribute

https://codepen.io/getflourish/pen/fba6681e122e50e233a1061ece90c89c

Demo #5: Color Picker

https://codepen.io/getflourish/pen/a721fbd12caf190153451b3a7ff3acef

<html>
<head>
<link rel="import" href="search.component.html" name="custom-search" />
<!-- Automatic Importing: -->
<!-- <script type="module" src="lib/automat.js"></script> -->
</head>
<body>
<input type="search" placeholder="Search value" />
<custom-search value="dog food"></custom-search>
<!-- Manual Import with async callback: -->
<script type="module">
import { importCustomElements } from "./lib/stecker.js";
importCustomElements().then(() => {
console.log("imported");
});
</script>
</body>
</html>
async function init() {
Array.from(document.querySelectorAll("link[rel='import']")).forEach(async (link) => {
fetch(link.getAttribute("href")).then(async (response) => {
response.text().then(async (html) => {
await mountComponentFromHTML(html);
if (postUpgrade) postUpgrade();
});
});
});
}
function upgradeCustomElements() {
Array.from(document.querySelectorAll("custom-element")).forEach(async (element) => {
fetch(element.getAttribute("src")).then(async (response) => {
response.text().then(async (html) => {
let component = await mountComponentFromHTML(html);
let outerHTML = element.outerHTML.replaceAll(
element.tagName.toLowerCase(),
component.name || element.getAttribute("name")
);
let customElement = new DOMParser().parseFromString(outerHTML, "text/html").body.firstElementChild;
customElement.removeAttribute("src");
element.parentNode.insertBefore(customElement, element.nextSibling);
element.remove();
if (postUpgrade) postUpgrade();
});
});
});
}
init();
upgradeCustomElements();
// We can take the HTML, parse it, extract parts and re-assemble it inside the CustomElement.
async function mountComponentFromHTML(html) {
let dom = new DOMParser().parseFromString(html, "text/html");
// We use the <title> of the HTML as the name for the component
let name = dom.head.querySelector("title").innerText;
// We get the attributes from the <body> tag
let namedAttributesMap = dom.body.attributes;
let attributes = [];
for (let attribute of namedAttributesMap) {
attributes.push(`"${attribute.name}"`);
}
attributes = `[${attributes}]`;
// We will inject the <head> into the Shadow DOM so that external resources like fonts are loaded
let headText = dom.head.innerHTML;
// We will later inject the script (this demo assumes only a one script tag per file)
let script = dom.body.querySelector("script");
let scriptText = "";
if (script) scriptText = script.innerText;
// We will later inject the style (this demo assumes only a one style tag per file)
let style = dom.body.querySelector("style");
let styleText = "";
if (style) {
style.innerText;
}
// In order to get raw "template", we’ll remove the style and script tags.
// This is a limitation / convention of this demo.
if (script) script.remove();
if (style) style.remove();
// The <body> is our template
let template = dom.body.outerHTML;
let construct = `customElements.define(
'${name}',
class HTMLComponent extends HTMLElement {
constructor() {
super();
var shadow = this.attachShadow({ mode: "open" });
let head = document.createElement("head");
head.innerHTML = \`${headText}\`;
shadow.appendChild(head);
let body = document.createElement("body");
body.innerHTML = \`${template}\`;
shadow.appendChild(body);
let style = document.createElement("style");
style.innerText = \`${styleText}\`;
body.appendChild(style);
new Function("document", "attributes", \`${scriptText}\`)(
this.shadowRoot,
this.attributes
);
}
static get observedAttributes() {
return ${attributes};
}
attributeChangedCallback(name, oldValue, newValue) {
this.shadowRoot.dispatchEvent(
new CustomEvent("attribute.changed", {
composed: true,
detail: { name, oldValue, newValue, value: newValue }
})
);
}
}
);
`;
await import(`data:text/javascript;charset=utf-8,${encodeURIComponent(construct)}`);
return { name };
}
@Leshe4ka
Copy link

Hello!
Thank you for your great ideas. I inspired by you and now i'm trying to integrate builded and hosted react app into another react app via web components. This is a big challenge and I faced with a lot of problems which seem insurmountable. As I know nobody did it yet...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment