Creating a Multiselect Component as a Web Component

    Artem Tabalin
    Share

    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.