/** @module delite/CustomElement */
define([
"dcl/advise",
"dcl/dcl",
"decor/Observable",
"decor/Destroyable",
"decor/Stateful",
"requirejs-dplugins/has",
"./on",
"./register"
], function (advise, dcl, Observable, Destroyable, Stateful, has, on, register) {
/**
* Dispatched after the CustomElement has been attached.
* This is useful to be notified when an HTMLElement has been upgraded to a
* CustomElement and attached to the DOM, in particular on browsers supporting native Custom Element.
* @example
* element.addEventListener("customelement-attached", function (evt) {
* console.log("custom element: "+evt.target.id+" has been attached");
* });
* @event module:delite/CustomElement#customelement-attached
*/
// Test if custom setters work for native properties like dir, or if they are ignored.
// They don't work on some versions of webkit (Chrome, Safari 7, iOS 7), but do work on Safari 8 and iOS 8.
// If needed, this test could probably be reduced to just use Object.defineProperty() and dcl(),
// skipping use of register().
has.add("setter-on-native-prop", function () {
var works = false,
Mixin = dcl(Stateful, { // mixin to workaround https://github.com/uhop/dcl/issues/9
getProps: function () { return {dir: true}; },
dir: "",
_setDirAttr: function () { works = true; }
}),
TestWidget = register("test-setter-on-native-prop", [HTMLElement, Mixin], {}),
tw = new TestWidget();
tw.dir = "rtl";
return works;
});
/**
* Get a property from a dot-separated string, such as "A.B.C".
*/
function getObject(name) {
try {
return name.split(".").reduce(function (context, part) {
return context[part];
}, this); // "this" is the global object (i.e. window on browsers)
} catch (e) {
// Return undefined to indicate that object doesn't exist.
}
}
// Properties not to monitor for changes.
var REGEXP_IGNORE_PROPS = /^constructor$|^_set$|^_get$|^deliver$|^discardChanges$|^_(.+)Attr$/;
/**
* Base class for all custom elements.
*
* Use this class rather that delite/Widget for non-visual custom elements.
* Custom elements can provide custom setters/getters for properties, which are called automatically
* when the value is set. For an attribute XXX, define methods _setXXXAttr() and/or _getXXXAttr().
*
* @mixin module:delite/CustomElement
* @augments module:decor/Stateful
* @augments module:decor/Destroyable
*/
var CustomElement = dcl([Stateful, Destroyable], /** @lends module:delite/CustomElement# */{
introspect: function () {
if (!has("setter-on-native-prop")) {
// Generate map from native attributes of HTMLElement to custom setters for those attributes.
// Necessary because webkit masks all custom setters for native properties on the prototype.
// For details see:
// https://bugs.webkit.org/show_bug.cgi?id=36423
// https://bugs.webkit.org/show_bug.cgi?id=49739
// https://bugs.webkit.org/show_bug.cgi?id=75297
var proto = this,
nativeProps = document.createElement(this._extends || "div"),
setterMap = this._nativePropSetterMap = {};
this._nativeAttrs = [];
do {
Object.keys(proto).forEach(function (prop) {
var lcProp = prop.toLowerCase();
if (prop in nativeProps && !setterMap[lcProp]) {
var desc = Object.getOwnPropertyDescriptor(proto, prop);
if (desc && desc.set) {
this._nativeAttrs.push(lcProp);
setterMap[lcProp] = desc.set;
}
}
}, this);
proto = Object.getPrototypeOf(proto);
} while (proto && proto !== this._baseElement.prototype);
}
},
getProps: function () {
// Override _Stateful.getProps() to ignore properties from the HTML*Element superclasses, like "style".
// You would need to explicitly declare style: "" in your widget to get it here.
//
// Also sets up this._propCaseMap, a mapping from lowercase property name to actual name,
// ex: iconclass --> iconClass, which does include the methods, but again doesn't
// include props like "style" that are merely inherited from HTMLElement.
var hash = {}, proto = this,
pcm = this._propCaseMap = {};
do {
Object.keys(proto).forEach(function (prop) {
if (!REGEXP_IGNORE_PROPS.test(prop)) {
hash[prop] = true;
pcm[prop.toLowerCase()] = prop;
}
});
proto = Object.getPrototypeOf(proto);
} while (proto && proto !== this._baseElement.prototype);
return hash;
},
/**
* This method will detect and process any properties that the application has set, but the custom setter
* didn't run because `has("setter-on-native-prop") === false`.
* Used during initialization and also by `deliver()`.
* @private
*/
_processNativeProps: function () {
if (!has("setter-on-native-prop")) {
this._nativeAttrs.forEach(function (attrName) {
if (this.hasAttribute(attrName)) { // value was specified
var value = this.getAttribute(attrName);
this.removeAttribute(attrName);
if (value !== null) {
this._nativePropSetterMap[attrName].call(this, value); // call custom setter
}
}
}, this);
}
},
/**
* Set to true when `createdCallback()` has completed.
* @member {boolean}
* @protected
*/
created: false,
/**
* Called when the custom element is created, or when `register.parse()` parses a custom tag.
*
* This method is automatically chained, so subclasses generally do not need to use `dcl.superCall()`,
* `dcl.advise()`, etc.
* @method
* @protected
*/
createdCallback: dcl.advise({
before: function () {
// Mark this object as observable with Object.observe() shim
if (!this._observable) {
Observable.call(this);
}
// Get parameters that were specified declaratively on the widget DOMNode.
this._parsedAttributes = this._mapAttributes();
},
after: function () {
this.created = true;
// Now that creation has finished, apply parameters that were specified declaratively.
// This is consistent with the timing that parameters are applied for programmatic creation.
this._parsedAttributes.forEach(function (pa) {
if (pa.event) {
this.on(pa.event, pa.callback);
} else {
this[pa.prop] = pa.value;
}
}, this);
if (!has("setter-on-native-prop")) {
// Call custom setters for initial values of attributes with shadow properties (dir, tabIndex, etc)
this._processNativeProps();
// Begin watching for changes to those DOM attributes.
// Note that (at least on Chrome) I could use attributeChangedCallback() instead, which is
// synchronous, so Widget#deliver() will work as expected, but OTOH gets lots of notifications
// that I don't care about.
// If Polymer is loaded, use MutationObserver rather than WebKitMutationObserver
// to avoid error about "referencing a Node in a context where it does not exist".
/* global WebKitMutationObserver */
var MO = window.MutationObserver || WebKitMutationObserver; // for jshint
var observer = new MO(function (records) {
records.forEach(function (mr) {
var attrName = mr.attributeName,
setter = this._nativePropSetterMap[attrName],
newValue = this.getAttribute(attrName);
if (newValue !== null) {
this.removeAttribute(attrName);
setter.call(this, newValue);
}
}, this);
}.bind(this));
observer.observe(this, {
subtree: false,
attributeFilter: this._nativeAttrs,
attributes: true
});
}
}
}),
/**
* Set to true when `attachedCallback()` has completed, and false when `detachedCallback()` called.
* @member {boolean}
* @protected
*/
attached: false,
/**
* Called automatically when the element is added to the document, after `createdCallback()` completes.
* This method is automatically chained, so subclasses generally do not need to use `dcl.superCall()`,
* `dcl.advise()`, etc.
* @method
* @fires module:delite/CustomElement#customelement-attached
*/
attachedCallback: dcl.advise({
before: function () {
// Call computeProperties() and refreshRendering() for declaratively set properties.
// Do this in attachedCallback() rather than createdCallback() to avoid calling refreshRendering() etc.
// prematurely in the programmatic case (i.e. calling it before user parameters have been applied).
this.deliver();
},
after: function () {
this.attached = true;
this.emit("customelement-attached", {
bubbles: false,
cancelable: false
});
}
}),
/**
* Called when the element is removed the document.
* This method is automatically chained, so subclasses generally do not need to use `dcl.superCall()`,
* `dcl.advise()`, etc.
*/
detachedCallback: function () {
this.attached = false;
},
/**
* Returns value for widget property based on attribute value in markup.
* @param {string} name - Name of widget property.
* @param {string} value - Value of attribute in markup.
* @private
*/
_parsePrototypeAttr: function (name, value) {
// inner function useful to reduce cyclomatic complexity when using jshint
function stringToObject(value) {
var obj;
try {
// TODO: remove this code if it isn't being used, so we don't scare people that are afraid of eval.
/* jshint evil:true */
// This will only be executed when complex parameters are used in markup
// <my-tag constraints="max: 3, min: 2"></my-tag>
// This can be avoided by using such complex parameters only programmatically or by not using
// them at all.
// This is harmless if you make sure the JavaScript code that is passed to the attribute
// is harmless.
obj = eval("(" + (value[0] === "{" ? "" : "{") + value + (value[0] === "{" ? "" : "}") + ")");
}
catch (e) {
throw new SyntaxError("Error in attribute conversion to object: " + e.message +
"\nAttribute Value: '" + value + "'");
}
return obj;
}
switch (typeof this[name]) {
case "string":
return value;
case "number":
return value - 0;
case "boolean":
return value !== "false";
case "object":
// Try to interpret value as global variable, ex: store="myStore", array of strings
// ex: "1, 2, 3", or expression, ex: constraints="min: 10, max: 100"
return getObject(value) ||
(this[name] instanceof Array ? (value ? value.split(/\s+/) : []) : stringToObject(value));
case "function":
return this.parseFunctionAttribute(value, []);
}
},
/**
* Helper to parse function attribute in markup. Unlike `_parsePrototypeAttr()`, does not require a
* corresponding widget property. Functions can be specified as global variables or as inline javascript:
*
* ```html
* <my-widget funcAttr="globalFunction" on-click="console.log(event.pageX);">
* ```
*
* @param {string} value - Value of the attribute.
* @param {string[]} params - When generating a function from inline javascript, give it these parameter names.
* @protected
*/
parseFunctionAttribute: function (value, params) {
/* jshint evil:true */
// new Function() will only be executed if you have properties that are of function type in your widget
// and that you use them in your tag attributes as follows:
// <my-tag whatever="console.log(param)"></my-tag>
// This can be avoided by setting the function programmatically or by not setting it at all.
// This is harmless if you make sure the JavaScript code that is passed to the attribute is harmless.
// Use Function.bind to get a partial on Function constructor (trick to call it with an array
// of args instead list of args).
return getObject(value) ||
new (Function.bind.apply(Function, [undefined].concat(params).concat([value])))();
},
/**
* Helper for parsing declarative widgets. Interpret a given attribute specified in markup, returning either:
*
* - `undefined`: ignore
* - `{prop: prop, value: value}`: set `this[prop] = value`
* - `{event: event, callback: callback}`: call `this.on(event, callback)`
*
* @param {string} name - Attribute name.
* @param {string} value - Attribute value.
* @protected
*/
parseAttribute: function (name, value) {
var pcm = this._propCaseMap;
if (name in pcm) {
name = pcm[name]; // convert to correct case for widget
return {
prop: name,
value: this._parsePrototypeAttr(name, value)
};
} else if (/^on-/.test(name)) {
return {
event: name.substring(3),
callback: this.parseFunctionAttribute(value, ["event"])
};
}
},
/**
* Parse declaratively specified attributes for widget properties and connects.
* @returns {Array} Info about the attributes and their values as returned by `parseAttribute()`.
* @private
*/
_mapAttributes: function () {
var attr,
idx = 0,
parsedAttrs = [],
attrsToRemove = [];
while ((attr = this.attributes[idx++])) {
var name = attr.name.toLowerCase(); // note: will be lower case already except for IE9
var parsedAttr = this.parseAttribute(name, attr.value);
if (parsedAttr) {
parsedAttrs.push(parsedAttr);
attrsToRemove.push(attr.name);
}
}
// Remove attributes that were processed, but do it in a separate loop so we don't modify this.attributes
// while we are looping through it. (See CustomElement-attr.html test failure on IE10.)
attrsToRemove.forEach(this.removeAttribute, this);
return parsedAttrs;
},
/**
* Release resources used by this custom element and its descendants.
* After calling this method, the element can no longer be used,
* and should be removed from the document.
*/
destroy: function () {
// Destroy descendants
this.findCustomElements().forEach(function (w) {
if (w.destroy) {
w.destroy();
}
});
if (this.parentNode) {
this.parentNode.removeChild(this);
this.detachedCallback();
}
},
/**
* Emits a synthetic event of specified type, based on eventObj.
* @param {string} type - Name of event.
* @param {Object} [eventObj] - Properties to mix in to emitted event. Can also contain
* `bubbles` and `cancelable` properties to control how the event is emitted.
* @returns {boolean} True if the event was *not* canceled, false if it was canceled.
* @example
* myWidget.emit("query-success", {});
* @protected
*/
emit: function (type, eventObj) {
eventObj = eventObj || {};
var bubbles = "bubbles" in eventObj ? eventObj.bubbles : true;
var cancelable = "cancelable" in eventObj ? eventObj.cancelable : true;
// Note: can't use jQuery.trigger() because it doesn't work with addEventListener(),
// see http://bugs.jquery.com/ticket/11047
var nativeEvent = this.ownerDocument.createEvent("HTMLEvents");
nativeEvent.initEvent(type, bubbles, cancelable);
for (var i in eventObj) {
if (!(i in nativeEvent)) {
nativeEvent[i] = eventObj[i];
}
}
return this.dispatchEvent(nativeEvent);
},
/**
* Call specified function when event occurs.
*
* Note that the function is not run in any particular scope, so if (for example) you want it to run
* in the element's scope you must do `myCustomElement.on("click", myCustomElement.func.bind(myCustomElement))`.
*
* Note that `delite/Widget` overrides `on()` so that `on("focus", ...)` and `on("blur", ...) will trigger the
* listener when focus moves into or out of the widget, rather than just when the widget's root node is
* focused/blurred. In other words, the listener is called when the widget is conceptually focused or blurred.
*
* @param {string} type - Name of event (ex: "click").
* @param {Function} func - Callback function.
* @param {Element} [node] - Element to attach handler to, defaults to `this`.
* @returns {Object} Handle with `remove()` method to cancel the event.
*/
on: function (type, func, node) {
return on(node || this, type, func);
},
// Override Stateful#getPropsToObserve() because the way to get the list of properties to watch is different
// than for a plain Stateful. Especially since IE doesn't support prototype swizzling.
getPropsToObserve: function () {
return this._ctor._propsToObserve;
},
// Before deliver() runs, process any native properties (tabIndex, dir) etc. that may have been
// set without the custom setter getting called.
deliver: dcl.before(function () {
this._processNativeProps();
}),
/**
* Search subtree under root returning custom elements found.
* @param {Element} [root] - Node to search under.
*/
findCustomElements: function (root) {
var outAry = [];
function getChildrenHelper(root) {
for (var node = root.firstChild; node; node = node.nextSibling) {
if (node.nodeType === 1 && node.createdCallback) {
outAry.push(node);
} else {
getChildrenHelper(node);
}
}
}
getChildrenHelper(root || this);
return outAry;
}
});
// Setup automatic chaining for lifecycle methods.
// destroy() is chained in Destroyable.js.
dcl.chainAfter(CustomElement, "createdCallback");
dcl.chainAfter(CustomElement, "attachedCallback");
dcl.chainBefore(CustomElement, "detachedCallback");
return CustomElement;
});