Source: deliteful/ToasterMessage.js

/** @module deliteful/ToasterMessage */
define([
	"delite/Widget",
	"delite/register",
	"requirejs-dplugins/Promise!",
	"requirejs-dplugins/jquery!attributes/classes",
	"dpointer/events",
	"./features",
	"delite/handlebars!./Toaster/ToasterMessage.html"
], function (Widget, register, Promise, $, pointer, has, template) {

	// TODO: this could be abstracted in a separate class, so that it can be used by other widgets
	// such as the toggle/switch.
	// TODO: this part might need some more refactoring, the updateElement/resetElement methods do not belong in
	// the SwipeStateMachine constructor.
	var SwipeToDismiss = function (element, callback) {
		var SwipeStateMachine = function (element) {

			var MIN_HORIZONTAL = 100,       // unit = px
				MIN_SPEED = 0.85;           // unit = px/ms

			function getEventLocation(event) {
				return { x: event.clientX, y: event.clientY };
			}

			function opacity(distance, elementWidth) {
				return distance < elementWidth ?
					1 - distance * 1.0 / elementWidth : 0;
			}

			function updateElement(element, gesture) {
				var d = gesture.distance();
				if (d >= 0) {
					element.style.left = d + "px";                           // translate element
					element.style.opacity = opacity(d, element.clientWidth); // change opacity
				}
			}

			function resetElement(element) {
				element.style.left = "";
				element.style.opacity = ""; // TODO we should make sure this doesn't interfere with user css
			}

			function setUnderGestureCtrl(element) {
				if (element.isExpirable()) {
					element._timer.pause();
				}
			}

			function releaseFromGestureCtrl(element) {
				if (element.isExpirable()) {
					element._timer.resume();
				}
			}

			this.gesture = {
				trajectory: null,
				startTime: null,
				endTime: null,

				first: function () {
					return this.trajectory[0];
				},
				last: function () {
					var last = this.trajectory.length - 1;
					return this.trajectory[last];
				},
				secondLast: function () {
					var last = this.trajectory.length - 2;
					return this.trajectory[last];
				},

				// gesture parameters
				distance: function () {
					return this.last().x - this.first().x;
				},
				direction: function () {
					return this.last().x - this.secondLast().x > 0 ?
						"right" : "left";
				},
				duration: function () {
					if (this.startTime && this.endTime) {
						return this.endTime - this.startTime;
					}
				},
				speed: function () {
					return this.distance() / this.duration();
				},

				// gesture validators
				isLongEnough: function () {
					return this.distance() > MIN_HORIZONTAL;
				},
				isFastEnough: function () {
					return this.speed() > MIN_SPEED;
				},
				isDirectedToRight: function () {
					return this.direction() === "right";
				}
			};

			this.hasStarted = false;
			this.hasEnded = false;

			this.startCapture = function (event) {
				this.hasStarted = true;
				this.hasEnded = false;

				var loc = getEventLocation(event);
				this.gesture.trajectory = [loc];
				this.gesture.startTime = new Date().getTime();
				this.gesture.endTime = null;

				setUnderGestureCtrl(element);
			};

			this.keepCapturing = function (event) {
				var loc = getEventLocation(event);
				this.gesture.trajectory.push(loc);
				updateElement(element, this.gesture);
			};

			this.endCapture = function () {
				this.hasStarted = false;
				this.hasEnded = true;

				this.gesture.endTime = new Date().getTime();
				if (this.gesture.isFastEnough() ||
					this.gesture.isLongEnough() && this.gesture.isDirectedToRight()) {
					callback();
				} else {
					resetElement(element);
					releaseFromGestureCtrl(element);
				}

			};
		};

		var state = new SwipeStateMachine(element);

		function _pointerDownHandler(event) {
			state.startCapture(event);
			pointer.setPointerCapture(element, event.pointerId);
		}

		function _pointerMoveHandler(event) {
			if (state.hasStarted && !state.hasEnded) {
				state.keepCapturing(event);
			}
		}

		function _pointerUpHandler(event) {
			if (state.hasStarted) {
				state.endCapture(event);
			}
		}

		this.isEnabled = false;
		var signalDown, signalMove, signalUp;
		this.enable = function () {
			this.isEnabled = true;
			signalDown = element.on("pointerdown", _pointerDownHandler);
			signalMove = element.on("pointermove", _pointerMoveHandler);
			signalUp = element.on("pointerup", _pointerUpHandler);
		};
		this.disable = function () {
			if (this.isEnabled) {
				this.isEnabled = false;
				signalDown.remove();
				signalMove.remove();
				signalUp.remove();
			}
		};

	};

	// TODO: this could be abstracted in a separate class, so that it can be used by other widgets
	var Timer = function (duration) {
		var promise = new Promise(function (resolve, reject) {
			var timer = null, _startDate = null, _remaining = null,
			_fulfilled = false;		// NOTE: necessary because _remaining == 0 doesn't 
									// necessarily mean the timeout callback was fired immediately

			function _start(duration) {
				_startDate = Date.now();
				timer = setTimeout(function () {
					_fulfilled = true;
					resolve();
				}, duration);
			}

			function computeRemaining() {
				var rt = duration - Date.now() + _startDate;
				return rt >= 0 ? rt : 0;
			}

			this.start = function () {
				_start(duration);
				return promise;
			};

			this.pause = function () {
				if (timer !== null) {
					clearTimeout(timer);
					timer = null;
					_remaining = computeRemaining();
				} else {
					_remaining = 0;
				}
			};

			this.resume = function () {
				_start(_remaining);
				return promise;
			};

			this.destroy = function () {
				if (! _fulfilled) {
					reject();
				}
			};
		}.bind(this));
	};

	var PauseTimerOnHover = function (element) {

		var hovering = false;
		function _pauseTimer() {
			if (!hovering) {
				hovering = true;
				element._timer.pause();
			}
		}

		function _resumeTimer() {
			if (hovering) {
				hovering = false;
				element._timer.resume();
			}
		}

		function _pointerUpHandler(e) {
			if (e.pointerType === "touch") {
				_resumeTimer();
			}
		}

		this.isEnabled = false;
		var eventHandlers;
		this.enable = function () {
			this.isEnabled = true;
			eventHandlers = [element.on("pointerover", _pauseTimer),
				element.on("pointerleave", _resumeTimer),
				element.on("pointercancel", _resumeTimer),
				element.on("pointerup", _pointerUpHandler)];
		};

		this.disable = function () {
			if (this.isEnabled) {
				this.isEnabled = false;
				eventHandlers.forEach(function (eventHandler) {
					eventHandler.remove();
				});
				eventHandlers = null;
			}
		};
	};

	var D_INVISIBLE = "d-invisible",
		D_HIDDEN = "d-hidden",
		D_SWIPEOUT = "d-toaster-swipeout";

	/* message types */
	var messageTypes = {
		info: "info", // default
		success: "success",
		warning: "warning",
		error: "error"
	};
	var defaultType = messageTypes.info;

	function normalizeType(type) {
		return messageTypes[type] || defaultType;
	}

	function messageTypeClass(type) {
		return "d-toaster-type-" + type;
	}

	/* message duration */
	var defaultDuration = 2000;

	function normalizeDuration(duration) {
		return typeof duration === "number" && !isNaN(duration) ? duration : defaultDuration;
	}

	var transitionendEvents = {
		"transition": "transitionend", // >= IE10, FF
		"-webkit-transition": "webkitTransitionEnd"  // > chrome 1.0 , > Android 2.1 , > Safari 3.2
	};

	function whichEvent(events) {
		// NOTE: returns null if event is not supported
		var fakeElement = document.createElement("fakeelement");
		for (var event in events) {
			if (fakeElement.style[event] !== undefined) {
				return events[event];
			}
		}
		return null;
	}

	var animationendEvent = has("animationEndEvent"),
		transitionendEvent = whichEvent(transitionendEvents);

	function listenAnimationEvents(element, callback) {
		var events = [animationendEvent, transitionendEvent];
		events.forEach(function (event) {
			if (event) { // if event is supported
				var tmp = {};
				var listener = (function (el, ev, d) {
					return function () {
						callback(el, ev);
						d.handler.remove();
					};
				})(element, event, tmp);
				tmp.handler = element.on(event, listener);
			} else {
				callback(element, event);
			}
		});
	}

	/**
	 * ToasterMessage widget.
	 *
	 * This class is not meant to be used alone. It needs to be combined
	 * with deliteful/Toaster.
	 *
	 * @class module:deliteful/ToasterMessage
	 * @augments module:delite/Widget
	 * @example
	 *   var toaster = new Toaster();
	 *   var message = new ToasterMessage({message: "hello, world!"});
	 *   toaster.postMessage(message);
	 */
	return register("d-toaster-message", [HTMLElement, Widget], /** @lends module:deliteful/ToasterMessage */ {
		baseClass: "d-toaster-message",

		/**
		 * Content of the message.
		 *
		 * @member {string}
		 * @default null
		 */
		message: null,

		/**
		 * Type of the message.
		 * `type` is one of `["info", "success", "warning", "error"]`.
		 *
		 * @member {string}
		 * @default "info"
		 */
		type: defaultType,
		_setTypeAttr: function (value) {
			var type = normalizeType(value);
			this.messageTypeClass = messageTypeClass(type);
			this._set("type", type);
		},

		/**
		 * Duration which specifies how long the message is shown.
		 * If set to a strictly negative value, the message is shown persistently until it is
		 * dismissed.
		 *
		 * @member {number}
		 * @default 2000
		 */
		duration: defaultDuration,
		_setDurationAttr: function (value) {
			var duration = normalizeDuration(value);
			this._set("duration", duration);
		},

		_dismissButton: null,
		/**
		 * Indicates whether the message can be dismissed. Is one of ["on", "off", "auto"].
		 * If "auto", `isDismissible` adopts a default behaviour that depends
		 * on `duration`.
		 *
		 * @member {string}
		 * @default null
		 */
		dismissible: "auto",

		/**
		 * A string containing the class that matches the type of the message.
		 * @member {string}
		 * @default null
		 */
		messageTypeClass: messageTypeClass(defaultType),

		/**
		 * Dismisses the message, optionally with an animation.
		 *
		 * @param {string} [animation] an animation class that will be added to dismiss the message
		 */
		dismiss: function (animation) { // called when dismiss button is clicked or swipe detected
			var parent = this.getParent();
			this._hideInDom(parent, !!animation, animation);
		},

		// states
		_isInserted: false,
		_hasExpired: false,
		_toBeRemoved: false,
		_isRemoved: false,

		/**
		 * Returns whether the message can expire. The default implementation
		 * returns `true` if `duration` is larger or equal than `0`.
		 * @returns {boolean}
		 */
		isExpirable: function () {
			return this.duration >= 0;
		},

		/**
		 * Returns whether the message can be dismissed.
		 *
		 * If the `dismissible` property was set to `"on"` (respectively `"off"`),
		 * this method returns `true` (respectively `false`).
		 * If `dismissible` was set to `"auto"`, this method returns `false` if
		 * the message is expirable, `true` otherwise.
		 *
		 * @returns {boolean}
		 */
		isDismissible: function () {
			return this.dismissible === "auto" ?
				!this.isExpirable() : this.dismissible === "on";
		},

		_timer: null,

		_insertInDom: function (toaster, animated) {
			var wrapper = toaster._wrapper;
			this._isInserted = true;
			if (animated) {
				$(this).addClass(toaster.animationInitialClass);
			}
			if (toaster.invertOrder && wrapper.hasChildNodes()) {
				// NOTE: invertOrder has an effect only when wrapper has children
				var first = wrapper.childNodes[0];
				wrapper.insertBefore(this, first);
			} else {
				wrapper.appendChild(this);
			}
			this.attachedCallback();

			// starting timer
			if (this.isExpirable()) {
				this._timer = new Timer(this.duration);
				this.own(this._timer);
				this._timer.start().then(function () {
					this._hasExpired = true;
					toaster.notifyCurrentValue("messages");
				}.bind(this));
			}

			// toggling dismiss button visibility
			$(this._dismissButton).toggleClass(D_HIDDEN, !this.isDismissible());
		},
		_showInDom: function (toaster, animated) {
			if (animated) {
				this.defer(function () {
					// NOTE: this timeout is here only to prevent the browser from optimizing
					// (which makes the animation invisible)
					$(this).removeClass(toaster.animationInitialClass);
					$(this).addClass(toaster.animationEnterClass);
					listenAnimationEvents(this, function (element) {
						$(element).removeClass(toaster.animationEnterClass);

						// NOTE: the swipe dismissing is made possible only once the entering animation is done
						// this is done to avoid the CSS of the animation to interfere with the swipe

						// setting up the swipe-to-dismiss
						if (element.isDismissible()) {
							element.swipeToDismiss.enable();
						}
					});
				}, 1);
			} else {
				// setting up the swipe-to-dismiss
				if (this.isDismissible()) {
					this.swipeToDismiss.enable();
				}
			}
			// setting up pause-timer-on-hover
			if (this.isExpirable()) {
				this.pauseTimerOnHover.enable();
			}
		},
		_hideInDom: function (toaster, animated, customAnimation) {
			var animation = customAnimation || toaster.animationQuitClass;
			if (toaster !== null) {
				// NOTE: the swipe dismissing is made possible only till the leaving animation starts
				// this is done to avoid the CSS of the animation to interfere with the swipe
				this.swipeToDismiss.disable();

				if (animated) {
					$(this).addClass(animation);
					listenAnimationEvents(this, function (element) {
						element._toBeRemoved = true;
						toaster.notifyCurrentValue("messages");
					});
				} else {
					$(this).addClass(D_INVISIBLE);
					this._toBeRemoved = true;
					toaster.notifyCurrentValue("messages"); // TODO: could be better handled with an event
				}
			}
			if (this.isExpirable()) {
				this.pauseTimerOnHover.disable();
			}
		},
		_removeFromDom: function (toaster, animated) {
			$(this).removeClass(toaster.animationQuitClass);
			$(this).addClass(animated ? toaster.animationEndClass : D_HIDDEN);
			toaster._wrapper.removeChild(this);
			this._isRemoved = true;
		},
		template: template,
		postRender: function () {
			// TODO this should be done only if this.isDismissible()
			// but at this stage this.isDismissible() ouput is wrong because members have not been initialized yet

			// setting up the swipe to dismiss listener
			this.swipeToDismiss = new SwipeToDismiss(this, function () {
				this.dismiss(D_SWIPEOUT);
			}.bind(this));

			// setting up click listener for dismiss button
			if (this._dismissButton !== null) {
				this.on("pointerdown", function () {
					this.dismiss();
				}.bind(this), this._dismissButton);
			}
			// setting up pause timer on hover listener
			this.pauseTimerOnHover = new PauseTimerOnHover(this);
		}
	});
});