Creating Custom Elements - Building HTML5 Web Components - Developing Web Components (2015)

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.