Polymer 2.0

Polymer 2.0 is here, and with it comes a new way to write Polymer elements that uses the latest and greatest features of the web platform. Taking advantage of ES2015 class syntax, Custom Elements V1, and Shadow DOM V1, authoring elements with Polymer 2.0 allows you to work closer to the platform, with Polymer being able to provide useful utilities through class inheritance.

#usetheplatform

What Are We Building?

To demonstrate how to use Polymer 2.0 with ES2015 class syntax, we will build a simple image carousel and gradually add features to it.

What You'll Learn

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

Novice Intermediate Proficient

How would you describe your familiarity with Polymer?

New to Polymer Some experience with Polymer 0.x and/or 1.x Have experimented with Polymer 2.0-preview

How will you use this tutorial?

Only read through it Read it and complete the exercises

Download the Code

Clone the repository from GitHub:

git clone https://github.com/PolymerLabs/polymer-2-carousel.git

Alternatively, click the following button to download the latest version of the repository:

Download source code

Navigate into (cd) into the downloaded polymer-2-carousel (or polymer-2-carousel-master) directory. Inside, there is the work directory, which contains the starting point of this codelab. There is also a directory for the expected end state of each step of this codelab provided for your reference.

Install Tools

  1. Install Git
  2. Install the current LTS version (4.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 Polymer CLI
npm install -g polymer-cli

Install Bower Dependencies

Go into the work directory:

cd work

Then install bower dependencies:

bower install

This will install the latest version of Polymer into bower_components.

Use polymer-cli to Serve Your Element

From the work directory, run the following command to start a web server for your element. Then, open a new browser window and load your element at http://localhost:8080/:

polymer serve

Defining a Custom Element with Polymer 2.0

Custom Elements allow you to define behavior for custom HTML tags. With Polymer 2.0, you can define custom elements by extending a class from Polymer.Element (whereas you would extend from HTMLElement with native custom elements). For example, let's look at the JavaScript of my-carousel.html:

// Extend Polymer.Element base class
class MyCarousel extends Polymer.Element {

  static get is() { return 'my-carousel' }

  static get config() {
    // properties, observers meta data
    return {};
  }

  connectedCallback() {
    super.connectedCallback();
  }

}

// Register custom element definition using standard platform API
customElements.define(MyCarousel.is, MyCarousel);

The static is getter is where you should define the name of your element. By convention, we use the dash-separated version of the class name.

The static config getter is where you can define the properties that are part of your element's public API and observers - methods that run when the given properties are modified. (We will cover adding a property to our element later on in this codelab.)

The Custom Elements V1 specification includes a set of callbacks that run at various points in the element's lifecycle. If you're using these callbacks, make sure you call super at the beginning of your implementation so that Polymer functionality can be invoked.

Callback

Description

constructor

Called when the element is created, but before property values are set and local DOM is initialized.

Use for one-time setup before property values are set.

Note: The V1 Custom Elements spec forbids reading/writing attributes, children, or parent information in the constructor - any such work must be deferred (e.g. until connectedCallback or setTimeout/requestAnimationFrame).

connectedCallback

Called after the element is inserted into the document. Can be called multiple times (e.g. if the element is removed and inserted again).

Use for accessing computed style information and adding event listeners.

disconnectedCallback

Called after the element is removed from the document.

Use for removing event listeners added in connectedCallback.

attributeChangedCallback

Called when one of the element's attributes in the config's properties have changed.

When using Polymer, use observers on those properties instead of this callback.

Using Shadow DOM with Polymer 2.0

Shadow DOM allows an element to host internal DOM elements that are hidden from the element user and define scoped CSS styles. You can specify your internal DOM elements and styles, referred to as your Local DOM, inside the <template> tag of your element.

Since users will only place <img> elements in this carousel, let's replace the placeholder content in the template with <slot>. In my-carousel.html:

<template>

  <!-- Styles MUST be inside template -->
  <style>

    :host {
      display: block;
    }

  </style>

  <div>
    <slot></slot>
  </div>

</template>

Also, let's style the element so that only the selected image (marked by the selected attribute) is visible and add that attribute to the first child. In the <style> tag of my-carousel.html:

<style>

  :host {
    display: block;
    position: relative;
    overflow: hidden;
  }

  div > ::slotted(:not([selected])) {
    display: none;
  }

</style>

Then, in the connectedCallback() of my-carousel.html:

connectedCallback() {
  super.connectedCallback();

  this.firstElementChild.setAttribute('selected', '');
}

Try reloading the page - you should now see the image defined in index.html inside the carousel instead of the placeholder text.

Using the Polyfills

Custom Elements and Shadow DOM are part of a set of web standards called Web Components. On browsers that don't natively support Web Components, you'll need to load a polyfill. For this codelab, this is already done in index.html:

<script src="bower_components/webcomponentsjs/webcomponents-lite.js"></script>

webcomponents-lite.js includes polyfills for Custom Elements, HTML Templates, and HTML Imports. It also includes Shady DOM (a high-performance shim for Shadow DOM that provides tree-scoping) for browsers that don't natively support Shadow DOM.

Defining Properties

To switch to a different image, we need to remove the selected attribute from one image and set it on another. However, this would not be very intuitive. Instead, we'll define a property called selected on our element and add an observer, _selectedChanged(), that toggles the attribute. First, define the property in the static config getter of my-carousel.html:

static get config() {
  // properties, observers meta data
  return {
    properties: {
      selected: {
        type: Object,
        observer: '_selectedChanged'
      }
    }
  };
}

Next, we'll define the _selectedChanged() observer in my-carousel.html:

_selectedChanged(selected, oldSelected) {
  if (oldSelected) oldSelected.removeAttribute('selected');
  if (selected) selected.setAttribute('selected', '');
}

Finally, let's modify our connectedCallback() to set the selected property on our element instead of setting the attribute directly. In the connectedCallback() of my-carousel.html:

connectedCallback() {
  super.connectedCallback();

  this.selected = this.firstElementChild;
}

Defining Methods

We'll also define two methods, previous() and next(), that will change the selected image. These methods will become part of the element's public API, so they don't start with underscores. In my-carousel.html:

previous() {
  const elem = this.selected.previousElementSibling;
  if (elem) {
    this.selected = elem;
  }
}

next() {
  const elem = this.selected.nextElementSibling;
  if (elem) {
    this.selected = elem;
  }
}

Let's also add some buttons that use these methods. In the <template> of my-carousel.html:

<div>
  <slot></slot>
</div>

<button id="prevBtn" on-click="previous">&#x276E;</button>
<button id="nextBtn" on-click="next">&#x276F;</button>

Next, add some styles for these buttons in the <style> tag of my-carousel.html:

button {
  position: absolute;
  top: calc(50% - 20px);
  padding: 0;
  line-height: 40px;
  border: none;
  background: none;
  color: #DDD;
  font-size: 40px;
  font-weight: bold;
  opacity: 0.7;
}

button:hover,
button:focus {
  opacity: 1;
}

#prevBtn {
  left: 12px;
}

#nextBtn {
  right: 12px;
}

button[disabled] {
  opacity: 0.4;
}

Additionally, we'll disable the buttons when there's no previous/next image. Let's modify the _selectedChanged() observer to disable the buttons when it's appropriate. In my-carousel.html:

_selectedChanged(selected, oldSelected) {
  if (oldSelected) oldSelected.removeAttribute('selected');
  if (selected) selected.setAttribute('selected', '');

  this.$.prevBtn.disabled = !selected.previousElementSibling;
  this.$.nextBtn.disabled = !selected.nextElementSibling;
}

You should now see a carousel with previous and next buttons:

Element users can also use these methods. For example, we can use next() to switch the selected image on an interval. In index.html:

<script>
  const carousel = document.querySelector('my-carousel');
  setInterval(_ => carousel.next(), 3000);
</script>

In Polymer 2.0, you can use ES2015 class inheritance as a way to reuse code. Since JavaScript doesn't itself support multiple inheritance, we recommend using mixins that return classes so you can reuse code over different inheritance trees. With the mixin structure, an element extends from a class created by nesting all the mixins that it needs. (In fact, Polymer.Element is really just an alias for Polymer.ElementMixin(HTMLElement).)

To demonstrate making a mixin, we'll factor out some code related to the selected property into MyMixin. First, let's create my-mixin.html with the following code (the config and methods are copied from my-carousel.html):

<script>

  const MyMixin = subclass => class extends subclass {

    static get config() {
      // properties, observers meta data
      return {
        properties: {
          selected: {
            type: Object,
            observer: '_selectedChanged'
          }
        }
      };
    }

    connectedCallback() {
      super.connectedCallback();

      this.selected = this.firstElementChild;
    }

    _selectedChanged(selected, oldSelected) {
      if (oldSelected) oldSelected.removeAttribute('selected');
      if (selected) selected.setAttribute('selected', '');
    }

  };

</script>

MyMixin is a function which returns a class that extends the subclass given via the argument. In the returned class, we can define config, callbacks, and other methods, just like any other Polymer element. When overriding a method in our element, we can invoke the superclass method through super.

Let's modify my-carousel.html to use MyMixin. First, import my-mixin.html:

<link rel="import" href="my-mixin.html">

Then, let's change MyCarousel to use MyMixin:

// Extend Polymer.Element with MyMixin
class MyCarousel extends MyMixin(Polymer.Element) {

Let's remove the static config getter from MyCarousel and the this.selected = ... line from connectedCallback() in my-carousel.html:

connectedCallback() {
  super.connectedCallback();
}

We also have some element-specific code to run when selected changes. So let's override _selectedChanged() in MyCarousel and call super._selectedChanged() to invoke the one in MyMixin. In my-carousel.html:

_selectedChanged(selected, oldSelected) {
  super._selectedChanged(selected, oldSelected);

  this.$.prevBtn.disabled = !selected.previousElementSibling;
  this.$.nextBtn.disabled = !selected.nextElementSibling;
}

Up until now, we've been relying on the dimensions of the images to size the carousel. This is expensive - we need to trigger page layout after the image is downloaded, and different images may have different sizes, so switching images can cause the carousel to change size and trigger page layout again. Since the element author does not know about the size of the images in the carousel, it's up to the element user to set the size of the carousel. Fortunately, the API of <my-carousel> can accommodate this - the user just has to set the size on the carousel and the images within it. In index.html:

<style>
  my-carousel,
  my-carousel img {
    width: 400px;
    height: 300px;
  }
</style>

Maintaining a Fixed Aspect Ratio

But what if we wanted the width of the carousel to fill the size of the container while maintaining the 4:3 aspect ratio? We can use the padding trick - a percentage value of padding is always relative to the width, so we can use a placeholder element (such as pseudo ::after) to give the carousel height and absolutely position the images within the carousel. Let's replace the <style> tag in index.html with:

<style>
  my-carousel {
    width: 100%;
  }

  my-carousel::after {
    display: block;
    content: '';
    padding-top: 75%; /* 4:3 = height is 75% of width */
  }

  my-carousel img {
    position: absolute;
    width: 100%;
    height: 100%;
  }
</style>

Using the CSS contain Property

One more optimization - since we know that the carousel won't affect the style of the rest of the document, we can use the CSS contain property to let the browser know so it can perform optimizations. (Note: we use contain: content instead of contain: strict because the size of the carousel is determined by a dependant, namely the pseudo ::after which gives the carousel height.) In the <style> tag of index.html:

my-carousel {
  width: 100%;
  contain: content;
}

Currently, when the page loads, we immediately load all the images in the carousel. As an optional optimization, we can add lazy-loading of images to the carousel. The element user will specify the image source in the data-src attribute of the <img> element, and <my-carousel> will dynamically set the src when the image (or an adjacent image) becomes selected. In index.html:

<my-carousel>
  <img data-src="https://app-layout-assets.appspot.com/assets/bg1.jpg">
  <img data-src="https://app-layout-assets.appspot.com/assets/bg2.jpg">
  <img data-src="https://app-layout-assets.appspot.com/assets/bg3.jpg">
  <img data-src="https://app-layout-assets.appspot.com/assets/bg4.jpg">
</my-carousel>

We'll define a new method, _loadImage(), that will set src based on data-src. In my-carousel.html:

_loadImage(img) {
  if (img && !img.src) {
    img.src = img.getAttribute('data-src');
  }
}

Then we'll use this in the _selectedChanged() observer. In my-carousel.html:

_selectedChanged(selected, oldSelected) {
  super._selectedChanged(selected, oldSelected);

  this.$.prevBtn.disabled = !selected.previousElementSibling;
  this.$.nextBtn.disabled = !selected.nextElementSibling;

  this._loadImage(selected);
  this._loadImage(selected.previousElementSibling);
  this._loadImage(selected.nextElementSibling);
}

First, we'll define a helper function that makes an element visible and horizontally translates it (optionally with a transition). In my-carousel.html:

_translateX(elem, x, transition) {
  elem.style.display = 'block';
  elem.style.transition = transition ? 'transform 0.2s' : '';
  elem.style.transform = 'translate3d(' + x + 'px, 0, 0)';
}

We'll modify our previous() and next() methods to transition the images. First, we position the incoming and outgoing images without a transition. Then, we translate them with a transition. In my-carousel.html:

previous() {
  const elem = this.selected.previousElementSibling;
  if (elem) {
    // Setup transition start state
    const oldSelected = this.selected;
    this._translateX(oldSelected, 0);
    this._translateX(elem, -this.offsetWidth);

    // Start the transition
    this.selected = elem;
    this._translateX(oldSelected, this.offsetWidth, true /* transition */);
    this._translateX(elem, 0, true /* transition */);
  }
}

next() {
  const elem = this.selected.nextElementSibling;
  if (elem) {
    // Setup transition start state
    const oldSelected = this.selected;
    this._translateX(oldSelected, 0);
    this._translateX(elem, this.offsetWidth);

    // Start the transition
    this.selected = elem;
    this._translateX(oldSelected, -this.offsetWidth, true /* transition */);
    this._translateX(elem, 0, true /* transition */);
  }
}

After the transition finishes, we need to go back and clean up the style attributes that we set, so we'll add a transitionend event listener to do this. Because we know there isn't going to be a transition until after the first frame, we can reduce the initialization time of our element by installing our event listener asynchronously, such as in a requestAnimationFrame. In the connectedCallback()of my-carousel.html, add:

connectedCallback() {
  super.connectedCallback();

  requestAnimationFrame(this._installListeners.bind(this));
}

Then we'll add these methods to the element to my-carousel.html:

_installListeners() {
  this.addEventListener('transitionend', this._resetChildrenStyles.bind(this));
}

_resetChildrenStyles() {
  let elem = this.firstElementChild;
  while (elem) {
    elem.style.display = '';
    elem.style.transition = '';
    elem.style.transform = '';
    elem = elem.nextElementSibling;
  }
}

The _resetChildrenStyles() method will clear the style attributes that were set by our element. Now we have a carousel that transitions between images:

Adding Touch Listeners

First, let's add event handlers for touchstart, touchmove, and touchend. Also, there's nothing to transition if there's less than two images, so we'll return immediately if that's the case. In my-carousel.html:

_touchstart(event) {
  // No transition if less than two images
  if (this.childElementCount < 2) {
    return;
  }
}

_touchmove(event) {
  // No transition if less than two images
  if (this.childElementCount < 2) {
    return;
  }
}

_touchend(event) {
  // No transition if less than two images
  if (this.childElementCount < 2) {
    return;
  }
}

We'll add these listeners to our _installListeners() method in my-carousel.html:

_installListeners() {
  this.addEventListener('transitionend', this._resetChildrenStyles.bind(this));
  this.addEventListener('touchstart', this._touchstart.bind(this));
  this.addEventListener('touchmove', this._touchmove.bind(this));
  this.addEventListener('touchend', this._touchend.bind(this));
}

Distinguish between Swiping and Scrolling

The user may try to vertically scroll the page while touching the carousel. To handle this case, we can detect if the touchmove is mostly vertical, and if so, we'll ignore touch events until touchend. Otherwise, we'll only transform the images (and block scrolling) until touchend.

To determine if a touchmove is mostly vertical, we capture the touch position at touchstart, and in touchmove, we compare the change in the x and y directions to see which is greater. Let's modify these methods in my-carousel.html:

_touchstart(event) {
  // ... <previously inserted code> ...

  // Save start coordinates
  if (!this._touchDir) {
    this._startX = event.changedTouches[0].clientX;
    this._startY = event.changedTouches[0].clientY;
  }
}

_touchmove(event) {
  // ... <previously inserted code> ...

  // Is touchmove mostly horizontal or vertical?
  if (!this._touchDir) {
    const absX = Math.abs(event.changedTouches[0].clientX - this._startX);
    const absY = Math.abs(event.changedTouches[0].clientY - this._startY);
    this._touchDir = absX > absY ? 'x' : 'y';
  }

  if (this._touchDir === 'x') {
    // Prevent vertically scrolling when swiping
    event.preventDefault();
  }
}

_touchend(event) {
  // ... <previously inserted code> ...

  // Reset touch direction
  this._touchDir = null;
}

We can also use this state information to disable the previous() and next() functionality while the user is interacting with the carousel. In my-carousel.html:

previous() {
  const elem = this.selected.previousElementSibling;
  if (elem && !this._touchDir) {
    // ... <previously inserted code> ...
  }
}

next() {
  const elem = this.selected.nextElementSibling;
  if (elem && !this._touchDir) {
    // ... <previously inserted code> ...
  }
}

Transforming the Images

We can reuse the _translateX() method to move the images based on touchmove events. Also, we'll be sure to handle cases when there are no adjacent images. Let's add to the touchmove method in my-carousel.html:

_touchmove(event) {
  // ... <previously inserted code> ...

  if (this._touchDir === 'x') {
    // Prevent vertically scrolling when swiping
    event.preventDefault();

    let dx = Math.round(event.changedTouches[0].clientX - this._startX);
    const prevChild = this.selected.previousElementSibling;
    const nextChild = this.selected.nextElementSibling;

    // Don't translate past the current image if there's no adjacent image in that direction
    if ((!prevChild && dx > 0) || (!nextChild && dx < 0)) {
      dx = 0;
    }

    this._translateX(this.selected, dx);
    if (prevChild) {
      this._translateX(prevChild, dx - this.offsetWidth);
    }
    if (nextChild) {
      this._translateX(nextChild, dx + this.offsetWidth);
    }
  }
}

Handling touchend

On touchend, we'll need to reset the style attributes and potentially switch to an adjacent image (based on a threshold change in the x direction). This is a little tricky - since we won't get a transitionend event if the images are already in their final position, we'll need to call _resetChildrenStyles() ourselves in those cases. Here's the final _touchend() listener for my-carousel.html:

_touchend(event) {
  // No transition if less than two images
  if (this.childElementCount < 2) {
    return;
  }

  // Don't finish swiping if there are still active touches.
  if (event.touches.length) {
    return;
  }

  if (this._touchDir === 'x') {
    let dx = Math.round(event.changedTouches[0].clientX - this._startX);
    const prevChild = this.selected.previousElementSibling;
    const nextChild = this.selected.nextElementSibling;

    // Don't translate past the current image if there's no adjacent image in that direction
    if ((!prevChild && dx > 0) || (!nextChild && dx < 0)) {
      dx = 0;
    }

    if (dx > 0) {
      if (dx > 100) {
        if (dx === this.offsetWidth) {
          // No transitionend will fire (since we're already in the final state),
          // so reset children styles now
          this._resetChildrenStyles();
        } else {
          this._translateX(prevChild, 0, true);
          this._translateX(this.selected, this.offsetWidth, true);
        }
        this.selected = prevChild;
      } else {
        this._translateX(prevChild, -this.offsetWidth, true);
        this._translateX(this.selected, 0, true);
      }
    } else if (dx < 0) {
      if (dx < -100) {
        if (dx === -this.offsetWidth) {
          // No transitionend will fire (since we're already in the final state),
          // so reset children styles now
          this._resetChildrenStyles();
        } else {
          this._translateX(this.selected, -this.offsetWidth, true);
          this._translateX(nextChild, 0, true);
        }
        this.selected = nextChild;
      } else {
        this._translateX(this.selected, 0, true);
        this._translateX(nextChild, this.offsetWidth, true);
      }
    } else {
      // No transitionend will fire (since we're already in the final state),
      // so reset children styles now
      this._resetChildrenStyles();
    }
  }

  // Reset touch direction
  this._touchDir = null;
}