How to separate web components to individual files and load them?
Asked Answered
D

3

42

I have a web component x-counter, which is in a single file.

const template = document.createElement('template');
template.innerHTML = `
  <style>
    button, p {
      display: inline-block;
    }
  </style>
  <button aria-label="decrement">-</button>
    <p>0</p>
  <button aria-label="increment">+</button>
`;

class XCounter extends HTMLElement {
  set value(value) {
    this._value = value;
    this.valueElement.innerText = this._value;
  }

  get value() {
    return this._value;
  }

  constructor() {
    super();
    this._value = 0;

    this.root = this.attachShadow({ mode: 'open' });
    this.root.appendChild(template.content.cloneNode(true));

    this.valueElement = this.root.querySelector('p');
    this.incrementButton = this.root.querySelectorAll('button')[1];
    this.decrementButton = this.root.querySelectorAll('button')[0];

    this.incrementButton
      .addEventListener('click', (e) => this.value++);

    this.decrementButton
      .addEventListener('click', (e) => this.value--);
  }
}

customElements.define('x-counter', XCounter);

Here the template is defined as using JavaScript and html contents are added as inline string. Is there a way to separate template to an x-counter.html file, css to say, x-counter.css and corresponding JavaScript code to xcounter.js and load them in index.html?

Every example I lookup has web components mixed. I would like to have separation of concerns, but I am not sure how to do that with components. Could you provide a sample code? Thanks.

Delaney answered 9/3, 2019 at 17:33 Comment(0)
H
48

In the main file, use <script> to load the Javascript file x-counter.js.

In the Javascript file, use fetch() to load the HTML code x-counter.html.

In the HTML file, use <link rel="stylesheet"> to load the CSS file x-counter.css.

CSS file : x-counter.css

button, p {
    display: inline-block;
    color: dodgerblue;
}

HTML file : x-counter.html

<link rel="stylesheet" href="x-counter.css">
<button aria-label="decrement">-</button>
    <p>0</p>
<button aria-label="increment">+</button>

Javascript file : x-counter.js

fetch("x-counter.html")
    .then(stream => stream.text())
    .then(text => define(text));

function define(html) {
    class XCounter extends HTMLElement {
        set value(value) {
            this._value = value;
            this.valueElement.innerText = this._value;
        }

        get value() {
            return this._value;
        }

        constructor() {
            super();
            this._value = 0;

            var shadow = this.attachShadow({mode: 'open'});
            shadow.innerHTML = html;

            this.valueElement = shadow.querySelector('p');
            var incrementButton = shadow.querySelectorAll('button')[1];
            var decrementButton = shadow.querySelectorAll('button')[0];

            incrementButton.onclick = () => this.value++;
            decrementButton.onclick = () => this.value--;
        }
    }

    customElements.define('x-counter', XCounter);
}

Main file : index.html

<html>
<head>
    <!-- ... -->
    <script src="x-counter.js"></script>
</head>
<body>
    <x-counter></x-counter>
</body>
</html>
Horsey answered 9/3, 2019 at 19:33 Comment(9)
really nice answer! Supposing you want to unit teste the x-count.js web component? How would you do it? For instance, in case you want to do a very basic unit test using karma just to check if the webcomponet was rendered, how would reach that?Gabey
@JimC I don't use Karma but I suppose you can check the html content of a custom element as any other elements. I know you can do it with Chai.js + Selenium Webdriver.Horsey
I wrote a Medium article - with one of the possible solutions roshan-khandelwal.medium.com/web-components-c7aef23fe478Paraboloid
I'm getting TypeError: failed to fetch when trying to fetch the html file to js class in a Chrome extensionIntradermal
While this is a clever solution, the component cannot be export / impoted, can it?Riotous
@Riotous it depends what you mean by export/import. If you refer to JS modules, then it could be better to use a full Javascript file. But it wasn't the question.Horsey
I've asked the question here - #73936044. @Supersharp: do you mind taking a look? Thanks.Riotous
For a 2022 update on how to separate CSS, see https://mcmap.net/q/391992/-organizing-multiple-web-components-with-seperation-of-concernsDaphne
It worked for me with just 1 component, but when I try to use another component inside my component the other component doesn't load ...Ruprecht
A
6

A generic pattern using top level await without side-effects:

my-component/
  element.js
  template.html
  styles.css

template.html (be sure to link to styles.css)

<template>
  <link rel="stylesheet" href="./styles.css" />

  <!-- other HTML/Slots/Etc. -->
  <slot></slot>
</template>

styles.css (regular CSS file)

:host {
  border: 1px solid red;
}

element.js (uses top level await in export)

const setup = async () => {
  const parser = new DOMParser()
  const resp = await fetch('./template.html')
  const html = await resp.text()
  const template = parser.parseFromString(html, 'text/html').querySelector('template')

  return class MyComponent extends HTMLElement {
    constructor() {
      super()

      this.attachShadow({ mode: 'open'}).appendChild(template.content.cloneNode(true))
    }

    // Rest of element implementation...
  }
}

export default await setup()

index.html (loading and defining the element)

<!doctype html>
<html>
  <head>
    <title>Custom Element Separate Files</title>
    <script type="module">
      import MyComponent from './element.js'

      if (!customElements.get('my-component')) {
        customElements.define('my-component', MyComponent)
      }
    </script>
  </head>
  <body>
    <my-component>hello world</my-component>
  </body>
</html>

You can and should make side-effects (like registering a custom element in the global scope) explicit. Aside from creating some init function to call on your element, you can also provide a distinct import path, for example:

defined.js (sibling to element.js)

import MyComponent from './element.js'

const define = async () => {
  let ctor = null

  customElements.define('my-component', MyComponent)
  ctor = await customElements.whenDefined('my-component')

  return ctor
}

export default await define()

index.html (side-effect made explicit via import path)

<!doctype html>
<html>
  <head>
    <title>Custom Element Separate Files</title>
    <script type="module" src="./defined.js"></script>
  </head>
  <body>
    <my-component>hello world</my-component>
  </body>
</html>

This approach can also support arbitrary names when defining the custom element by including something like this inside define:

new URL(import.meta.url).searchParams.get('name')

and then passing the name query param in the import specifier:

<script type="module" src="./defined.js?name=custom-name"></script>
<custom-name>hello</custom-name>

Here's an example snippet using tts-element that combines all three approaches (see the network tab in dev console):

<!DOCTYPE html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <title>tts-element combined example</title>
    <style>
      text-to-speech:not(:defined), my-tts:not(:defined), speech-synth:not(:defined) {
        display: none;
      }
    </style>
    <script type="module" src="https://unpkg.com/tts-element/dist/text-to-speech/defined.js"></script>
    <script type="module" src="https://cdn.jsdelivr.net/npm/[email protected]/dist/text-to-speech/defined.js?name=my-tts"></script>
    <script type="module">
      import ctor from 'https://unpkg.com/tts-element/dist/text-to-speech/element.js'

      customElements.define('speech-synth', ctor)
    </script>
  </head>
  <body>
    <text-to-speech>Your run-of-the-mill text-to-speech example.</text-to-speech>
    <my-tts>Example using the "name" query parameter.</my-tts>
    <speech-synth>Example using element.js.</speech-synth>
  </body>
</html>
Adda answered 27/9, 2022 at 22:37 Comment(0)
S
0

I've wanted a solution for this as well - but didn't find a satisfying way to do this.

I see that fetching the CSS/HTML has been suggested here - but I find this a bit troublesome. For me this seems to be a bit too much overhead and might cause some problems or performant issues.

I wanted to see if I could find other solutions.

"CSS Module scripts" seems to bee coming soon, but browsers like Safari doesn't support this.

Webpack compiling with raw-loader is another solution - or Rollup. But I find that it is too slow or too much config.

I ended up creating my own CLI tool - which I have set up in my IDE (PHPStorm) - so it automatically compiles CSS and HTML into native JavaScript modules that exports ready HTMLTemplate with code.

I have also an example on how to achieve the same in VSCode.

Maybe this could be an alternative approach for some - so I wanted to share it.

It is available as a NPM package here: https://www.npmjs.com/package/csshtml-module

I can now write HTML files and even SCSS files - which PHPStorm automatically compiles to CSS with Autoprefixer and CSSO (optimizer) - and then it compiles these to a native JS module with template.

Like this:

export const template = document.createElement('template');
template.innerHTML = `<style>button{background-color:red}</style><button>Hello</button>`;

You can set it to compile a single file as well, like CSS - which compiles to a module:

// language=css
export const css = `button {
    background-color: red;
}`;
Saleratus answered 8/2, 2023 at 13:57 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.