Developing Web Components (2015)
Part III. Building HTML5 Web Components
Chapter 12. Creating Custom Elements
Jason Strimpel
Raise your hand if you have seen markup like this:
<ul class="product-listing">
<li class="product">
<img src="img/log.jpg" alt="log" />
<h3>log</h3>
<p>
What rolls down stairs<br />
alone or in pairs,<br />
and over your neighbor's dog?<br />
What's great for a snack,<br />
And fits on your back?<br />
It's log, log, log<br />
<p>
<ul class="reviews">
<li class="review">
<div class="reviewer">
<img src="img/rcrumb.jpg" alt="R. Crumb" />
<div class="reviewer-name">R. Crumb</div>
</div>
<p>
We are living surrounded by illusion, by professionally
created fairy tales. We barely have contact with the real
world.
</p>
</li>
</ul>
</li>
</ul>
This markup is fairly semantic, and you can easily determine its intent. However, what if you could make it even more semantic and easier to understand? Consider this version:
<product-listing>
<product-desc name="log" img="img/log.jpg">
What rolls down stairs<br />
alone or in pairs,<br />
and over your neighbor's dog?<br />
What's great for a snack,<br />
And fits on your back?<br />
It's log, log, log<br />
<product-reviews>
<product-review>
<product-reviewer name="R. Crumb" img="img/rcrumb.jpg" />
We are living surrounded by illusion, by professionally
created fairy tales. We barely have contact with the real
world.
</product-review>
</product-reviews>
</product-desc>
</product-listing>
All the extraneous markup and properties have been eliminated, making it understandable at a glance. Until recently, this second version would not have been possible. What makes it possible today are custom elements.
Introducing Custom Elements
The intent of custom elements is to provide a common way to encapsulate functionality in a self-describing manner that can be reused. Additionally, this mechanism provides a consistent life cycle for elements, and an API for registering and extending elements.
In order to leverage custom elements, you must first determine if the browser rendering the page supports them:
// determine if browser supports custom elements
var supportsCustomElements = (function () {
return 'registerElement' in document;
})();
Registering Custom Elements
In order for a browser to recognize and support a custom element, it must first be registered with the browser. This is done via document.registerElement, which takes two arguments. The first argument is the name of the custom element being registered. The second argument is an options object, which allows you to define the prototype from which the element inherits. The default prototype is HTMLElement.prototype. This options argument also provides a mechanism for extending native elements, which will be covered in the next section of this chapter.
You register an element as follows:
// register an element
document.registerElement('product-listing');
// reigster an element and specify the prototype option
document.registerElement('product-listing', {
prototype: Object.create(HTMLElement.prototype)
});
NOTE
Registering a custom element adds the element to the browser’s registry. This registry is used for resolving elements to their definitions.
Once a custom element has been registered, it can be referenced and utilized like a native element:
// create an instance of the product-listing custom element
var productListingEl = document.createElement('product-listing');
// append it to the DOM
document.body.appendChild(productListingEl);
NAMING CONVENTIONS
You might have noticed that the name of our example custom element was all lowercase, and contained a dash (-). This is per the W3C specification. The rationale behind the naming convention is to prevent name collisions from occurring as new elements are added to the HTML specification and to allow browsers to easily distinguish custom elements from native elements.
Extending Elements
document.registerElement provides a method for extending both custom elements and native elements via the options argument.
Extending Custom Elements
Custom elements can be extended by adding the extends property to the document.registerElement options object in addition to specifying the prototype:
// create a custom element that represents a turtle
var Turtle = document.registerElement('regular-turtle');
// extend regular-turtle custom element
var NinjaTurtle = document.registerElement('ninja-turtle', {
prototype: Object.create(turtle.prototype, {
isMutant: { value: true },
isNinja: { value: true }
}),
extends: 'regular-turtle'
});
These extended custom elements and their bases can then be instantiated in three different ways:
// declared in markup
<regular-turtle></regular-turtle>
<ninja-turtle></ninja-turtle>
// document.createElement
var regularTurtle = document.createElement('regular-turtle');
var ninjaTurtle = document.createElement('ninja-turtle');
// new operator
var regularTurtle = new Turtle();
var ninjaTurtle = new NinjaTurtle();
Extending Native Elements
Native elements can be extended in the same fashion as custom elements:
var fancyTable = document.registerElement('fancy-table', {
prototype: Object.create(HTMLTableElement.prototype),
extends: 'table'
});
When a native element is extended by a custom element it is referred to as a type extension custom element. The difference between extending custom elements and native elements is that native elements have the added benefit of declaratively defining themselves as being enhanced versions of their native counterparts. For example, this HTML declaration is stating that <table> is a type of fancy-table:
<table is="fancy-table"></table>
Defining Properties and Methods
This is where the fun begins! Just as native elements have properties (e.g., parentNode and methods (e.g., cloneNode), custom elements can define their own. This allows a developer to create a public API for a custom element.
The process for adding properties to an element prototype is not any different than doing so for other object prototypes. We have already seen one example that uses Object.create to define all the properties at once, but the following code block illustrates some different methods:
// better like this?
Turtle.prototype.walk = function () {
// walk this way
};
// or better like this?
Object.defineProperty(NinjaTurtle.prototype, 'fight', {
value: function () {
// hiya
}
});
// better like this?
Turtle.prototype.legs = 4;
Resolving Custom Elements
If you have experimented with creating elements that are not defined by one of the HTML specifications—e.g., <jason-is-awesome>—then you know that a browser’s HTML parser will happily accept such an element and continue processing the document without hesitation. This is because the browser resolves the element to HTMLUnknownElement. All elements of unknown type and deprecated elements (remember <blink>?) inherit from this type. The same is true for custom elements that have yet to be registered. For instance, <jason-is-awesome> could be declared in the markup and then registered after the fact:
<body>
<jason-is-awesome>Let me count the ways.</jason-is-awesome>
<script type="text/javascript">
var jasonIsAwesomeEl = document.querySelector('jason-is-awesome');
// isUnknown is true
var isUnknown = jasonIsAwesomeEl.__proto__ === HTMLUnknownElement
.prototype;
document.registerElement('jason-is-awesome');
// isUnknown is false
isUnknown = jasonIsAwesomeEl.__proto__ === HTMLUnknownElement.prototype;
</script>
</body>
If a custom element’s prototype is no longer an HTMLUnknownElement.prototype once it has been registered, then what is it? In this case it is an HTMLElement.prototype, because that is the default prototype:
// continuing from the last example...
// isHTMLElement is true
var isHTMLElement = jasonIsAwesomeEl.__proto__ === HTMLElement.prototype;
Hooking Into Custom Element Life Cycles
One feature that JavaScript widgets lack is a clearly defined life cycle. Libraries such as YUI have done a great job of defining a life cycle for their base class widgets. However, there is not a standardized widget life cycle across libraries. In my opinion as an application and framework developer, this is one of the greatest benefits that custom elements offer.
I cannot count how many times I have encountered life cycle issues combining different widgets and widget libraries. You spend all your time either attempting to wrap them in a common life cycle or applying miscellaneous patches until your application becomes completely unmanageable. Custom elements are not the panacea for life cycle issues, but they at least provide a standardized set of hook points into an element’s life cycle. If they provided too much structure, then they would be extremely inflexible and likely make a fair amount of assumptions that turned out to be incorrect.
Fortunately, custom elements have hook points at specific times in their life cycles that help provide consistency in terms of when and how your code executes:
createdCallback
createdCallback is called when an instance of the element is created:
var CallbackExample = document.registerElement('callback-example');
CallbackExample.prototype.createdCallback = function () {
alert('and boom goes the dynomite');
}
// createdCallback is executed
document.createElement('callback-example');
attachedCallback
attachedCallback is called when an element is attached to the DOM:
var CallbackExample = document.registerElement('callback-example');
CallbackExample.prototype.attachedCallback = function () {
alert('Put yourself in my position.');
}
var cbExampleEl = document.createElement('callback-example');
// attachedCallback is executed
document.body.appendChild(cbExampleEl);
detachedCallback
detachedCallback is called when an element is removed from the DOM:
var CallbackExample = document.registerElement('callback-example');
CallbackExample.prototype.detachedCallback = function () {
alert('He died, too. So it goes.');
}
var cbExampleEl = document.createElement('callback-example');
document.body.appendChild(cbExampleEl);
// detachedCallback is executed
document.body.removeChild(cbExampleEl);
attributeChangedCallback
attributeChangedCallback is called when an element attribute has changed. The callback is passed three arguments—the attribute name, previous attribute value, and new attribute value:
<head>
<script type="text/javascript">
var CallbackExample = document.registerElement('callback-example');
CallbackExample.prototype
.attributeChangedCallback = function (attr, prevVal, newVal) {
alert('Change places!');
}
</script>
</head>
<body>
<callback-example change-places="fry" />
<script type="text/javascript">
var cbExampleEl = document.querySelector('callback-example');
// attributeChangedCallback is executed
cbExampleEl.setAttribute('change-places', 'bender');
</script>
</body>
Styling Custom Elements
Styling a custom element is no different from styling any other element:
/* type extension attribute selector */
[is="four-roses"] {
color: Brown;
opacity: 0.5;
}
/* custom element tag name */
sculpin-ipa {
color: GoldenRod
opacity: 0.4;
}
The only difference is that a new pseudoselector, :unresolved, now exists to help prevent FOUCs (flashes of unstyled content) for custom elements that have not been registered when the DOM is being parsed and rendered.
This occurs because the custom element will not be rendered until its definition is resolved by the browser. In some cases this may not matter because the element registration will occur before rendering, as with a nondeferred <script> in the <head>, but in other cases registration will occur after rendering, as in the case of a <script> at the bottom of the <body>. In the latter case the custom element would render unstyled. Then, once the element had been registered, the styling would be applied and a repaint or redraw would occur, resulting in a FOUC. This is a good example use case for :unresolved. One preventative measure would be to set the opacity of the unresolved elements to 0:
[is="four-roses"]:unresolved {
// nothing to see here
opacity: 0;
}
sculpin-ipa:unresolved {
// nothing to see here
opacity: 0;
}
Utilizing Templates and the Shadow DOM with Custom Elements
One of the benefits of web components is that they are just sets of APIs that support the concept of making the Web a better development platform, so you can decide when and how you utilize them. In the case of custom elements it makes sense to store the element contents inside of a template, so they remain inert until an instance of the element is created. The shadow DOM is also useful in the case that you do not want the innards of your custom element exposed, to prevent potential issues if developers intentionally or accidentally manipulate the element’s child nodes. The shadow DOM also provides the added benefit of style encapsulation so that none of the custom element styles bleed out and parent page styles do not bleed through.
The following example illustrates the use of a template and shadow DOM with a custom element:
<head>
<template class="draag-children">
<style>
p span {
text-decoration: underline;
}
</style>
<p>
<span>Draag child 1</span>: It doesn't move.
</p>
<p>
<span>Draag child 2</span>: What a shame we can't play with her any
more.
</p>
</template>
<script type="text/javascript">
document.registerElement('fantastic-planet', {
prototype: Object.create(HTMLElement.prototype, {
createdCallback: {
value: function () {
var template = document
.querySelector('.draag-children');
var content = document
.importNode(template.content, true);
// the context, this, is the custom element in
// element instance methods
this.createShadowRoot().appendChild(content);
}
}
})
});
</script>
</head>
<body>
<fantastic-planet />
</body>
Converting the Dialog Component to a Custom Element
This is where the power of web components really begins to shine. The ability to encapsulate (and obfuscate) a UI component, such as a dialog, into a declarative interface, a custom element, is an extremely concise and powerful pattern. It frees the custom element implementer, to a certain degree, from the concern of maintaining backward compatibility with an extensive public API. It also allows the implementer to easily interchange implementation details and technologies in an inert template. Lastly, it protects the implementer and the consumer from runtime conflicts because of the encapsulation the shadow DOM offers.
NOTE
The sections that follow display the applicable portions of the registerElement prototype with the call for context. In reality these would all be part of the same prototype of a single registerElement call.
Creating the Dialog Custom Element
The first step in converting the dialog component to a custom element is to define the template and register the element (the life cycle callback will be implemented in a later section):
<head>
<script type="text/javascript" src="/vendor/jquery.js"></script>
<template id="dialog">
<style>
// styling source
</style>
<script type="text/javascript">
// dialog component source
</script>
<div role="dialog" aria-labelledby="title" aria-describedby="content">
<h2 id="title"></h2>
<p id="content"></p>
</div>
</template>
<script type="text/javascript">
// callback will be implemented later
document.registerElement('dialog-component', {
prototype: Object.create(HTMLElement.prototype, {
createdCallback: { value: function () {} }
});
});
</script>
</head>
<body>
<dialog-component title="Heavy Traffic">
What makes you happy? What makes you happy? Where do you go? <br />
Where do you go? Where do you hide? Where do you hide? <br />
Who do you see? Who do you see? Who do you trust? <br />
Who do you trust?
</dialog-component>
</body>
Implementing the Dialog Custom Element’s Callbacks
The injection and activation of the template contents should be done upon element creation. This ensures that the content will only be activated once per element:
// private and public properties and methods referenced in the callback
// will be defined in the next section
document.registerElement('dialog-component', {
prototype: Object.create(HTMLElement.prototype, {
createdCallback: {
enumerable: false,
value: function () {
var template = document.querySelector('#dialog');
var content = document.importNode(template.content, true);
var draggable = this.getAttribute('draggable');
var resizable = this.getAttribute('resizable');
var options = {
draggable: draggable === undefined ? true : draggable,
resizable: resizable === undefined ? true : resizable,
hostQrySelector: this
};
this.root = this.createShadowRoot().appendChild(content);
// get a reference to the dialog container element in the root
this.dialogEl = this.root.querySelector('[role="dialog"]');
options.$el = this.dialogEl;
// align element to body since it is a fragment
options.alignToEl = document.body;
options.align = 'M';
// do not clone node
options.clone = false;
// get the content from the host node; projecting would retain
// host node styles and not allow for encapsulation of template
// styles
this.setTitle(this.host.getAttribute('title'));
this.setContent(this.innerHTML);
// create a dialog component instance
this.api = new Dialog(this.options);
}
}
});
});
Implementing the Dialog Custom Element API
The final step is creating an API. Some properties will be considered public and will be enumerable. Private properties will not be enumerable:
document.registerElement('dialog-component', {
prototype: Object.create(HTMLElement.prototype, {
// public
show: {
value: function () {
this.api.show();
},
enumerable: true
},
hide: {
value: function () {
this.api.hide();
},
enumerable: true
},
setTitle: {
value: function (title) {
this.dialogEl.querySelector('#title').innerHTML = title;
},
enumerable: true
},
setContent: {
value: function (content) {
this.dialogEl.querySelector('#content').innerHTML = content;
},
enumerable: true
},
// private
dialogEl: {},
root: {},
api: {}
});
});
Showing the Dialog
Now that an API for the dialog custom element has defined, a developer can show it and set values:
// get a reference to the custom element
var dialog = document.querySelector('dialog-component');
// use the custom element's public API to show it
dialog.show();
Summary
In this chapter, we first introduced custom elements and outlined the primary benefits: maintainability, readability, encapsulation, etc. Next, we examined registering elements, extending custom and native elements, and adding properties and methods to elements, affording developers a way to extend the Web. Then we reviewed how the browser resolves elements. After that we looked at the custom element life cycle and the callbacks available for different points in the life cycle. We discussed how these callbacks help to create a consistent life cycle, which makes development, debugging, and maintenance easier. Next, we saw that native element styling rules apply to custom elements as well and introduced a new pseudoselector that helps to prevent FOUCs, a common problem with modern web applications. Then we saw the benefits of using templates and the shadow DOM in conjunction with custom elements. Finally, we applied these learnings to create a custom element for the dialog component, adding all the aforementioned benefits to our component.