Source: delite/Widget.js

/** @module delite/Widget */
define([
	"dcl/dcl",
	"requirejs-dplugins/jquery!attributes/classes",	// addClass(), removeClass()
	"./features",
	"decor/Invalidating",
	"./CustomElement",
	"./register",
	"./features!bidi?./Bidi"
], function (dcl, $, has, Invalidating, CustomElement, register, Bidi) {
	// Used to generate unique id for each widget
	var cnt = 0;

	/**
	 * Base class for all widgets, i.e. custom elements that appear visually.
	 *
	 * Provides stubs for widget lifecycle methods for subclasses to extend, like `render()`,
	 * `postRender()`, and `destroy()`, and also public API methods like `observe()`.
	 * @mixin module:delite/Widget
	 * @augments module:delite/CustomElement
	 * @augments module:decor/Invalidating
	 * @mixes module:delite/Bidi
	 */
	var Widget = dcl([CustomElement, Invalidating], /** @lends module:delite/Widget# */ {

		/**
		 * Root CSS class of the widget (ex: "d-text-box")
		 * @member {string}
		 * @protected
		 */
		baseClass: "",

		/**
		 * This widget or a widget it contains has focus, or is "active" because
		 * it was recently clicked.
		 * @member {boolean}
		 * @default false
		 * @protected
		 */
		focused: false,

		/**
		 * Unique id for this widget, separate from id attribute (which may or may not be set).
		 * Useful when widget creates subnodes that need unique id's.
		 * @member {number}
		 * @constant
		 * @readonly
		 * @protected
		 */
		widgetId: 0,

		/**
		 * Controls the layout direction of the widget, for example whether the arrow of
		 * a Combobox appears to the right or the left of the input field.
		 *
		 * Values are "ltr" and "rtl", or "" which means that the value is inherited from the
		 * setting on the document root (either `<html>` or `<body>`).
		 *
		 * @member {string}
		 */
		dir: "",

		/**
		 * Actual direction of the widget, which can be set explicitly via `dir` property or inherited from the
		 * setting on the document root (either `<html>` or `<body>`).
		 * Value is either "ltr" or "rtl".
		 * @member {string}
		 * @readonly
		 */
		effectiveDir: "",

		//////////// INITIALIZATION METHODS ///////////////////////////////////////

		createdCallback: function () {
			this.widgetId = ++cnt;
		},

		// deliver() is called on widget creation, either from CustomElement#attachedCallback() (for the declarative
		// case) or the widget constructor (for the programmatic case).  At that point, Invalidating's observers haven't
		// been set up yet, so Stateful#deliver() won't call computeProperties() or refreshRendering().  But instead,
		// Widget calls Invalidating#initializeInvalidating(), which calls computeProperties(this, true) and
		// refreshRendering(this, true).
		deliver: dcl.after(function () {
			this.initializeInvalidating();
		}),

		computeProperties: function (props) {
			if ("dir" in props) {
				if ((/^(ltr|rtl)$/i).test(this._get("dir"))) {
					this.effectiveDir = this._get("dir").toLowerCase();
				} else {
					this.effectiveDir = this.getInheritedDir();
				}
			}
		},

		shouldInitializeRendering: function (oldVals) {
			// render the template on widget creation and also whenever app changes template prop
			return !this.rendered || "template" in oldVals;
		},

		initializeRendering: function () {
			this.rendered = false;
			this.preRender();
			this.render();
			this.postRender();
			this.rendered = true;
		},

		/**
		 * Get the direction setting for the page itself.
		 * @returns {string} "ltr" or "rtl"
		 * @protected
		 */
		getInheritedDir: function () {
			return (this.ownerDocument.body.dir || this.ownerDocument.documentElement.dir || "ltr").toLowerCase();
		},

		// Override Invalidating#refreshRendering() to execute the template's refreshRendering() code, etc.
		refreshRendering: function (oldVals, justRendered) {
			if (this._templateHandle && !justRendered) {
				// Refresh the template based on changed values, but not right after the template is rendered,
				// because that would be redundant.
				this._templateHandle.refresh(oldVals);
			}

			if ("baseClass" in oldVals) {
				$(this).removeClass(oldVals.baseClass).addClass(this.baseClass);
			}
			if ("effectiveDir" in oldVals) {
				$(this).toggleClass("d-rtl", this.effectiveDir === "rtl");
			}
			if ("dir" in oldVals) {
				this.style.direction = this._get("dir");
			}
		},

		attachedCallback: dcl.after(function () {
			// Call attachedCallback() on any widgets in the template
			if (this._templateHandle && !has("document-register-element")) {
				this._templateHandle.attach();
			}
		}),

		/**
		 * Processing before `render()`.
		 *
		 * This method is automatically chained, so subclasses generally do not need to use `dcl.superCall()`,
		 * `dcl.advise()`, etc.
		 * @protected
		 */
		preRender: function () {
		},

		/**
		 * Value returned by delite/handlebars! or compatible template engine.
		 * Specifies how to build the widget DOM initially and also how to update the DOM when
		 * widget properties change.
		 * @member {Function}
		 * @protected
		 */
		template: null,

		/**
		 * Construct the UI for this widget, filling in subnodes and/or text inside of this.
		 * Most widgets will leverage delite/handlebars! to set `template`, rather than defining this method.
		 * @protected
		 */
		render: function () {
			// Tear down old rendering (if there is one).
			if (this._templateHandle) {
				this._templateHandle.destroy();
				delete this._templateHandle;
			}

			// Render the widget.
			if (this.template) {
				this._templateHandle = this.template(this.ownerDocument, register);
				if (this.attached && !has("document-register-element")) {
					this._templateHandle.attach();
				}
			}
		},

		/**
		 * Helper method to set a class (or classes) on a given node, removing the class (or classes) set
		 * by the previous call to `setClassComponent()` *for the specified component and node*.  Used mainly by
		 * template.js to set classes without overwriting classes set by the user or other code (ex: CssState).
		 * @param {string} component - Specifies the category.
		 * @param {string} value - Class (or classes) to set.
		 * @param {HTMLElement} [node] - The node to set the property on; defaults to widget root node.
		 * @protected
		 */
		setClassComponent: function (component, value, node) {
			if (!node) { node = this; }
			var oldValProp = "_" + component + "Class";
			$(node).removeClass(node[oldValProp] || "").addClass(value);
			node[oldValProp] = value;
		},

		/**
		 * Helper method to set/remove an attribute based on the given value:
		 *
		 * - If value is undefined, the attribute is removed.  Useful for attributes like aria-valuenow.
		 * - If value is boolean, the attribute is set to "true" or "false".  Useful for attributes like aria-selected.
		 * - If value is a number, it's converted to a string.
		 *
		 * @param {Element} node - The node to set the property on.
		 * @param {string} name - Name of the property.
		 * @param {string} value - Value of the property.
		 * @protected
		 */
		setOrRemoveAttribute: function (node, name, value) {
			if (value === undefined) {
				node.removeAttribute(name);
			} else {
				node.setAttribute(name, "" + value);
			}
		},

		/**
		 * Processing after the DOM fragment is created.
		 *
		 * Called after the DOM fragment has been created, but not necessarily
		 * added to the document.  Do not include any operations which rely on
		 * node dimensions or placement.
		 *
		 * This method is automatically chained, so subclasses generally do not need to use `dcl.superCall()`,
		 * `dcl.advise()`, etc.
		 * @protected
		 */
		postRender: function () {
		},

		//////////// DESTROY FUNCTIONS ////////////////////////////////

		detachedCallback: function () {
			// Call detachedCallback() on any widgets in the template
			if (this._templateHandle && !has("document-register-element")) {
				this._templateHandle.detach();
			}
			if (this.bgIframe) {
				this.bgIframe.destroy();
				delete this.bgIframe;
			}
		},

		/**
		 * Returns the parent widget of this widget, or null if there is no parent widget.
		 */
		getParent: function () {
			return this.parentNode ? this.getEnclosingWidget(this.parentNode) : null;
		},

		// Override CustomElement#on() to handle on("focus", ...) when the widget conceptually gets focus.
		on: dcl.superCall(function (sup) {
			return function (type, func, node) {
				// Treat on(focus, "...") like on("focusin", ...) since
				// conceptually when widget.focusNode gets focus, it means the widget itself got focus.
				// Ideally we would set up a wrapper function to ignore focus changes between nodes inside the widget,
				// but evt.relatedTarget in null on FF.
				type = {focus: "focusin", blur: "focusout"}[type] || type;

				return sup.call(this, type, func, node);
			};
		}),

		/**
		 * Place this widget somewhere in the dom, and allow chaining.
		 *
		 * @param {string|Element|DocumentFragment} reference - Element, DocumentFragment,
		 * or id of Element to place this widget relative to.
		 * @param {string|number} [position] Numeric index or a string with the values:
		 * - number - place this widget as n'th child of `reference` node
		 * - "first" - place this widget as first child of `reference` node
		 * - "last" - place this widget as last child of `reference` node
		 * - "before" - place this widget as previous sibling of `reference` node
		 * - "after" - place this widget as next sibling of `reference` node
		 * - "replace" - replace specified reference node with this widget
		 * - "only" - replace all children of `reference` node with this widget
		 * @returns {module:delite/Widget} This widget, for chaining.
		 * @protected
		 * @example
		 * // create a Button with no srcNodeRef, and place it in the body:
		 * var button = new Button({ label:"click" }).placeAt(document.body);
		 * @example
		 * // place a new button as the first element of some div
		 * var button = new Button({ label:"click" }).placeAt("wrapper","first");
		 * @example
		 * // create a contentpane and add it to a TabContainer
		 * var tc = document.getElementById("myTabs");
		 * new ContentPane({ href:"foo.html", title:"Wow!" }).placeAt(tc)
		 */
		placeAt: function (reference, position) {
			if (typeof reference === "string") {
				reference = this.ownerDocument.getElementById(reference);
			}

			/* jshint maxcomplexity:14 */
			if (position === "replace") {
				reference.parentNode.replaceChild(this, reference);
			} else if (position === "only") {
				// SVG nodes, strict elements, and DocumentFragments don't support innerHTML
				for (var c; (c = reference.lastChild);) {
					reference.removeChild(c);
				}
				reference.appendChild(this);
			} else if (/^(before|after)$/.test(position)) {
				reference.parentNode.insertBefore(this, position === "before" ? reference : reference.nextSibling);
			} else {
				// Note: insertBefore(node, null) is equivalent to appendChild().  Second "null" arg needed only on IE.
				var parent = reference.containerNode || reference,
					children = parent.children || Array.prototype.filter.call(parent.childNodes, function (node) {
						return node.nodeType === 1;	// no .children[] on DocumentFragment :-(
					});
				parent.insertBefore(this, children[position === "first" ? 0 : position] || null);
			}

			if (!this.attached) {
				// run attach code for this widget and any descendant custom elements too
				this.attachedCallback(true);
			}

			return this;
		},


		/**
		 * Returns the widget whose DOM tree contains the specified DOMNode, or null if
		 * the node is not contained within the DOM tree of any widget
		 * @param {Element} node
		 */
		getEnclosingWidget: function (node) {
			do {
				if (node.nodeType === 1 && node.render) {
					return node;
				}
			} while ((node = node.parentNode));
			return null;
		}
	});

	if (has("bidi")) {
		Widget = dcl(Widget, Bidi);
	}

	// Setup automatic chaining for lifecycle methods, except for render().
	// destroy() is chained in Destroyable.js.
	dcl.chainAfter(Widget, "preRender");
	dcl.chainAfter(Widget, "postRender");

	return Widget;
});