Torsten Müller

Web Components: The Basics

published Dec 30th, 2020

Ever since one of the teams I worked with was using Web Components I was intrigued. Taking the rewrite of a web site as an excuse, I finally got around to learn how to implement and use them.

The Problem to Solve

For the new web site I want to have a feature that shows readers the last few stories that they viewed and enable them to go back and look at them again. Since the site will be a static site, encapsulating the view and functionality was important to not copy/paste code all over the place.

On a former job, a team was using web components, and a brief look at Can-I-Use for web components showed that for the web site under consideration, web components, HTML templates and shadow DOM are supported in browsers for 99% of the site’s traffic/visitors. Here is a visual of what I’m about to develop:

Screenshot of the web component

From this screen grab, the following features are discernable:

  1. A link to a web page, with the page title shown
  2. Long page titles are truncated with ellipses
  3. The last access time is displayed in human-readable form
  4. Content will be scrollable

So here we go! You can go along and/or look at the code on Bitbucket.

Rendering Some Output

The first step consists in setting up the web component and getting something rendered on the page when it is included on the page. Here is the HTML to embed an instance of the web component last-viewed into a HTML document:

use-component.html
<div class="sidebar" style="width:150px">
  <last-viewed></last-viewed>
</div>

Note: The explicit closing tag is required for web components, so you can’t use self-closing tags like <last-viewed /> as you would for <img />.

Now that we have used an unknown tag, we need to go and set up our web component. In this first step, I’ll define one and set up some basic rendering. Here is the JavaScript code to define a web component and get something rendered — The details of how the various functions generate the HTML used in this snippet are not important for this discussion, so I will skip discussing the details. I’ll talk more about the rendering later.

last-viewed-stories.js
class LastViewedStories extends HTMLElement {

  shadowRoot = null;

  constructor() {
    super();
    this.shadowRoot = this.attachShadow({mode: 'open'})

    const previousPagesViewedList = ... // Retrieving content to render 
    const storyListContainer = this.renderContainer();
    const documentPageList = this.renderLinksFor(previousPagesViewedList);
    storyListContainer.appendChild(documentPageList);
    this.shadowRoot.appendChild(storyListContainer);
  }

  renderContainer() {
    const container = document.createElement('section');
    container.setAttribute('class', 'recent-articles__box');
    return container;
  }

  ...
}

customElements.define('last-viewed', LastViewedStories);

There are a few lines in this code, which warrant comment:

  1. Line 1: Web Components extend a base class with the desired behavior and features. While I’m using HTMLElement here, one could extend off more specific elements such as HTMLSelectElement to inherit specific behaviors.

  2. Line 6: Since we’re extending, we need to call the extended class’ constructor

  3. Line 7: We can use an approach called Shadow DOM for web components to make them more robust:

    It introduces scoped styles to the web platform. Without tools or naming conventions, you can bundle CSS with markup, hide implementation details, and author self-contained components in vanilla JavaScript.

  4. Lines 10-12: Take care of rendering the web component’s HTML

  5. Lines 16-20: An example of how one could render the HTML for the component

  6. Line 25: So far LastViewedStories is just a class. We’ll turn this class into a web component in line 24 and assign the class the tag name last-viewed. It is mandatory for web components to contain at least one hyphen to avoid collision with standard HTML tags, current or future ones.

Simplifying the HTML Generation and Slots

In the previous section, I built the HTML very laboriously using JavaScript in functions like renderContainer(). While some use of createElement is unavoidable, building an entire DOM tree that way is unnecessary. The following snippet uses a predefined class constant to hold HTML and attach it to an element via an element’s innerHtml property (on line 12):

last-viewed-stories.js
  template = '<div>' +
    '<h3>Recent stories ...</h3>' +
    '<slot name="story-list" id="list-slot"></slot>' +
    '<p class="auxiliary-copy">There could be a little description here ...</p>' +
    '</div>';

  ...

  renderContainer() {
    const container = document.createElement('section');
    container.setAttribute('class', 'recent-articles__box');
    container.innerHTML = this.template;
    return container;
  }

Line 3 sports a <slot> tag, which is a tag provided by the Web Components API. it is a placeholder into which a programmer can fill markup from outside the web component. The markup to be included needs to be inserted in between the Web Component’s tags and the top-level HTML tag needs to have a slot attribute specifying the name. So for the following HTML, the Browser would display a list consisting of an ordered list with three items with content “one”, “two” and “three” respectively.

use-component.html
<last-viewed>
  <ol slot="story-list">
    <li>one</li>
    <li>two</li>
    <li>three</li>
  </ol>
</last-viewed>

Content injected from HTML

There are more scenarios that the <slot> syntax supports, which are summarized in the Google Developers page on the shadow DOM. The main take-away is that <slot> permits the population of web components with static HTML at predefined points in the HTML, thus making the component more versatile to display different main content. An example might be to permit the display of the three different HTML lists, ordered, unordered and definition lists.

Rendering Content in Slots With JavaScript

Using slots to populate components from HTML markup is a nice feature, but for my purposes, I want to dynamically update the list, in particular the time since a page was last accessed needs to be updated every minute. Thus, using a JS timer, we query the storage every few seconds and update the content in <slot> accordingly.

last-viewed-stories.js
  interval;

  constructor() {
    ...
    this.interval = setInterval(this.reloadAndRerender.bind(this), 60000);
  }

  reloadAndRerender() {
    const slotElement = this.shadowRoot.getElementById('list-slot');
    const updatedEntries = this.renderLinksFor(this.loadStoredList());
    slotElement.innerHTML = '';
    slotElement.appendChild(updatedEntries);
  }

We set an interval in the constructor, repeatedly calling the reloadAndRerender() method. That method then retrieves a reference to the slot via the specified ID, renders the new content from the stored data and clears the DOM inside using innerHtml = ''. As the final step, the method inserts the generated HTML on line 12.

With all the heavy lifting of rendering the individual list entries and aggregating them to a list and HTML tree done in the background, the actual process of changing out the DOM is quick and unnoticeable.

Using both HTML and JavaScript for slot

Can you use both mechanisms to populate slots, or asked another way: Is it possible to set a default in the HTML using the slot mechanism and overwrite that from JavaScript? First attempts to do that failed: Even though the code to update the slot is called and executed, the view does not update when we try to overwrite the HTML content in <slot> with a naïve approach such as using the shown JavaScript code with HTML nested in the <slot></slot> tag pair.

A look at the debug tool shows that the HTML structure for using the HTML population looks different between the two approaches — It appears that the HTML version does not insert the HTML children under <slot> but instead attached them to another place in the HTML, away from the slot:

Screenshot of the Chrome browser 'Elements" debug panel

I’ll update this or create another post if I find a way to make this work using the HTML as a default that can be overwritten from the JavScript side if needed.

Styling the Component

Web Components are self-contained, as the previous quote suggests. That means that we can specify CSS rules within the component and not have component-styles be affected by styles on the page and vice versa. The specification of styles within a web component can occur either by creating a style tag from JavaScript with the requisite style rules attached as a text node or by adding a <style> section to the template markup for the web component, as outlined for the component’s HTML in “Simplifying the HTML Generation”

To create the styling without extending the component markup, one can create a function setting up the required styles and invoke that function in the constructor of the web component (we’ll see another way in a subsequent post). The setup would look like this:

last-viewed-stories.js
  constructor() {
    super();
    this.shadow = this.attachShadow({mode: 'open'})
    ...
    this.shadow.appendChild(this.returnStyling());
    this.shadow.appendChild(storyListContainer);
  }

  returnStyling() {
    const styleElement = document.createElement('style');
    styleElement.setAttribute('type', 'text/css');

    styleElement.textContent = '
      .recent-articles__box {
        border: 1px solid #555;
        border-radius:7px;
        padding:0px;
        min-width:250px;
      }  
    ...
    '
  }

This little snippet shows that we have created a method to generate the requisite method to generate the CSS and then invoke that method from the constructor. This approach also allows for the inclusion of dynamically changing style parameters — maybe an element should be styled according to the available screen real estate. In JS, we have the opportunity to measure the available dimensions and then dynamically adjust the height of an element, for example.

It is noteworthy here that we should include the <style> before the markup, as done here, to rule out a “flash of unstyled content.”

Rerendering the Component

Since in this example we deal with periodic re-rendering of the list content to update the timing information, we’ll have to make sure of three things:

  1. The old content gets removed and replaced with new content, not new content appended,
  2. the update should be instantaneous or at least fast enough to not be noticeable and
  3. any scroll position should be preserved because otherwise, the list display jumps to the top at seemingly arbitrary points in time.

The method reloadAndRerender() in the following example implements the solution to these three concerns.

last-viewed-stories.js
  constructor() {
    ...
    this.interval = setInterval(this.reloadAndRerender.bind(this), 60000);
  }

  reloadAndRerender() {
    const slotElement = this.shadowRoot.getElementById('list-slot');
    const updatedEntries = this.renderLinksFor(this.loadStoredList());
    const listOffset = this.shadowRoot.getElementById('list-root-element').scrollTop;
    slotElement.innerHTML = '';
    slotElement.appendChild(updatedEntries);
    slotElement.childNodes[0].scrollTop = listOffset;
  }

In the first 2 lines, reloadAndRerender() gets a reference to the <slot> element via the id set on the tag (line 1), then loads the data to be rendered from localStorage and generates the HTML markup for the list of links in the element (line 2).

In line 3, it stores the current scroll offset, so that we can reset it after the new list has rendered.

The rest of the method is fairly mundane: We clear the HTML from within the <slot>, append our new HTML list and then reset the vertical scroll position. With this code in place, the list gets replaced and reset so that upon changing the markup no noticeable visual shift will occur, but the times under the link names will have updated — again instantaneously because of the show DOM in use here.

Summary

In this post, I have taken a quick overview of the main features of web components and the corresponding features in shadow DOM. Both techniques are by now well-supported by the major browsers and are ready for use.

This code still has some issues and can be improved upon, but that’ll be the topic of another post in the near future.