Source: delite/Scrollable.js

/** @module delite/Scrollable */
define([
	"dcl/dcl",
	"requirejs-dplugins/jquery!css,attributes/classes,effects,event",	// css(), toggleClass(), animate(), on()
	"./Widget",
	"./theme!./Scrollable/themes/{{theme}}/Scrollable.css"
], function (dcl, $, Widget) {

	/**
	 * A mixin which adds scrolling capabilities to a widget.
	 *
	 * When mixed into a host widget, this mixin brings scrolling capabilities
	 * based on the overflow: auto CSS property.
	 *
	 * By default, the scrolling capabilities are added to the widget
	 * node itself. The host widget can chose the node thanks to the property
	 * `scrollableNode` which must be set at latest in its `render()`
	 * method.
	 *
	 * During interactive or programmatic scrolling, native "scroll"
	 * events are emitted, and can be listened to as follows (here,
	 * `scrollWidget` is the widget into which this mixin is mixed):
	 *
	 * ```js
	 * scrollWidget.on("scroll", function (event) {
	 *   ...
	 * });
	 * ```
	 *
	 * For widgets that customize the `scrollableNode` property,
	 * the events should be listened to on `widget.scrollableNode`:
	 *
	 * ```js
	 * scrollWidget.on("scroll", function (event) {
	 *   ...
	 *   }, scrollWidget.scrollableNode);
	 * ```
	 *
	 * @mixin module:delite/Scrollable
	 * @augments module:delite/Widget
	 */
	return dcl(Widget, /** @lends module:delite/Scrollable# */{

		/**
		 * The direction of the interactive scroll. Possible values are:
		 * "vertical", "horizontal", "both, and "none".
		 *
		 * Note that scrolling programmatically using `scrollTo()` is
		 * possible on both horizontal and vertical directions independently
		 * on the value of `scrollDirection`.
		 * @member {string}
		 * @default "vertical"
		 */
		scrollDirection: "vertical",

		/**
		 * Designates the descendant node of this widget which is made scrollable.
		 *
		 * If not set otherwise before the `render()`
		 * method of the mixin is executed, it is set by default to this widget
		 * itself (`this`).
		 *
		 * Note that this property can be set only at construction time, at latest
		 * in the `render()` method of the host widget into which this class is mixed.
		 * It should not be changed afterwards.
		 * Typically, this property can be set by a host widget which needs scrolling
		 * capabilities on one its descendant nodes.
		 *
		 * @member {Element}
		 * @default null
		 */
		scrollableNode: null,

		render: dcl.after(function () {
			// Do it using after advice to give a chance to a custom widget to
			// set the scrollableNode at latest in an overridden render().
			if (!this.scrollableNode) {
				this.scrollableNode = this; // If unspecified, defaults to 'this'.
			}

			// Disable text selection in scrollable node as per
			// http://stackoverflow.com/questions/2700000/how-to-disable-text-selection-using-jquery
			this.scrollableNode.setAttribute("unselectable", "on");
			$(this.scrollableNode)
				.css("user-select", "none") // maps to WebkitUserSelect, etc.
				.on("selectstart", false);
		}),

		refreshRendering: function (oldVals) {
			if ("scrollDirection" in oldVals) {
				$(this.scrollableNode)
					.toggleClass("d-scrollable", this.scrollDirection !== "none")
					.toggleClass("d-scrollable-h", /^(both|horizontal)$/.test(this.scrollDirection))
					.toggleClass("d-scrollable-v", /^(both|vertical)$/.test(this.scrollDirection));
			}
		},

		detachedCallback: function () {
			this._stopAnimation();
		},

		/**
		 * Returns true if container's scroll has reached the maximum limit at
		 * the top of the contents. Returns false otherwise.
		 * @example
		 * var scrollableNode = scrollableWidget.scrollableNode;
		 * scrollableNode.on("scroll", function () {
		 *   if (scrollableWidget.isTopScroll()) {
		 *     console.log("Scroll reached the maximum limit at the top");
		 *   }
		 * }
		 * @returns {boolean}
		 */
		isTopScroll: function () {
			return this.scrollableNode.scrollTop === 0;
		},

		/**
		 * Returns true if container's scroll has reached the maximum limit at
		 * the bottom of the contents. Returns false otherwise.
		 * @example
		 * var scrollableNode = scrollableWidget.scrollableNode;
		 * scrollableNode.on("scroll", function () {
		 *   if (scrollableWidget.isBottomScroll()) {
		 *     console.log("Scroll reached the maximum limit at the bottom");
		 *   }
		 * }
		 * @returns {boolean}
		 */
		isBottomScroll: function () {
			var scrollableNode = this.scrollableNode;
			return scrollableNode.offsetHeight + scrollableNode.scrollTop >=
				scrollableNode.scrollHeight;
		},

		/**
		 * Returns true if container's scroll has reached the maximum limit at
		 * the left of the contents. Returns false otherwise.
		 * @example
		 * var scrollableNode = scrollableWidget.scrollableNode;
		 * scrollableNode.on("scroll", function () {
		 *   if (scrollableWidget.isLeftScroll()) {
		 *     console.log("Scroll reached the maximum limit at the left");
		 *   }
		 * }
		 * @returns {boolean}
		 */
		isLeftScroll: function () {
			return this.scrollableNode.scrollLeft === 0;
		},

		/**
		 * Returns true if container's scroll has reached the maximum limit at
		 * the right of the contents. Returns false otherwise.
		 * @example
		 * var scrollableNode = scrollableWidget.scrollableNode;
		 * scrollableNode.on("scroll", function () {
		 *   if (scrollableWidget.isRightScroll()) {
		 *     console.log("Scroll reached the maximum limit at the right");
		 *   }
		 * }
		 * @returns {boolean}
		 */
		isRightScroll: function () {
			var scrollableNode = this.scrollableNode;
			return scrollableNode.offsetWidth + scrollableNode.scrollLeft >= scrollableNode.scrollWidth;
		},

		/**
		 * Returns the current amount of scroll, as an object with x and y properties
		 * for the horizontal and vertical scroll amount.
		 * This is a convenience method and it is not supposed to be overridden.
		 * @returns {Object}
		 */
		getCurrentScroll: function () {
			return {x: this.scrollableNode.scrollLeft, y: this.scrollableNode.scrollTop};
		},

		/**
		 * Scrolls by the given amount.
		 * @param {Object} by - The scroll amount. An object with x and/or y properties, for example
		 * `{x: 0, y: -5}` or `{y: -29}`.
		 * @param {number} duration - Duration of scrolling animation in milliseconds.
		 * If 0 or unspecified, scrolls without animation.
		 */
		scrollBy: function (by, duration) {
			var to = {};
			if (by.x !== undefined) {
				to.x = this.scrollableNode.scrollLeft + by.x;
			}
			if (by.y !== undefined) {
				to.y = this.scrollableNode.scrollTop + by.y;
			}
			this.scrollTo(to, duration);
		},

		/**
		 * Scrolls to the given position.
		 * @param {Object} to - The scroll destination position. An object with w and/or y properties,
		 * for example `{x: 0, y: -5}` or `{y: -29}`.
		 * @param {number} [duration] - Duration of scrolling animation in milliseconds.
		 * If 0 or unspecified, scrolls without animation.
		 */
		scrollTo: function (to, duration) {
			var scrollableNode = this.scrollableNode;
			this._stopAnimation();
			if (!duration || duration <= 0) { // shortcut
				if (to.x !== undefined) {
					scrollableNode.scrollLeft = to.x;
				}
				if (to.y !== undefined) {
					scrollableNode.scrollTop = to.y;
				}
			} else {
				var from = {
					x: to.x !== undefined ? scrollableNode.scrollLeft : undefined,
					y: to.y !== undefined ? scrollableNode.scrollTop : undefined
				};
				// See http://james.padolsey.com/javascript/fun-with-jquerys-animate/
				var self = this;
				self._animation = $(from).animate(to, {
					duration: duration,
					rate: 20, // TODO: IMPROVEME
					step: function () {
						if (this.x !== undefined) {
							scrollableNode.scrollLeft = this.x;
						}
						if (this.y !== undefined) {
							scrollableNode.scrollTop = this.y;
						}
					},
					complete: function () {
						if (this.x !== undefined) {
							scrollableNode.scrollLeft = this.x;
						}
						if (this.y !== undefined) {
							scrollableNode.scrollTop = this.y;
						}
						delete self._animation;
					}
				});
			}
		},

		/**
		 * Stops the scrolling animation if it is currently playing.
		 * Does nothing otherwise.
		 */
		_stopAnimation: function () {
			if (this._animation) {
				this._animation.stop();
			}
		}
	});
});