Monday, September 1, 2014

Creating Web Components (custom elements) in AngularJS 1.x

I'm continuously looking for ways to write UI code (JavaScript, HTML, CSS) that will have as long of a shelf life as possible.  This is despite the fact that web technologies and fads change every few months or so.  My main motivation is that I am lazy, as any good web developer should be.  I don't want to have to keep rewriting the same logic every few months.  A by product of being lazy, is code that is more cost effective because it can live longer.

It's a reasonably safe bet that Web Components APIs will become part of the lingua franca of the web in the near future, and JavaScript frameworks will either support them or die.  Standardization is still a ways off for some of the APIs, and due to various technical reasons most of the current polyfills cannot truly emulate the APIs reliably and completely across modern browsers, most notably, Shadow DOM. 

However, after reading TJ VanToll's blog post, it got me thinking about what can be done now in production ready, cross-browser code to help future proof components and apps.  It seems that Custom Elements can be polyfilled across all the latest browsers with two caveats.  The :unresolved pseudo element can't be used yet, and some of the method and callback names (not logic) may still change before standardization.  Both of these issues are minor and definitely not show stoppers.

Now it's not really debatable that Shadow DOM is the magical browser capability that UI component developers crave for encapsulation, especially for component CSS.  But Custom Elements provide more value than might be apparent on the surface. After all we can make up element names now and the browser won't complain, right?

Besides allowing new elements to become official citizens of the DOM rather than illegal aliens, Custom Elements allow us component developers to define the APIs that allow components to be export, imported, shared, and re-used within frameworks and across frameworks.  This is because APIs are plain old HTML element APIs- attributes, properties, methods, and events.

Lately I've been playing with W3C Custom Element generation from within AngularJS 1.x code.  When AngularJS 2.0 arrives, it will be integrated with Web Components APIs.  It will be able to import and export Custom Elements, and will have specialized directives including template and component directives to do this rather than the generic directive definitions we have now in 1.x.

Below is a GitHub Gist I posted of an AngularJS service that acts as a generic Custom Element factory, and what I would define as an AngularJS 1.x component directive that accesses it.  It works in Chrome and Opera without a polyfill, and the rest with a custom-element-only polyfill like https://github.com/WebReflection/document-register-element


// This depends on a custom element polyfill for browsers
// other than chrome and opera - 9/1/14
angular.module('CustomElemFactory', [])
.service('customElem', [
'$window',
function($window){
// hash of registered element types
// that Angular knows about
// note that this would NOT include CEs registered from
// elsewhere
var customElements = {};
var noop = function(){};
// generic custom element registration function
var registerElem = function(name, protoName, callbacks, members){
// if the element is already registered, exit
// since the DOM will throw errors otherwise
if(customElements[name]) return customElements[name];
// special properties and methods all elems of this type get
members = members || {};
// must be an existing DOM element interface
protoName = protoName || $window.HTMLElement;
// create and populate what will become the custom element
// prototype
var CustomElemProto = Object.create(protoName.prototype, members);
// these are the "current as of 9/14" names of the custom
// element lifecycle callbacks
// createdCallback executes upon instantiation
CustomElemProto.createdCallback = callbacks.createdCallback || noop;
// attachedCallback is queued when the element is added to the DOM
CustomElemProto.attachedCallback = callbacks.attachedCallback || noop;
// detachedCallback is queued when the element is removed from the DOM
CustomElemProto.detachedCallback = callbacks.detachedCallback || noop;
// attributeChangedCallback is queued when something adds/alters an attr
CustomElemProto.attributeChangedCallback = callbacks.attributeChangedCallback || noop;
// call the new DOM API method to make this element
// a first class citizen of the DOM
var CustomElem = $window.document.registerElement( name, {
prototype: CustomElemProto
});
// add the newly registered element to the tracking hash
customElements[name] = CustomElem;
return CustomElem;
}
return registerElem;
}]);
angular.module('uiComponents.smartButton', ['CustomElemFactory'])
// An example of how an AngularJS 1.x "component" directive
// might be defined including the Angular independent
// custom element config that is exported to the DOM
.directive('smartButton', [
'customElem', function(customElem){
return {
template: tpl,
transclude: true,
// create an isolate (component) scope
scope: {},
// must restrict directive matching to custom element name
restrict: 'E',
// must not overwrite the custom element markup
replace: false,
// housing the custom element config code in a directive
// controller may or may not be the ideal place to store
// the custom element config code depending on situation
controller: function($scope, $element, $attrs, $window){
// custom elements must include hyphens
// in the real world we'd use a namespace of 3 letters
var elemName = 'smart-button';
// this will be a descendent of <button>
var parent = $window.HTMLButtonElement;
// define the element's lifecycle logic
var callbacks = {
createdCallback: function(){
// this is where most of the instance
// config would occur
//alert('created')
},
attachedCallback: function(){
//alert('attached to DOM')
},
detachedCallback: function(){
//test in dev tools by deleting a node
//alert('detached from DOM')
},
attributeChangedCallback: function(attr, oldVal, newVal){
//console.warn(attr, oldVal, newVal)
}
};
// add any special class props and methods
var members = {
testMember: {
get: function() { return "foo"; },
enumerable: true,
configurable: true
}
};
// invoke the Custom Element factory service
var SmartButton = customElem(elemName, parent, callbacks, members);
...
// rest of directive definition


This is the first version, and I have yet to play with it using existing AngularJS components, but I plan to do so extensively.  One of the big questions about creating custom element from inside a framework is where the framework should end and the DOM APIs begin.  In other words, should we now be thinking about placing most or all of the component's logic in it's DOM properties and methods, or will much of the logic still remain in the framework part of the "framework enhanced" component (i.e. $scope)?  Storing logic and data in the DOM makes custom element components independent of any framework, and therefore portable and sharable.  But then we have everyone accessing the global context!