Creating a Timepicker Web Component

Following on from my last post on the basics of web components this article will detail how to make a basic web component – a timepicker.

The stages for creating the component will be:

  • defining a template of the component’s DOM
  • defining a JavaScript class for the component
  • importing the component into another HTML file to use it

Creating the Component’s DOM

The structure of the component will be created as an HTML template. The JavaScript class we define later will clone this template and add it to the shadow root of our web component. You can see how this looks in the demo.

<template id="time-picker-dom">
  <style>
    :host {
      display: inline-flex;
      align-items: center;
      padding: 0.5rem;
      border-radius: 6px;
      background-color: black;
      color: white;
    }
    
    .field {
      display: flex;
      flex-direction: column;
    }
    
    .field > * {
      padding: 2px;
      border-radius: 2px;
    }

    .field > *:hover, .field > *:focus {
      background-color: #355cca;
    }

    input {
      font-size: 1.2rem;
      width: 2ch;
      height: 2ch;
      background-color: inherit;
      border: none;
      color: inherit;
    }

    svg {
      fill: white;
    }

    .colon::after {
      content: ':';
      margin: 0.4rem;
      color: white;
    }
  </style>
  <div class="field">
    <svg class="icon switch" viewBox="0 0 24 24" tabIndex="0"><g><path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"></path></g></svg>
    <input id="hours" type="text" maxlength="2">
    <svg class="icon switch" viewBox="0 0 24 24" tabIndex="0"><g><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path></g></svg>
  </div>

  <span class="colon"></span>

  <div class="field">
    <svg class="icon switch" viewBox="0 0 24 24" tabIndex="0"><g><path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"></path></g></svg>
    <input id="minutes" type="text" maxlength="2">
    <svg class="icon switch" viewBox="0 0 24 24" tabIndex="0"><g><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path></g></svg>
  </div>

  <span class="colon"></span>

  <div class="field">
    <svg class="icon switch" viewBox="0 0 24 24" tabIndex="0"><g><path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"></path></g></svg>
    <input id="seconds" type="text" maxlength="2">
    <svg class="icon switch" viewBox="0 0 24 24" tabIndex="0"><g><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path></g></svg>
  </div>

</template>

Styling

The DOM directly contains the style for the component. This allows the style to be scoped to the component. In this example all of the style is simply hardcoded but a good approach for real world use is to use CSS custom properties to allow clients to adjust the style as necessary.

The :host pseudo-class allows the element hosting the shadow root to be styled.

Defining the JavaScript class

The class used to instantiate web components must extend HTMLElement or one of its subclasses.

The function observedAttributes defines the attributes for which the lifecycle callback attributeChangedCallback() will be called. attributeChangedCallback() is implemented to re-render the timepicker’s display values.

Getters and setters are defined for these properties to provide a clean API to the component where we can simply access the properties for hours, minutes and seconds as well as set them using the assignment operator. The setters validate the values assigned.

Several event listeners are defined to allow interactions with the component both through the increment/decrement buttons and by changing the displayed value directly. If the user directly enters an invalid value the timepicker display values are re-rendered to show their last valid values.

class TimePicker extends HTMLElement {
  static get observedAttributes() {return ['hours', 'minutes', 'seconds']};

  constructor() {
    super();
    this.initShadowDOM();
    this.setUIReferences();
    this.updateDisplay();
    this.addEventListeners();
  }

  initShadowDOM() {
    this.attachShadow({mode: 'open'});
    const clockDOM = document.getElementById('time-picker-dom');
    const clockDOMClone = document.importNode(clockDOM.content, true);
    this.shadowRoot.appendChild(clockDOMClone);
  }

  setUIReferences() {
    const fields = this.shadowRoot.querySelectorAll('.field');
    this.hourDisplay = fields[0].querySelector('input');
    this.hourIncrement = fields[0].querySelectorAll('svg')[0];
    this.hourDecrement = fields[0].querySelectorAll('svg')[1];

    this.minuteDisplay = fields[1].querySelector('input');
    this.minuteIncrement = fields[1].querySelectorAll('svg')[0];
    this.minuteDecrement = fields[1].querySelectorAll('svg')[1];

    this.secondDisplay = fields[2].querySelector('input');
    this.secondIncrement = fields[2].querySelectorAll('svg')[0];
    this.secondDecrement = fields[2].querySelectorAll('svg')[1];
  }
  
  set hours(value) {
    const hours = parseInt(value);
    if (this.validateHours(hours)) {
      this.setAttribute('hours', hours);
    }
  }
  
  set minutes(value) {
    const minutes = parseInt(value);
    if (this.validateMinutesOrSeconds(minutes)) {
      this.setAttribute('minutes', minutes);
    }
  }
  
  set seconds(value) {
    const seconds = parseInt(value);
    if (this.validateMinutesOrSeconds(seconds)) {
      this.setAttribute('seconds', seconds);
    }
  }
  
  get hours() {
    return parseInt(this.getAttribute('hours'));
  }
  
  get minutes() {
    return parseInt(this.getAttribute('minutes'));
  }
  
  get seconds() {
    return parseInt(this.getAttribute('seconds'));
  }

  addEventListeners() {
    this.incrementHour = this.incrementHour.bind(this);
    this.decrementHour = this.decrementHour.bind(this);
    this.incrementMinute = this.incrementMinute.bind(this);
    this.decrementMinute = this.decrementMinute.bind(this);
    this.incrementSecond = this.incrementSecond.bind(this);
    this.decrementSecond = this.decrementSecond.bind(this);
    
    function executeOnEnter(fn, event) {
      if (event.key === 'Enter') {
        fn();
      }
    }
    
    this.hourIncrement.addEventListener('click', this.incrementHour);
    this.hourIncrement.addEventListener('keydown', executeOnEnter.bind(this, this.incrementHour));

    this.hourDecrement.addEventListener('click', this.decrementHour);
    this.hourDecrement.addEventListener('keydown', executeOnEnter.bind(this, this.decrementHour));

    this.minuteIncrement.addEventListener('click', this.incrementMinute);
    this.minuteIncrement.addEventListener('keydown', executeOnEnter.bind(this, this.incrementMinute));

    this.minuteDecrement.addEventListener('click', this.decrementMinute);
    this.minuteDecrement.addEventListener('keydown', executeOnEnter.bind(this, this.decrementMinute));

    this.secondIncrement.addEventListener('click', this.incrementSecond);
    this.secondIncrement.addEventListener('keydown', executeOnEnter.bind(this, this.incrementSecond));
    
    this.secondDecrement.addEventListener('click', this.decrementSecond);
    this.secondDecrement.addEventListener('keydown', executeOnEnter.bind(this, this.decrementSecond));

    this.hourDisplay.addEventListener('change', event => {
      const hours = parseInt(event.currentTarget.value);
      if (this.validateHours(hours)) {
        this.setAttribute('hours', hours);
      } else {
        this.updateDisplay();
      }
    });

    this.minuteDisplay.addEventListener('change', event => {
      const minutes = parseInt(event.currentTarget.value);
      if (this.validateMinutesOrSeconds(minutes)) {
        this.setAttribute('minutes', minutes);
      } else {
        this.updateDisplay();
      }
    });

    this.secondDisplay.addEventListener('change', event => {
      const seconds = parseInt(event.currentTarget.value);
      if (this.validateMinutesOrSeconds(seconds)) {
        this.setAttribute('seconds', seconds);
      } else {
        this.updateDisplay();
      }
    });
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (['hours', 'minutes', 'seconds'].includes(name)) {
      this.updateDisplay();
    }
  }

  incrementHour() {
    let hours = parseInt(this.getAttribute('hours'));
    hours = (hours + 1) % 24;
    this.setAttribute('hours', hours);
  }

  decrementHour() {
    let hours = parseInt(this.getAttribute('hours'));
    --hours;
    if (hours === -1) {
      hours = 23;
    }
    this.setAttribute('hours', hours);
  }

  incrementMinute() {
    let minutes = parseInt(this.getAttribute('minutes'));
    minutes = (minutes + 1) % 60;
    if (minutes === 0) {
      this.incrementHour();
    }

    this.setAttribute('minutes', minutes);
  }

  decrementMinute() {
    let minutes = parseInt(this.getAttribute('minutes'));
    --minutes;
    if (minutes === -1) {
      minutes = 59;
      this.decrementHour();
    }

    this.setAttribute('minutes', minutes);
  }

  incrementSecond() {
    let seconds = parseInt(this.getAttribute('seconds'));
    seconds = (seconds + 1) % 60;
    if (seconds === 0) {
      this.incrementMinute();
    }

    this.setAttribute('seconds', seconds);
  }

  decrementSecond() {
    let seconds = parseInt(this.getAttribute('seconds'));
    --seconds;
    if (seconds === -1) {
      seconds = 59;
      this.decrementMinute();
    }

    this.setAttribute('seconds', seconds);
  }

  updateDisplay() {
    this.hourDisplay.value = this.formatClockValue(this.getAttribute('hours'));
    this.minuteDisplay.value = this.formatClockValue(this.getAttribute('minutes'));
    this.secondDisplay.value = this.formatClockValue(this.getAttribute('seconds'));
  }

  formatClockValue(value) {
    return value.toString().padStart(2, '0');
  }

  validateHours(value) {
    return Number.isInteger(value) && value >= 0 && value < 24;
  }

  validateMinutesOrSeconds(value) {
    return Number.isInteger(value) && value >= 0 && value < 60;
  }

  get time() {
    const nowDate = new Date();
    return Date.UTC(nowDate.getFullYear(), nowDate.getMonth(), nowDate.getDate(), this.getAttribute('hours'), this.getAttribute('minutes'), this.getAttribute('seconds'));
  }
}

window.customElements.define('time-picker', TimePicker);

Importing and Using the Timepicker

Importing is simply done by including a link element in the document head.

<link rel="import" href="time-picker.html">

The timepicker can then be declared with initial attribute values.

<time-picker hours="12" minutes="59" seconds="59"></time-picker>

Demo

Here is a Codepen demo of the component in action. Due to the nature of Codepen the HTML import is not used but the main code is the same.