Creating a Multiselect Component as a Web Component
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 div
s and span
s. 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:
- Find a template in the owner document.
- 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.
- 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. - 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:
- Building Custom Web Components with X-Tag
- Building an Image Gallery Component with Polymer
- Bringing Componentization to the Web: An Overview of Web Components
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.