JavaScript
Article

Creating a Multiselect Component as a Web Component

By Artem Tabalin

Update 12.05.2016: Following some discussion in the comments, a second post has been written to address the shortcomings of this one — How to Make Accessible Web Components. Please be sure to read this, too.

This article was peer reviewed by Ryan Lewis. Thanks to all of SitePoint’s peer reviewers for making SitePoint content the best it can be!

Web applications become every day more complicated and require a lot of markup, scripts and styling. To manage and maintain hundred kilobytes of HTML, JS, and CSS we try to split our application into reusable components. We try hard to encapsulate components and prevent styles clashing and scripts interference.

In the end a component source code is distributed between several files: markup file, script file, and a stylesheet. Another issue we might encounter is having long markup cluttered with divs and spans. This kind of code is weakly-expressive and also hardly maintainable. To address and try to solve all these issues, W3C has introduced Web Components.

In this article I’m going to explain what Web Components are and how you can build one by yourself.

Meet Web Components

Web Components solve all these issues discussed in the introduction. Using Web Components we can link a single HTML file containing the implementation of a component and use it on the page with a custom HTML element. They simplify the creation of components, strengthen encapsulation, and make markup more expressive.

Web Components are defined with a suite of specifications:

  • Custom Elements: allow to register a custom meaningful HTML element for a component
  • HTML Templates: define the markup of the component
  • Shadow DOM: encapsulates internals of the component and hides it from the page where it’s used
  • HTML Imports: provides the ability to include the component to the target page.

Having describe what Web Components are, let’s have a look at them in action.

How to Build a Production-Ready Web Component

In this section, we’re going to build a useful multiselect widget that is ready to use in production. The result can be found on this demo page and the whole source code can be found on GitHub.

Requirements

First of all, let’s define some requirements to our multiselect widget.

The markup should have the following structure:

<x-multiselect placeholder="Select Item">
    <li value="1" selected>Item 1</li>
    <li value="2">Item 2</li>
    <li value="3" selected>Item 3</li>
</x-multiselect>

The custom element <x-multiselect> has a placeholder attribute to define the placeholder of the empty multiselect. Items are defined with <li> elements supporting value and selected attributes.

The multiselect should have the selectedItems API method returning an array of selected items.

// returns an array of values, e.g. [1, 3]
var selectedItems = multiselect.selectedItems();

Moreover, the widget should fire an event change each time selected items are changed.

multiselect.addEventListener('change', function() {
    // print selected items to console
    console.log('Selected items:', this.selectedItems()); 
});

Finally, the widget should work in all modern browsers.

Template

We start creating the multiselect.html file that will contain all the source code of our component: HTML markup, CSS styles, and JS code.

HTML Templates allow us to define the template of the component in a special HTML element <template>. Here is the template of our multiselect:

<template id="multiselectTemplate">
    <style>
      /* component styles */
    </style>

    <!-- component markup -->
    <div class="multiselect">
        <div class="multiselect-field"></div>
        <div class="multiselect-popup">
            <ul class="multiselect-list">
                <content select="li"></content>
            </ul>
        </div>
    </div>
</template>

The component markup contains the field of the multiselect and a popup with the list of the items. We want multiselect to get items right from the user markup. We can do this with a new HTML element <content> (you can find more info about the content element on MDN). It defines the insertion point of the markup from shadow host (component declaration in user markup) to the shadow DOM (encapsulated component markup).

The select attribute accepts CSS selector and defines which elements to pick from the shadow host. In our case we want to take all <li> elements and set select="li".

Create Component

Now let’s create a component and register a custom HTML element. Add the following creation script to the multiselect.html file:

<script>
    // 1. find template
    var ownerDocument = document.currentScript.ownerDocument;
    var template = ownerDocument.querySelector('#multiselectTemplate');

    // 2. create component object with the specified prototype 
    var multiselectPrototype = Object.create(HTMLElement.prototype);

    // 3. define createdCallback
    multiselectPrototype.createdCallback = function() {
        var root = this.createShadowRoot();
        var content = document.importNode(template.content, true);
        root.appendChild(content);
    };

    // 4. register custom element
    document.registerElement('x-multiselect', {
        prototype: multiselectPrototype
    });
</script>

The creation of a Web Component includes four steps:

  1. Find a template in the owner document.
  2. Create a new object with the specified prototype object. In this case we’re inheriting from an existing HTML element, but any available element can be extended.
  3. Define createdCallback that is called when component is created. Here we create a shadow root for the component and append the content of the template inside.
  4. Register a custom element for the component with the document.registerElement method.

To learn more about creating custom elements, I suggest you to check out Eric Bidelman’s guide.

Render Multiselect Field

The next step is to render the field of the multiselect depending on selected items.

The entry point is the createdCallback method. Let’s define two methods, init and render:

multiselectPrototype.createdCallback = function() {
    this.init();
    this.render();
};

The init method creates a shadow root and finds all the internal component parts (the field, the popup, and the list):

multiselectPrototype.init = function() {
    // create shadow root
    this._root = this.createRootElement();

    // init component parts
    this._field = this._root.querySelector('.multiselect-field');
    this._popup = this._root.querySelector('.multiselect-popup');
    this._list = this._root.querySelector('.multiselect-list');
};

multiselectPrototype.createRootElement = function() {
    var root = this.createShadowRoot();
    var content = document.importNode(template.content, true);
    root.appendChild(content);
    return root;
};

The render method does the actual rendering. So it calls the refreshField method that loops over selected items and creates tags for each selected item:

multiselectPrototype.render = function() {
    this.refreshField();
};

multiselectPrototype.refreshField = function() {
    // clear content of the field
    this._field.innerHTML = '';

    // find selected items
    var selectedItems = this.querySelectorAll('li[selected]');

    // create tags for selected items
    for(var i = 0; i < selectedItems.length; i++) {
        this._field.appendChild(this.createTag(selectedItems[i]));
    }
};

multiselectPrototype.createTag = function(item) {
    // create tag text element
    var content = document.createElement('div');
    content.className = 'multiselect-tag-text';
    content.textContent = item.textContent;

    // create item remove button
    var removeButton = document.createElement('div');
    removeButton.className = 'multiselect-tag-remove-button';
    removeButton.addEventListener('click', this.removeTag.bind(this, tag, item));

    // create tag element
    var tag = document.createElement('div');
    tag.className = 'multiselect-tag';
    tag.appendChild(content);
    tag.appendChild(removeButton);

    return tag;
};

Each tag has a remove button. The remove button click handler remove the selection from items and refreshes the multiselect field:

multiselectPrototype.removeTag = function(tag, item, event) {
    // unselect item
    item.removeAttribute('selected');

    // prevent event bubbling to avoid side-effects
    event.stopPropagation();

    // refresh multiselect field
    this.refreshField();
};

Open Popup and Select Item

When the user clicks the field, we should show the popup. When he/she clicks the list item, it should be marked as selected and the popup should be hidden.

To do this, we handle clicks on the field and the item list. Let’s add the attachHandlers method to the render:

multiselectPrototype.render = function() {
    this.attachHandlers();
    this.refreshField();
};

multiselectPrototype.attachHandlers = function() {
    // attach click handlers to field and list
    this._field.addEventListener('click', this.fieldClickHandler.bind(this));
    this._list.addEventListener('click', this.listClickHandler.bind(this));
};

In the field click handler we toggle popup visibility:

multiselectPrototype.fieldClickHandler = function() {
    this.togglePopup();
};

multiselectPrototype.togglePopup = function(show) {
    show = (show !== undefined) ? show : !this._isOpened;
    this._isOpened = show;
    this._popup.style.display = this._isOpened ? 'block' : 'none';
};

In the list click handler we find clicked item and mark it as selected. Then, we hide the popup and refresh the field of multiselect:

multiselectPrototype.listClickHandler = function(event) {
    // find clicked list item
    var item = event.target;
    while(item && item.tagName !== 'LI') {
        item = item.parentNode;
    }
    
    // set selected state of clicked item
    item.setAttribute('selected', 'selected');

    // hide popup
    this.togglePopup(false);

    // refresh multiselect field
    this.refreshField();
};

Add Placeholder Attribute

Another multiselect feature is a placeholder attribute. The user can specify the text to be displayed in the field when no item is selected. To achieve this task, let’s read the attribute values on the component initialization (in the init method):

multiselectPrototype.init = function() {
    this.initOptions();
    ...
};

multiselectPrototype.initOptions = function() {
    // save placeholder attribute value
    this._options = {
        placeholder: this.getAttribute("placeholder") || 'Select'
    };
};

The refreshField method will show placeholder when no item is selected:

multiselectPrototype.refreshField = function() {
    this._field.innerHTML = '';

    var selectedItems = this.querySelectorAll('li[selected]');

    // show placeholder when no item selected
    if(!selectedItems.length) {
        this._field.appendChild(this.createPlaceholder());
        return;
    }

    ...
};

multiselectPrototype.createPlaceholder = function() {
    // create placeholder element
    var placeholder = document.createElement('div');
    placeholder.className = 'multiselect-field-placeholder';
    placeholder.textContent = this._options.placeholder;
    return placeholder;
};

But this is not the end of the story. What if a placeholder attribute value is changed? We need to handle this and update the field. Here the attributeChangedCallback callback comes in handy. This callback is called each time an attribute value is changed. In our case we save a new placeholder value and refresh the field of multiselect:

multiselectPrototype.attributeChangedCallback = function(optionName, oldValue, newValue) {
    this._options[optionName] = newValue;
    this.refreshField();
};

Add selectedItems Method

All we need to do is to add a method to the component prototype. The implementation of the selectedItems method is trivial – loop over selected items and read values. If the item has no value, then the item text is returned instead:

multiselectPrototype.selectedItems = function() {
    var result = [];

    // find selected items
    var selectedItems = this.querySelectorAll('li[selected]');

    // loop over selected items and read values or text content
    for(var i = 0; i < selectedItems.length; i++) {
        var selectedItem = selectedItems[i];

        result.push(selectedItem.hasAttribute('value')
                ? selectedItem.getAttribute('value')
                : selectedItem.textContent);
    }

    return result;
};

Add Custom Event

Now let’s add the change event that will be fired each time the user changes the selection. To fire an event we need to create a CustomEvent instance and dispatch it:

multiselectPrototype.fireChangeEvent = function() {
    // create custom event instance
    var event = new CustomEvent("change");

    // dispatch event
    this.dispatchEvent(event);
};

At this point, we need to fire the event when the user selects or unselects an item. In the list click handler we fire the event just when an item was actually selected:

multiselectPrototype.listClickHandler = function(event) {
    ...
    
    if(!item.hasAttribute('selected')) {
        item.setAttribute('selected', 'selected');
        this.fireChangeEvent();
        this.refreshField();
    }
    
    ...
};

In the remove tag button handler we also need to fire the change event since an item has been unselected:

multiselectPrototype.removeTag = function(tag, item, event) {
    ...
    
    this.fireChangeEvent();
    this.refreshField();
};

Styling

Styling the internal elements of Shadow DOM is pretty straightforward. We attach few particular classes like multiselect-field or multiselect-popup and add necessary CSS rules for them.

But how can we style list items? The problem is that they are coming from shadow host and don’t belong to the shadow DOM. The special selector ::content comes to our rescue.

Here are the styles for our list items:

::content li {
    padding: .5em 1em;
    min-height: 1em;
    list-style: none;
    cursor: pointer;
}

::content li[selected] {
    background: #f9f9f9;
}

Web Components introduced a few special selectors, and you can find out more about them here.

Usage

Great! Our multiselect functionality is completed, thus we’re ready to use it. All we need to do is to import the multiselect HTML file and add a custom element to the markup:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <link rel="import" href="multiselect.html">
</head>
<body>
    <x-multiselect placeholder="Select Value">
        <li value="1" selected>Item 1</li>
        <li value="2">Item 2</li>
        <li value="3" selected>Item 3</li>
        <li value="4">Item 4</li>
    </x-multiselect>
</body>
</html>

Let’s subscribe to change event and print selected items to the console each time the user changes the selection:

<script>
    var multiselect = document.querySelector('x-multiselect');
    multiselect.addEventListener('change', function() {
        console.log('Selected items:', this.selectedItems());
    });
</script>

Go to the demo page and open browser console to see selected items each time the selection is changed.

Browsers Support

If we look at browser support, we see that Web Components are fully supported by Chrome and Opera only. Nevertheless, we can still use Web Components with the suite of polyfills webcomponentjs, which allows to use Web Components in the latest version of all browsers.

Let’s apply this polyfill to be able to use our multiselect in all browsers. It can be installed with Bower and then included in your web page.

bower install webcomponentsjs

If we open the demo page in Safari, we’ll see the error in the console “null is not an object”. The issue is that document.currentScript doesn’t exist. To fix the issue, we need to get ownerDocument from the polyfilled environment (using document._currentScript instead of document.currentScript).

var ownerDocument = (document._currentScript || document.currentScript).ownerDocument;

It works! But if you open multiselect in Safari, you’ll see that list items are not styled. To fix this other issue, we need to shim styling of the template content. It can be done with theWebComponents.ShadowCSS.shimStyling method. We should call it before appending shadow root content:

multiselectPrototype.createRootElement = function() {
    var root = this.createShadowRoot();
    var content = document.importNode(template.content, true);

    if (window.ShadowDOMPolyfill) {
        WebComponents.ShadowCSS.shimStyling(content, 'x-multiselect');
    }

    root.appendChild(content);
    return root;
};

Congratulations! Now our multiselect component works properly and looks as expected in all modern browsers.

Web Components polyfills are great! It obviously took huge efforts to make these specs work across all modern browsers. The size of polyfill source script is 258Kb. Although, the minified and gzipped version is 38Kb, we can imagine how much logic is hidden behind the scene. It inevitably influences performances. Although authors make the shim better and better putting accent on the performance.

Polymer & X-Tag

Talking about Web Components I should mention Polymer. Polymer is a library built on top of Web Components that simplifies the creation of components and provides plenty of ready-to-use elements. The webcomponents.js polyfill was a part of Polymer and was called platform.js. Later, it was extracted and renamed.

Creating Web components with Polymer is way easier. This article by Pankaj Parashar shows how to use Polymer to create Web Components.
If you want to deepen the topic, here is a list of articles that might be useful:

There is another library that can make working with Web Components simpler, and that is X-Tag. It was developed by Mozilla, and now it’s supported by Microsoft.

Conclusions

Web Components are a huge step forward in the Web development field. They help to simplify the extraction of components, strengthen encapsulation, and make markup more expressive.

In this tutorial we’ve seen how to build a production-ready multiselect widget with Web Components. Despite of the lack of browser support, we can use Web Components today thanks to high-quality polyfill webcomponentsjs. Libraries like Polymer and X-Tag offer the chance to create Web components in an easier way.

Now please be sure to check out the follow up post: How to Make Accessible Web Components.

Have you already used Web Components in your web applications? Feel free to share your experience and thoughts in the section below.

Meet the author
Artem is a web developer with 8 years experience in back-end and front-end development. He worked at DevExpress creating DevExtreme and currently works at Criteo. He loves learning new web-related things and believes in open source.
  • http://webplatformdaily.org Šime Vidas

    How can you call it production-ready if it’s not keyboard-accessible?

    • http://tabalin.net Artem Tabalin

      Well, why not. It’s not necessary to have all possible features implemented to be production-ready. The widget follows stated requirements and works in all modern browsers, so it can be used in a final application.

      • heydonworks

        Keyboard accessibility is not a feature. If an interactive widget is on The Web and not keyboard accessible, it is unfinished / broken. It is not ready for The Web’s diversity of users.

        • http://tabalin.net Artem Tabalin

          I do agree that keyboard support is a must-have feature for a ui control. The point is it’s out of the scope of the article, which aimed at showing how to create a web component covering basic aspects like template, attributes, events, api methods, and styling.

          As for the keyboard support it can be easily added. All we need to do is to add a tabindex attribute to the root element and put a couple of handlers on keypress.

          • heydonworks

            If the aim of the article is to show how to make a web component and the web component is a ui control and keyboard accessibility is—as you stated—a must-have feature of ui controls, then why is there no keyboard accessibility?

          • http://bittersmann.de/ Gunnar Bittersmann

            The point is it should not be out of the scope. People learn from examples in articles. They either learn how to do it the good™ way or how to do it the bad™ way (shades of gray in between…)—it’s the responsibility of the author to teach the good™ way.

            ‘After reading your article I will search for an article telling me how to add keyboard support.’ —no reader, ever.

            People will start thinking about accessibility not by reading special articles on that topic, but on the fly by reading articles on other topics. Or they never will.

      • hexx

        what??? are you serious?

  • http://tabalin.net Artem Tabalin

    Thank you Šime for the discussion. I agree with you and don’t think that it’s acceptable to ship non-accessible components. You could even argue that we should go further and support WAI ARIA to make the component screenreader-friendly. This is all true.
    But again, this is out of the main scope of the article. And, as I mentioned, can be easily implemented for the existing component.

    • http://adrianroselli.com/ adrianroselli

      For what it’s worth, developers have a long history of copying code and pasting it into their own applications (I maintain that a great many systems have been built from code snippets cobbled together from Stack Overflow). Those same developers are unlikely to recognize the holes in this example (they are holes, or bugs, not nice-to-have features). As such, they will codify broken features into their applications, and those will be re-used in follow-on projects. That leaves a trail of broken implementations (that carry legal risk for them as well). I think that’s more than enough of an argument to not only fix the keyboard problems here, but also to add necessary ARIA support for screen readers and other assistive technology. What a great opportunity you have to set this tutorial apart!

    • http://marcysutton.com/ Marcy Sutton

      Despite “scope”, it hurts more than it helps to put inaccessible examples out there on the Web. Developers will find it and copy it–we need to set a better example.

  • James Hibbard

    Hi all, Just to let you know that your accessibility concerns have been heard and the situation will be addressed.

  • James Hibbard

    By way of an update, we now have a new branch in the repo where keyboard accessibility has been implemented: https://github.com/sitepoint-editors/multiselect-web-component/tree/keyboard_support

    Artem will be writing a second article explaining in more depth how to make the multiselect component keyboard accessible.

    • http://onsman.com/ ronsman

      That’s still back-to-front, James. It’s not about publishing articles that address accessibility needs AFTER publishing an article that disregards accessibility. It’s about publishing articles that take accessibility into account in the first place. Adopt that as a policy and help to change the world. Make accessibility part of the scope of every article. Put it into your article writing guidelines: “If you don’t cover accessibility in your article, you must explain why”.

      • James Hibbard

        A good suggestion.

Learn Coding Online
Learn Web Development

Start learning web development and design for free with SitePoint Premium!