Source: deliteful/Slider.js

/** @module deliteful/Slider */
define([
	"requirejs-dplugins/jquery!attributes/classes",
	"dpointer/events",
	"delite/register",
	"delite/FormValueWidget",
	"delite/CssState",
	"delite/handlebars!./Slider/Slider.html",
	"delite/theme!./Slider/themes/{{theme}}/Slider.css"
], function ($, dpointer, register, FormValueWidget, CssState, template) {
	/**
	 * @private
	 */
	function boxFromElement(domElt) {
		var ret = domElt.getBoundingClientRect();
		return {x: ret.left, y: ret.top, w: ret.right - ret.left, h: ret.bottom - ret.top};
	}

	/**
	 * The Slider widget allows selecting one value or a pair of values, from a range delimited by a minimum (min) and
	 * a maximum (max).
	 *
	 * The selected value depends on the position of the handle and the step, which specifies the value granularity.
	 * Slider can be vertical or horizontal. The position of the minimum and maximum depends on the text direction,
	 * and can be forced using the flip property. Handles can be move using pointers (mouse, touch) or keys
	 * (up, down, home or end).
	 *
	 * A change event is fired after the user select a new value, either by releasing a pointer, or by pressing a
	 * selection key. Before a change event, input events are fired while the user moves the Slider handle.
	 *
	 * The Slider Widget supports ARIA attributes aria-valuemin, aria-valuemax, aria-valuenow and aria-orientation.
	 *
	 * Most of the Slider behavior (default values, out of bound values reconciliations...) is similar to the
	 * HTML5.1 input type=range element [1], but it doesn't strictly conform to the specification, in particular for:
	 * - the "multiple" attribute (single/range Slider is directly determined from the content of the value property)
	 * - the "datalist" attribute (see https://github.com/ibm-js/deliteful/issues/252)
	 *
	 * Like the native input type=range element, this widget can be used in a form. It relies on a hidden input text
	 * element to provide the value to the form.
	 *
	 * [1] http://www.w3.org/TR/html5/forms.html#range-state-%28type=range%29
	 *
	 * @class module:deliteful/Slider
	 * @augments module:delite/FormValueWidget
	 * @augments module:delite/CssState
	 */
	return register("d-slider", [HTMLElement, FormValueWidget, CssState],
		// todo: HTML5 introduce the attribute "multiple" to handle multiple values
		/** @lends module:deliteful/Slider# */ {

			/**
			 * Indicates the minimum boundary of the allowed range of values. Must be a valid floating-point number.
			 * Invalid min value is defaulted to 0.
			 * @member {number}
			 * @default 0
			 */
			min: 0,

			/**
			 * Indicates the maximum boundary of the allowed range of values. Must be a valid floating-point number.
			 * Invalid max value is defaulted to 100.
			 * @member {number}
			 * @default 100
			 */
			max: 100,

			/**
			 * Specifies the value granularity. causes the slider handle to snap/jump to the closest possible value.
			 * Must be a positive floating-point number. Invalid step value is defaulted to 1.
			 * @member {number}
			 * @default 1
			 */
			step: 1,

			/**
			 * Applies only when the slider has two values. Allow sliding the area between the handles to change both
			 * values at the same time.
			 * @member {boolean}
			 * @default true
			 */
			slideRange: true,

			/**
			 * The slider direction:
			 * - false: horizontal
			 * - true: vertical
			 * @member {boolean}
			 * @default false
			 */
			vertical: false,

			/**
			 * Specifies if the slider should change its default: ascending <--> descending.
			 * @member {boolean}
			 * @default false
			 */
			flip: false,

			/**
			 * The name of the CSS class of this widget.
			 * @member {string}
			 * @default "d-slider"
			 */
			baseClass: "d-slider",

			/**
			 * Names of events and CSS properties whose values depend on the orientation of the Slider.
			 * `_orientationNames[true]` to get names when orientation is vertical.
			 * `_orientationNames[false]` to get names when orientation is horizontal.
			 * @private
			 */
			_orientationNames: {
				false: {
					start: "x",
					size: "w",
					clientStart: "clientX",
					progressBarStart: "left",
					progressBarSize: "width"
				},
				true: {
					start: "y",
					size: "h",
					clientStart: "clientY",
					progressBarStart: "top",
					progressBarSize: "height"
				}
			},

			/**
			 * Names of event and CSS properties to use with the current orientation of the Slider.
			 * _orientation.start = "x|y"
			 * _orientation.size = "w|h"
			 * _orientation.clientStart = "clientX|clientY"
			 * _orientation.progressBarSize = "width|height"
			 * @private
			 */
			_propNames: null,

			/**
			 * Used for various calculations: Indicates if current direction must be/is reversed.
			 * @private
			 */
			_reversed: false,

			template: template,

			render: register.superCall(function (sup) {
				return function () {
					sup.call(this);
					if (!this.valueNode.parentNode) {
						this.appendChild(this.valueNode);
					}
					this.handleMin.setAttribute("aria-valuemin", this.min);
					this.focusNode.setAttribute("aria-valuemax", this.max);
					this.tabStops = "handleMin,focusNode";
					this.handleMin._isActive = true;
					// prevent default browser behavior / accept pointer events
					// todo: use pan-x/pan-y according to this.vertical (once supported by dpointer)
					// https://github.com/ibm-js/dpointer/issues/8
					dpointer.setTouchAction(this, "none");
				};
			}),

			/**
			 * Update the handle(s) attribute `aria-orientation` to reflect the actual value of the
			 * `vertical` property.
			 * Update _propName with the properties name to use with the current orientation of the Slider.
			 * @private
			 */
			_refreshOrientation: function () {
				this.focusNode.setAttribute("aria-orientation", this.vertical ? "vertical" : "horizontal");
				if (this.handleMin._isActive) {
					this.handleMin.setAttribute("aria-orientation", this.vertical ? "vertical" : "horizontal");
				}
				this._propNames = this._orientationNames[this.vertical];
			},

			/**
			 * Refresh CSS classes.
			 * @private
			 */
			_refreshCSS: function () {
				function toCSS(baseClass, modifier) {
					return baseClass.split(/ /).map(function (c) {
						return c + modifier;
					}).join(" ");
				}
				// add V or H suffix to baseClass for styling purposes
				var rootBaseClass = toCSS(this.baseClass, this.vertical ? "-v" : "-h");
				var baseClass = this.baseClass + " " + rootBaseClass;
				// root node: do not remove all classes; user may define custom classes; CssState adds classes that
				// we do not want to lose.
				$(this).removeClass(toCSS(this.baseClass + "-v" + " " + this.baseClass + "-h", "-htl") + " " +
					toCSS(this.baseClass + "-v" + " " + this.baseClass + "-h", "-lth") + " " +
					this.baseClass + "-v" + " " + this.baseClass + "-h");
				$(this).addClass(rootBaseClass + " " + toCSS(baseClass, this._reversed ? "-htl" : "-lth"));
				this.wrapperNode.className = toCSS(baseClass, "-bar") + " " + toCSS(baseClass, "-container");
				this.progressBar.setAttribute("style", "");// reset left/width/height/top
				this.progressBar.className = toCSS(baseClass, "-bar") + " " + toCSS(baseClass, "-progress-bar");
				this.focusNode.className = toCSS(baseClass, "-handle") + " " + toCSS(baseClass, "-handle-max");
				if (this.handleMin._isActive) {
					this.handleMin.className = toCSS(baseClass, "-handle") + " " + toCSS(baseClass, "-handle-min");
				}
			},

			/* jshint maxcomplexity: 12 */
			computeProperties: function (props) {
				if ("value" in props || "min" in props || "max" in props || "step" in props) {
					var value = this._getValueAsArray(),
						isDual = value.length > 1,
						// convert and set default value(s) as needed
						minValue = this._convert2Float(value[0],
							this._calculateDefaultValue(isDual ? 0.25 : 0.5)),
						maxValue = this._convert2Float(value[value.length - 1],
							this._calculateDefaultValue(isDual ? 0.75 : 0.5)),
						// ensure minValue is less than maxValue
						maxV = Math.max(minValue, maxValue);
					minValue = Math.min(minValue, maxValue);
					maxValue = maxV;
					// correct step mismatch/underflow/overflow
					minValue = this._adjustValue(minValue, this.min);
					maxValue = this._adjustValue(maxValue, minValue);
					// set corrected value as needed
					this.value = isDual ? (minValue + "," + maxValue) : String(maxValue);
				}

				// Complicated since you can have flipped right-to-left and vertical is upside down by default.
				if ("vertical" in props || "flip" in props || "effectiveDir" in props) {
					var ltr = this.effectiveDir === "ltr";
					this._reversed = !((!this.vertical && (ltr !== this.flip)) || (this.vertical && this.flip));
				}
			},
			/* jshint maxcomplexity: 10 */

			refreshRendering: function (props) {
				if ("value" in props) {
					this._refreshValueRendering();
				}
				if ("vertical" in props) {
					this._refreshOrientation();
				}
				if ("name" in props) {
					var name = this.name;
					this.removeAttribute("name");
					// won't restore after a browser back operation since name changed nodes
					this.valueNode.setAttribute("name", name);
				}
				if ("max" in props) {
					this.focusNode.setAttribute("aria-valuemax", this.max);
				}
				if ("min" in props) {
					(this.handleMin._isActive ? this.handleMin : this.focusNode)
						.setAttribute("aria-valuemin", this.min);
				}
				if ("baseClass" in props || "vertical" in props || "_reversed" in props) {
					this._refreshCSS();
				}
				this._positionHandles();
			},

			/**
			 * Set handle(s) position relative to the progress bar.
			 * @private
			 */
			_positionHandles: function () {
				var currentVal = this._getValueAsArray();
				if (currentVal.length === 1) {
					currentVal = [this.min, currentVal[0]];
				}
				var toPercent = (currentVal[1] - this.min) * 100 /
						(this.max < this.min ? this.min : this.max - this.min),
					toPercentMin = (currentVal[0] - this.min) * 100 /
						(this.max < this.min ? this.min : this.max - this.min);
				this.progressBar.style[this._propNames.progressBarSize] = (toPercent - toPercentMin) + "%";
				this.progressBar.style[this._propNames.progressBarStart] =
					(this._reversed ? (100 - toPercent) : toPercentMin) + "%";
			},

			/**
			 * Add/remove and set handle as needed.
			 * @private
			 */
			_refreshValueRendering: function () {
				var currentVal = this._getValueAsArray();
				if (!this.handleMin._isActive && currentVal.length === 2) {
					this.handleMin.setAttribute("aria-valuemin", this.min);
					this.focusNode.setAttribute("aria-valuemax", this.max);
					this.tabStops = "handleMin,focusNode";
					this.handleMin._isActive = true;
				}
				if (this.handleMin._isActive && currentVal.length === 1) {
					this.handleMin.className = "d-hidden";
					this.handleMin.removeAttribute("aria-valuemin");
					this.focusNode.setAttribute("aria-valuemin", this.min);
					this.focusNode.setAttribute("aria-valuemax", this.max);
					this.handleMin._isActive = false;
				}
				// update aria attributes
				if (this.handleMin._isActive) {
					this.handleMin.setAttribute("aria-valuenow", currentVal[0]);
					this.handleMin.setAttribute("aria-valuemax", currentVal[1]);
					this.focusNode.setAttribute("aria-valuemin", currentVal[0]);
					this.focusNode.setAttribute("aria-valuenow", currentVal[1]);
				} else {
					this.focusNode.setAttribute("aria-valuenow", currentVal[0]);
				}
				// set input field value.
				this.valueNode.value = String(this.value);
			},

			createdCallback: function () {
				this._pointerCtx = {
					target: null, // the element that has focus when user manipulate a pointer
					offsetVal: 0, // Offset value when use points and drag a handle
					containerBox: null // to avoid recalculations when moving the slider with a pointer
				};

				this.on("pointerdown", this.pointerDownHandler.bind(this));
				this.on("pointermove", this.pointerMoveHandler.bind(this));
				this.on("lostpointercapture", this.lostCaptureHandler.bind(this));
				this.on("keydown", this.keyDownHandler.bind(this));
				this.on("keyup", this.keyUpHandler.bind(this));
			},

			postRender: function () {
				if (this.valueNode.value) { // INPUT value
					// browser back button or value coded on INPUT
					// the valueNode value has precedence over the widget markup value
					this.value = this.valueNode.value;
				}
			},

			attachedCallback: function () {
				// Chrome: avoids text selection of elements when mouse is dragged outside of the Slider.
				this.onmousedown = function (e) {
					e.preventDefault();
				};
			},

			/**
			 * HTML 5.1 input range spec:
			 * The min attribute, if specified, must have a value that is a valid floating-point number.
			 * The default minimum is 0.
			 * If the element has a min attribute, and the result of applying the algorithm to convert a
			 * string to a number to the value of the min attribute is a number, then that number is the element's
			 * minimum; otherwise, if the type attribute's current state defines a default minimum, then
			 * that is the minimum.
			 * @param value
			 * @private
			 */
			_setMinAttr: function (value) {
				this._set("min", this._convert2Float(value, 0));
			},

			/**
			 * HTML 5.1 input range spec:
			 * The max attribute, if specified, must have a value that is a valid floating-point number.
			 * The default maximum is 100.
			 * If the element has a max attribute, and the result of applying the algorithm to convert a
			 * string to a number to the value of the max attribute is a number, then that number is the element's
			 * maximum; otherwise, if the type attribute's current state defines a default maximum,
			 * then that is the maximum;
			 * @param value
			 * @private
			 */
			_setMaxAttr: function (value) {
				this._set("max", this._convert2Float(value, 100));
			},

			/**
			 * Must be a positive floating-point number. Invalid step value is defaulted to 1.
			 * @param value
			 * @private
			 */
			_setStepAttr: function (value) {
				value = this._convert2Float(value, 1);
				this._set("step", value <= 0 ? 1 : value);
			},

			/**
			 * HTML 5.1 spec (input range attributes):
			 * The Infinity and Not-a-Number (NaN) values are not valid floating-point numbers.
			 * @param value
			 * @param defaultValue
			 * @returns {Number|*}
			 * @private
			 */
			_convert2Float: function (value, defaultValue) {
				var v = parseFloat(value);
				return (isNaN(v) || v === Infinity) ? defaultValue : v;
			},

			/**
			 * HTML 5.1 input range spec:
			 * The default value is the minimum plus half the difference between the minimum and the
			 * maximum, unless the maximum is less than the minimum, in which case the default value
			 * is the minimum.
			 * @param ratio For a single handle, ratio is 0.5 ("half the difference between the minimum and the
			 * maximum"). For dual handle, it is 0.25 or 0.75.
			 * @private
			 */
			_calculateDefaultValue: function (ratio) {
				return this.max < this.min ? this.min : this.min + (this.max - this.min) * ratio;
			},

			/**
			 * Correct the value according to the HTML 5.1 input range spec.
			 * @param value the actual value to correct
			 * @param relativeMin the minimum value relative to the current value.
			 * @returns {Number|*}
			 * @private
			 */
			_adjustValue: function (value, relativeMin) {
				// value = (this.max > this.min) ? Math.min(this.max, value) : value;
				// When the element is suffering from a step mismatch, the user agent must round the element's value to
				// the nearest number for which the element would not suffer from a step mismatch, and which is greater
				// than or equal to the minimum, and, if the maximum is not less than the minimum, which is less than or
				// equal to the maximum, if there is a number that matches these constraints. If two numbers match these
				// constraints, then user agents must use the one nearest to positive infinity.
				if (value % this.step) {
					var x = Math.max(relativeMin, Math.round(value / this.step) * this.step);
					value = (this.max > relativeMin) ? Math.min(this.max, x) : x;
				}
				// When the element is suffering from an underflow, the user agent must set the element's
				// value to a valid floating-point number that represents the minimum. (spec)
				value = Math.max(relativeMin, value);
				// When the element is suffering from an overflow, if the maximum is not less than the minimum,
				// the user agent must set the element's value to a valid floating-point number that represents
				// the maximum. (spec)
				value = Math.min(this.max > this.min ? this.max : this.min, value);
				return value;
			},

			/**
			 * Convenience method to get the value as an array.
			 * @returns {Array}
			 * @private
			 */
			_getValueAsArray: function () {
				return String(this.value).split(/,/g);
			},

			/* jshint maxcomplexity: 11 */
			pointerDownHandler: function (e) {
				if (this._ignoreUserInput(e)) {
					return;
				}

				this._pointerCtx.target = null;
				this._pointerCtx.offsetVal = 0;
				this._pointerCtx.containerBox = boxFromElement(this.wrapperNode);
				var currentVal = this._getValueAsArray();
				var selectedVal = this._selectedValue(e, this._pointerCtx.containerBox);

				if (this._startSlideRange(e)) {
					// user is about to slide a range of values
					this._pointerCtx.target = this.progressBar;
					this._pointerCtx.offsetVal = selectedVal - currentVal[0];
				} else {
					// relativePos allow to determine which handle should get the focus and move, according to the
					// selected value:
					// relativePos > 0 => handleMin
					// relativePos < 0 => focusNode
					// relativePos = 0 => must be decided 
					var relativePos = Math.abs(selectedVal - currentVal[1]) - Math.abs(selectedVal - currentVal[0]);
					if (relativePos === 0 && (e.target === this.focusNode || e.target === this.handleMin)) {
						this._pointerCtx.target = document.elementFromPoint(e.clientX, e.clientY);
					} else {
						if (relativePos === 0) {
							// determine which handle can move to the position of the selected value.
							relativePos = currentVal[0] -
								Math.min(this.max - this.step, Math.max(this.min + this.step, selectedVal));
						}
						// get the handle which is closest from the selected value.
						this._pointerCtx.target = (relativePos > 0) ? this.handleMin : this.focusNode;
					}
					this._pointerCtx.target.focus();
					if (e.target !== this.focusNode && e.target !== this.handleMin) {
						this.handleOnInput(this._formatSelection(selectedVal, this._pointerCtx.target));
					}

				}
				if (e.target === this.focusNode || e.target === this.handleMin) {
					// track offset between current and selected value 
					this._pointerCtx.offsetVal = selectedVal -
						currentVal[(this.handleMin._isActive && (this._pointerCtx.target === this.focusNode)) ? 1 : 0];
				}
				// start capture on the target element
				dpointer.setPointerCapture(this._pointerCtx.target, e.pointerId);
				e.stopPropagation();
			},

			pointerMoveHandler: function (e) {
				if (e.target === this._pointerCtx.target) {
					this.handleOnInput(this._formatSelection(this._selectedValue(e, this._pointerCtx.containerBox) -
						this._pointerCtx.offsetVal, e.target));
					e.stopPropagation();
				}
			},

			lostCaptureHandler: function () {
				this._pointerCtx.target = null;
				this.handleOnChange(this.value);
			},

			/* jshint maxcomplexity: 13 */
			keyDownHandler: function (e) {
				if (this._ignoreUserInput(e)) {
					return;
				}
				var currentVal = this._getValueAsArray(),
					idx = (e.target === this.focusNode) ? currentVal.length - 1 : 0,
					multiplier = 1,
					newValue;
				switch (e.key) {
				case "Home":
					newValue = [this.min, currentVal[0]][idx];
					break;
				case "End":
					newValue = (e.target === this.handleMin) ? currentVal[1] : this.max;
					break;
				case "ArrowRight":
					multiplier = -1;
					/* falls through */
				case "ArrowLeft":
					newValue = parseFloat(currentVal[idx]) +
						multiplier * ((this.flip && !this.vertical) ? this.step : -this.step);
					break;
				case "ArrowDown":
					multiplier = -1;
					/* falls through */
				case "ArrowUp":
					newValue = parseFloat(currentVal[idx]) +
						multiplier * ((!this.flip || !this.vertical) ? this.step : -this.step);
					break;
				default:
					return;
				}
				this.handleOnInput(this._formatSelection(newValue, e.target));
				e.preventDefault();
			},

			keyUpHandler: function (e) {
				if (this._ignoreUserInput(e)) {
					return;
				}
				if (e.target === this.focusNode || e.target === this.handleMin) {
					this.handleOnChange(this.value);
				}
			},

			/**
			 * Return true if the user input should be ignored.
			 * @param event
			 * @returns {Boolean}
			 * @private
			 */
			_ignoreUserInput: function (event) {
				return this.disabled || this.readOnly || event.altKey || event.ctrlKey || event.metaKey;
			},

			/**
			 * Return true if all conditions required to slide a range of value are fulfilled.
			 * @param uiEvent
			 * @returns {boolean}
			 * @private
			 */
			_startSlideRange: function (uiEvent) {
				if (!(this.slideRange && this.handleMin._isActive) ||
					uiEvent.target === this.focusNode || uiEvent.target === this.handleMin) {
					return false;
				}
				var progressBarBox = boxFromElement(this.progressBar);
				var currentPos = uiEvent[this._propNames.clientStart] - progressBarBox[this._propNames.start];
				var maxPos = progressBarBox[this._propNames.size];
				return (currentPos >= 0 && currentPos <= maxPos);
			},

			/**
			 * Read UI Event coordinates and calculate the corresponding value, corrected with the step, without
			 * enforcing boundaries to allow user to slide the handle outside the boundaries to set value to min/max.
			 * @param uiEvent a UI event
			 * @param containerBox
			 * @private
			 */
			_selectedValue: function (uiEvent, containerBox) {
				function pixel2value(pixelValue, pixelMin, pixelMax, valMin, valMax) {
					return ((pixelValue - pixelMin) * (valMax - valMin)) / (pixelMax - pixelMin) + valMin;
				}

				var pixelMax = containerBox[this._propNames.size];
				var pixelValue = uiEvent[this._propNames.clientStart] - containerBox[this._propNames.start];
				return Math.round(pixel2value(pixelValue, this._reversed ? pixelMax : 0, this._reversed ? 0 : pixelMax,
					this.min, this.max) / this.step) * this.step;
			},

			/**
			 * format and return the selected value corrected from min/max boundaries in case the handle is released
			 * outside of the widget coordinates.
			 * @param newValue the new selected value
			 * @param sourceNode the node responsible of the new selected value
			 * @private
			 */
			_formatSelection: function (newValue, sourceNode) {
				var currentVal = this._getValueAsArray();
				var updatedValue = newValue;
				switch (sourceNode) {
				case this.focusNode:
					updatedValue = (currentVal.length === 1) ? String(newValue) :
						Math.min(currentVal[0], newValue) + "," + newValue;
					break;
				case this.handleMin:
					updatedValue = newValue + "," + Math.max(currentVal[1], newValue);
					break;
				case this.progressBar:
					var delta = currentVal[1] - currentVal[0];
					newValue = Math.max(this.min, Math.min(newValue + delta, this.max) - delta);
					updatedValue = newValue + "," + (newValue + delta);
					break;
				}
				return updatedValue;
			}
		});
});