1

I'm building a small application, and planning to use web components (rather than use a UI library). I don't plan to use any bundlers etc., as this is going to be a small personal site.

I would like to store each web component in a separate ES6 JS module file, and here is an example of my setup:

hello-planet.mjs

export class HelloPlanet extends HTMLElement {
  constructor() {
    super();
  }
  connectedCallback(){
    // Attach a shadow root to the element.
    let shadowRoot = this.attachShadow({mode: 'open'});
    let planet = this.getAttribute('planet')
    shadowRoot.innerHTML = `<p>hello ${planet}</p>`;
  }
}

hello-world.mjs

export class HelloWorld extends HTMLElement {
  constructor(){
    super()
  }
  connectedCallback(){
    // Attach a shadow root to the element.
    let shadowRoot = this.attachShadow({mode: 'open'});
    ['Mercury','Venus','Earth','Mars','Jupiter','Saturn','Uranus','Neptune'].forEach(planet=>{
      let el = document.createElement('hello-planet')
      el.setAttribute('planet', planet);
      shadowRoot.appendChild(el)
    })
  }
}

main.mjs

// ordering of imports does not matter
import {HelloPlanet} from './hello-planet.mjs';
import {HelloWorld} from './hello-world.mjs';

customElements.define('hello-world', HelloWorld);
customElements.define('hello-planet', HelloPlanet);

// this will typically be handled by a client side router (e.g. Navigo)
let el = document.createElement('hello-world');
let body = document.querySelector('body');
body.appendChild(el);

index.html (only calls main.mjs, browser will download the rest of the scripts)

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <script src="main.mjs" type="module"></script>
  <title>Web components test</title>
</head>
<body>
</body>
</html>

Questions:

  1. I've not seen this approach in any examples I've encountered so far, so wondering if is a good approach? Or there is a better approach than this in terms of organizing web components.
  2. When I need template+styles, how can this approach be extended to read those from different files i.e. html and css in separate files (so we have separation of concerns)? I've seen this but not sure how to adapt to my kind of setup. I've also gone through this - but it seems too complex already, and not even sure if it can handle complex scripting.

Thank you!

mrbrahman
  • 455
  • 5
  • 18
  • 2
    I would move the ``customeElements.define`` to the component file itself; keep everything in one file; no need to do any exports. And attach shadowDOM in the ``constructor`` as the ``connectedCallback`` _can_ be called multiple times. For separate files take inspiration from [the ```` Web Component](https://dev.to/dannyengelman/load-file-web-component-add-external-content-to-the-dom-1nd); as [HTML Modules](https://stackoverflow.com/questions/57766631/what-is-an-html-module) haven't arrived yet. – Danny '365CSI' Engelman Oct 03 '22 at 14:26
  • 1
    If there is no export (and hence import in main), the browser would not know to load the module. That's pretty much the only reason for doing exports. And my reasoning for doing the `customElements.define` in main.mjs is that otherwise, the imports would show as unused in editors. Hence, this side effect is now explicit in one place for all components. – mrbrahman Oct 03 '22 at 15:33
  • 1
    Only import is enough – Danny '365CSI' Engelman Oct 03 '22 at 16:04
  • Didn't know that syntax! `import {} from './hello-world.mjs';` works. Thanks for the feedback – mrbrahman Oct 03 '22 at 16:29
  • @Danny'365CSI'Engelman -> Just wanted to point out, that if you are creating web components to be reused across multiple sites, I think its preferable to leave the customElements.define() out of the component. That way, the consuming script has total control over the naming of the web component, and can avoid conflicts if there is another HelloWorld web component loaded from another "vendor". A theoretical issue, at least. – Henrik Nielsen May 06 '23 at 11:26
  • Yes, that is the IKEA way. You buy a flatpack and have to assemble ITLAS yourself. Another future possibility is multiple custom element registries. They are working on [scoped registries](https://github.com/WICG/webcomponents/issues?q=is%3Aissue+is%3Aopen+scoped-registries) – Danny '365CSI' Engelman May 06 '23 at 11:39

4 Answers4

2
  1. I've not seen this approach in any examples I've encountered so far, so wondering if is a good approach? Or there is a better approach than this in terms of organizing web components.

It's perfectly fine. Creating your elements programmatically has many advantages, mainly there is no need to query your own shadow root to get access to child elements/components. If need be, you can directly hold references or even create those in class properties, e.g.:

export class HelloWorld extends HTMLElement {
  planets = ['Mercury', 'Venus', 'Earth', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune']
    .map(
      planet => Object.assign(document.createElement('hello-planet'), { planet })
    )
  )
  
  constructor() {
    super().attachShadow({ mode: 'open' }).append(...this.planets);
  }
}

Sidenote: Creating the shadow root can and should safely be done in the constructor.

  1. When I need template+styles, how can this approach be extended to read those from different files i.e. html and css in separate files (so we have separation of concerns)?

For CSS, we have CSS module scripts:

import styles from './hello-world.css' assert { type: 'css' }

then, in your constructor, do

constructor() {
  // ...
  this.shadowRoot.adoptedStylesheets.push(styles);
}

For HTML, this importing feature unfortunately is still work in progress.

connexo
  • 53,704
  • 14
  • 91
  • 128
1

Using import assert to load the component's HTML and CSS may be the generic approach moving forward, however, browser support is still limited. To create web components with a separation of concerns and limited build tools (like bundlers) you can use standard fetch with top level await to ensure the component's bootstrapped appropriately before using.

I similarly wanted to separate concerns and minimize tooling while developing modern web components as ES modules. I've started documentation (WIP) on web component best practices that I wanted to follow while developing tts-element.

This is the relevant setup work to bootstrap the component during its import:

const setup = async () => {
  const url = new URL(import.meta.url)
  const directory = url.pathname.substring(0, url.pathname.lastIndexOf('/'))
  const baseUrl = `${url.origin}${directory}`
  const [html, css] = await Promise.all([
    fetch(`${baseUrl}/template.html`).then((resp) => resp.text()),
    fetch(`${baseUrl}/styles.css`).then((resp) => resp.text())
  ])
  const parser = new DOMParser()
  const template = parser
    .parseFromString(html, 'text/html')
    .querySelector('template') as HTMLTemplateElement
  const style = document.createElement('style')

  style.textContent = css
  template.content.prepend(style)

  return class TextToSpeech extends HTMLElement {/* implementation */}
}

Here is how the component will be exported to allow top level await to be used:

export default await setup()

Here is an example of how tts-element can be loaded in various ways (all using top level await) to control the timing of when the component is defined.

  • Loading of defined.js will automatically register the component under the default name text-to-speech.
  • Using the name query parameter while loading defined.js?name=custom-name will register the component with the provided custom name.
  • Loading of element.js will require manual registration of the components definition, i.e. the consumer will be responsible for calling define().

Check the network tab in the dev console to see how the HTML/CSS/JS is loaded:

<!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/tts-element@0.0.3-beta/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>
morganney
  • 6,566
  • 1
  • 24
  • 35
0

The way they do it in react is you import all your components into one file and then you just insert that component into a statice div with an id of root in your HTML page.

So your index file:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <script src="main.mjs" type="module"></script>
  <title>Web components test</title>
</head>
<body>
<div id="root"></div>
</body>
</html>

Than anything you want to display in the DOM you insert into that div via JS.

I believe this might help your problem, honestly wasn't a 100% clear on your isssue

Wayne
  • 660
  • 6
  • 16
0

I had similar questions regarding separating HTML and CSS, but had a hard time finding a solution I wanted.

The "CSS Module Script" that connexo suggest sounded great, but doesn't seem to be supported by Safari.

Here's how I solved this - in case this might resonate with you as well.

I built a CLI tool to use with PHPStorm and VSCode. The IDE will automatically detect changes in HTML and CSS and compile it automatically to JS/TS modules. No need for Webpack, Rollup or similar tools.

I have shared it here: https://www.npmjs.com/package/csshtml-module

It can compile a single CSS or HTML file, or it can bundle both in a HTMLTemplate module.

Example: this (button.css)

button {
    background-color: red;
}

automatically becomes this (button.style.ts) when css file has changed

// language=css
export const css: string = `button {
    background-color: red;
}`;

And I can use plain JS/TS to get it:

import {css} from './button.style.js'

const style = document.createElement('style');
style.innerHTML = css;

And since the IDE compile on save, it is almost instant changes between CSS/HTML edits and result.

bonsay
  • 21
  • 4