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 };
}
@o0101
Copy link

o0101 commented Oct 30, 2021

I LOVE 💕 how clean this is: everything in one file, hosted anywhere, imported using a single attribute. This is like the ideal of HTML transclusion that was never really realized, except here it's directed at components. I think this is perfect.

Related to how I think about building components as well keeping it simple, using just HTML, CSS and JavaScript as files. I implemented these ideas in my bang framework but also gets you a sort of syntax for custom elements without an end tag you might want to check it out here: https://github.com/i5ik/_____

But I love how your idea seems more universal more down to the metal of HTML CSS and JavaScript which is what I strive for too and doesn't require a framework. I love that! mine is like a thousand lines of JavaScript because it also includes templating and other complexities like a class for component functionality a syntax for adding event listeners.

@getflourish, what's your idea on how to do collection components and templating of values with this sort of approach?

@getflourish
Copy link
Author

Thanks for your positive response @i5ik! Regarding your question, I need to check if I understand it correctly: is it about data-driven components, loops, recursion and data binding? For example, how to enhance the HTML templates with dynamic data?

@o0101
Copy link

o0101 commented Oct 30, 2021

@getflourish Exactly like that! (thanks for translating my question into sensible terms, heh 😅 )

@getflourish
Copy link
Author

getflourish commented Oct 31, 2021

@i5ik Good question! I’ve a prototype that uses the built-in template literals for expressions which I hope to be a simple "low code" solution:

<span>${ name }</span>

The questions is wether (a) every component would need to implement the expression parsing methods to be 100% self-contained or (b) wether to move expression parsing into the "runtime".

For repeating elements I’m playing with a custom element that can handle that:

<for-each values="...">
  <span>${ name }</span>
</for-each>

@o0101
Copy link

o0101 commented Oct 31, 2021

@getflourish that's nice! I like your custom element syntax 🙂

World you then use eval or equivalent for cooking the template literals into strings (since you fill them from HTML, not have them already in a script)?

@cekvenich2
Copy link

Nice!

@getflourish
Copy link
Author

@i5ik Yeah, evaluating at runtime with new Function() / eval is the necessary evil. I think the same note written here applies: https://github.com/vuejs/petite-vue#security-and-csp

@o0101
Copy link

o0101 commented Oct 31, 2021

@getflourish mmm, cool, thanks for your answers, and best of luck with it all! 🙂

@getflourish
Copy link
Author

@i5ik You're welcome! Happy to read/see anything you’ll come up with, too :)

@getflourish
Copy link
Author

@i5ik Here’s a function that parses native template expressions at runtime: https://gist.github.com/getflourish/9338fc0ee213be2a8a0ed8080cc1f499

@geoffp
Copy link

geoffp commented Dec 14, 2022

I actually am here because I just came up with more or this idea as I was building a lightweight framework-neutral (or -free) prototyping environment using Parcel and wishing for a vanilla .css way to style Shadow DOMs so I can take advantage of Parcel's excellent CSS processing support. I'm glad to see I'm not the first!

It would be interesting to see where you could take this when implemented as a build-time thing rather than a run-time thing.

@ds604
Copy link

ds604 commented Feb 12, 2023

This is really nice! Thanks for the historical context of HTML components, and the current discussion of HTML Modules. It's hard to find information like this when so many resources seem to assume that you're going to be using React or Vue, and a build system. It seems like the whole mindset of just making web pages (stable, boring html pages, that don't get a new version and break every few months) has been sidelined by the new developer-focused, over-complicated versions.

I was making a sort of "web page desktop environment" using iframes (and all contained in one html page). But then thinking if I should maybe use web components, it seems to get complicated quickly, and push down the path towards non-standard web things and build systems. So it's great to see a legitimate way to do without any of that.

@getflourish
Copy link
Author

@ds604 Thanks for your comment 😊

@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