Web Components

Web Components are a set of web platform APIs that allow you to create new custom, reusable, encapsulated HTML tags to use in web pages and web apps. Custom components and widgets build on the Web Component standards, will work across modern browsers, and can be used with any JavaScript library or framework that works with HTML.

Web components are based on existing web standards. Features to support web components are currently being added to the HTML and DOM specs, letting web developers easily extend HTML with new elements with encapsulated styling and custom behavior.

What are we building?

We'll be building a vanilla web component and prepare all the necessary elements to publish on webcomponents.org

What you'll learn

What you'll need

How would you describe your level of experience with web development?

Novice Intermediate Proficient

How will you use this tutorial?

Only read through it Read it and complete the exercises

Install Tools

  1. Install Git
  2. Install the current LTS version (6.x) of Node.js or newer (check your installed version with node -v)
  3. Install the latest version of Bower
npm install -g bower
  1. Install the latest Polymer CLI
npm install -g polymer-cli
  1. Install the vanilla web component yeoman generator
npm install -g generator-polymer-init-vanilla-web-component

The next steps are reproduced in the my-element repository, we'll reference the commits during the codelab for you to check your progress.

Why Polymer CLI? I thought we were building a vanilla web component?

The Polymer CLI provides a number of tools for scaffolding, serving and testing web componentsβ€”both those built with the Polymer library, and those built with the vanilla web components APIs. The generator-polymer-init-vanilla-web-component package is a plugin for the Polymer CLI for scaffolding vanilla web components.

Create a directory for your element project. It's best practice for the name of your project directory to match the name of your element.

mkdir my-element && cd my-element

Initialize your element using the vanilla-web-component generator. Polymer CLI asks you a few questions as it sets up your element project.

polymer init vanilla-web-component

Leave the name of the element as "my-element" and add a description of the element (e.g. "My first web component").

Polymer CLI will create these project files and install the dependencies:

Run the following command to start a web server for your element, then navigate to the demo page by using the --open flag. The CLI will find a free port (default 8081), but you can specify which port to use with the -p flag followed by the port number.

polymer serve --open

Run the following command to test your element.

polymer test

Checkout commit "generator"

What are we implementing?

Let's implement a sample feature and write tests for it.

We want to expose 3 properties:

We also want to display:

Before starting the implementation, let's update demo/index.html to use our new properties:

<demo-snippet>
  <template>
    <h1>My stock</h1>
    <p>Was 5, now is 10</p>
    <my-element current="10" previous="5"></my-element>
    <p>Was 10, now is 5</p>
    <my-element current="5" previous="10"></my-element>
    <p>Was 2, now is 2</p>
    <my-element current="2" previous="2"></my-element>
  </template>
</demo-snippet>

Checkout commit "update demo"

This is what we see on our demo:

The final result we'll implement should look like this:

Properties

First, let's declare the properties. In my-element.html file, we want to update the this._properties object and define the properties' getters and setters

constructor() {
  super();

  /**
    * @type {!Object}
    * @private
    */
  this._properties = {
    current: null,
    previous: null,
    difference: null,
  };
}

/** 
  * @property {number|null} current
  */
get current() {
  return this._properties.current;
}

/** 
  * @property {number|null} previous
  */
get previous() {
  return this._properties.previous;
}

/** 
  * @property {number|null} difference
  * @readonly
  */
get difference() {
  return this._properties.difference;
}

set current(val) {
  if (val !== this.current) {
    this._properties.current = val;
    this._updateRendering();
  }
}

set previous(val) {
  if (val !== this.previous) {
    this._properties.previous = val;
    this._updateRendering();
  }
}

Checkout commit "define current, previous, difference"

Note that difference has only a getter, as it is a computed property.

We want to update difference when either current or previous change. Let's add _updateDifference method and invoke it in the setters:

set current(val) {
  val = Number.isFinite(val) ? val : null;
  if (val !== this.current) {
    this._properties.current = val;
    this._updateDifference();
    this._updateRendering();
  }
}

set previous(val) {
  val = Number.isFinite(val) ? val : null;
  if (val !== this.previous) {
    this._properties.previous = val;
    this._updateDifference();
    this._updateRendering();
  }
}

/**
  * @private
  */
_updateDifference() {
  this._properties.difference = this._computeDifference(this.current, this.previous);
}

/**
  * @param {number|null} current
  * @param {number|null} previous
  * @return {number|null}
  * @private
  */
_computeDifference(current, previous) {
  return (current === null || previous === null) ? null : current - previous;
}

Checkout commit "compute difference"

Note we also added a value check on the setters to ensure current and previous values are either a finite number or null. Like this, we can afford to only check for null in the implementation of _computeDifference, and gain in performance.

Let's see how we're doing so far: navigate in Chrome to demo/index.html, open the developer console, and observe what is the value of document.querySelector('my-element').difference

We'd expect it to be different from null, as the element has current and previous defined. This is because we are not observing the right attributes!

Before fixing that though, let's check if updating current and previous does update difference: try setting current and previous from the developer console and observe if difference is correctly updated.

Attributes

Let's observe the right attributes, and ensure we normalize strings to numbers

static get observedAttributes() {
  return ['current', 'previous'];
}

attributeChangedCallback(name, old, value) {
  if (old !== value) {
    // Normalize strings to numbers.
    this[name] = Number(value);
  }
}

Checkout commit "observe attributes"

Now our element is able to accept setting properties through attributes πŸ‘Œ

Next, let's update the rendering part.

Rendering

First, let's simplify the element's template to have only one <h2> which we'll update to contain our symbol:

<template id="my-element">
  <style>
     :host {
      display: block;
    }
  </style>
  <h2></h2>
</template>

Then, let's change _updateRendering to actually render our symbol:

/**
  * @private
  */
_updateRendering() {
  // Avoid rendering when not connected.
  if (this.shadowRoot && this.isConnected) {
    const h2 = this.shadowRoot.querySelector('h2');
    h2.textContent = this._computeSymbol(this.difference);
  }
}

/**
  * @param {number|null} difference
  * @return {string}
  * @private
  */
_computeSymbol(difference) {
  return difference === null ? '' :
    difference === 0 ? 'πŸ‘€' :
    difference > 0 ? 'πŸ“ˆ' : 'πŸ“‰';
}

Checkout commit "update rendering"

And we're done!

Next, let's make our tests page green again.

Update test/my-element.html

We want to update the ChangedPropertyTestFixture to set the correct attributes:

<test-fixture id="ChangedPropertyTestFixture">
  <template>
    <my-element current="10" previous="0"></my-element>
  </template>
</test-fixture>

Our tests should check at least for the values of current, previous, difference and that the rendering of the symbol is done correctly.

test('instantiating the element with default properties works', function() {
  var element = fixture('BasicTestFixture');
  assert.equal(element.current, null);
  assert.equal(element.previous, null);
  assert.equal(element.difference, null);
  var elementShadowRoot = element.shadowRoot;
  var elementHeader = elementShadowRoot.querySelector('h2');
  assert.equal(elementHeader.textContent, '');
});

test('setting a property on the element works', function() {
  var element = fixture('ChangedPropertyTestFixture');
  assert.equal(element.current, 10);
  assert.equal(element.previous, 0);
  assert.equal(element.difference, 10);
  var elementShadowRoot = element.shadowRoot;
  var elementHeader = elementShadowRoot.querySelector('h2');
  assert.equal(elementHeader.textContent, 'πŸ“ˆ');
});

Checkout commit "update tests"

Consider unit tests as the manifesto of how your element is expected to be used.

Verify tests are all green by running polymer test.

Documentation

Remember to always keep README.md, bower.json and package.json documentation updated πŸ“–

e.g. we might want to update the description and snippet:

Checkout commit "update description"

In order to publish your element on webcomponents.org, you'll have to first publish your repository on github.

In order to publish on webcomponents.org, your element must provide:

Newer versions of your element will be automatically visible on Webcomponents.org after 10-15 minutes from tagging and releasing of the version. See more details at https://www.webcomponents.org/publish.

The vanilla-web-component generator we used in this tutorial already fulfilled these requirements:

Additionally, webcomponents.org allows to include an inline demo in your README.md for people to try your element. You can preview the result at https://www.webcomponents.org/preview (you will need to first setup Github preview integration).

The inline demo is enabled through a comment block in the README.md - already included for you by vanilla-web-component generated elements.

Finally, ensure you test, document and preview your inline demo of your components before publishing them on webcomponents.org.

Another benefit of publishing your repository on github is that you can integrate Travis CI to automate your tests.

You'll need to enable your repository to be run by Travis CI in your Travis Profile and to configure the build via a .travis.yml file in your repository.

language: node_js
sudo: required
node_js: '6'
addons:
  firefox: latest
  apt:
    sources:
    - google-chrome
    packages:
    - google-chrome-stable
script:
- xvfb-run npm test
dist: trusty

After that is done, any new commit or creation of new branches will trigger a build on Travis.

Most of the code we wrote is repetitive, e.g. see our getters/setters. Also, we have our custom way to keep track of the exposed properties, handle computed properties, and update the rendering.

While this gives us complete control over what is happening in our element, it makes it harder to maintain the codebase, and we might soon feel the urge of building some sugar code to handle these mundane tasks.

Polymer provides the minimal sugar code and syntax which allows our element to focus on its functionality. In particular:

The polymer branch shows how my-element.html implementation can be simplified:

<link rel="import" href="../polymer/polymer-element.html">

<dom-module id="my-element">
  <template>
    <style>
       :host {
        display: block;
      }
    </style>
    <h2>[[_computeSymbol(difference)]]</h2>
  </template>
</dom-module>

<script>
  (() => {
    'use strict';

    class MyElement extends Polymer.Element {

      static get is() {
        return 'my-element';
      }

      static get properties() {
        return {

          /** 
           * @property {number|null} current
           */
          current: {
            type: Number,
            value: null
          },

          /** 
           * @property {number|null} previous
           */
          previous: {
            type: Number,
            value: null
          },

          /** 
           * @property {number|null} difference
           * @readonly
           */
          difference: {
            type: Number,
            computed: '_computeDifference(current, previous)'
          }
        };
      }

      /**
       * @param {number|null} current
       * @param {number|null} previous
       * @return {number|null}
       * @private
       */
      _computeDifference(current, previous) {
        return (current === null || previous === null) ? null : current - previous;
      }

      /**
       * @param {number|null} difference
       * @return {string}
       * @private
       */
      _computeSymbol(difference) {
        return difference === null ? '' :
          difference === 0 ? 'πŸ‘€' :
          difference > 0 ? 'πŸ“ˆ' : 'πŸ“‰';
      }
    }

    customElements.define(MyElement.is, MyElement);
  })();
</script>