rangeslider
Version:
A range slider with fallback to number inputs
590 lines (503 loc) • 22.2 kB
JavaScript
/**
* Creates a slider for picking intervals or numbers.
*
* A slider can be created with one or two draggable thumbs indicating the values.
*
* A slider with one thumb is created with
*
* new RangeSlider(startInputElement)
*
* A slider with two thumbs is created with
*
* new RangeSlider(startInputElement, endInputElement)
*
* The startInputElement must have a `min` attribute specified. If the slider has one thumb,
* the startInputElement must also have a `max` attribute specified. Otherwise, the `max`
* attribute must be specified on the endInputElement.
*
* The `step` attribute may be specified on the startInputElement, in which case values
* will be set in steps.
*
* The `value` attribute may be specified on both elements, defining the initial value. If it's
* not specified, the initial values will be set to the minimum and maximum values.
*
* The slider's interval will be the closed interval
*
* [startInputElement.min, endInputElement.max]
*
* If the `data-unbounded-end` attribute is specified on one of the elements, the corresponding
* end will be unbounded which means that the value of the element, upon reaching its maximum or
* minimum value, will be set to the empty string.
*/
/*
TODO:
Handle division by zero
*/
(function() {
"use strict";
function RangeSlider(startInputEle, endInputEle, options) {
if (!startInputEle) {
throw Error("A start input element has to be specified.");
}
options = options || {};
this._sliderEle = options.container;
this._format = options.formatter || function(value) { return value; };
this._output = options.output || this._setOutputValue;
this._isRangeSlider = Boolean(endInputEle);
this._activeThumb = null;
this._pageX = 0;
this._dragStartX = 0;
this._handleMouseDown = this._handleMouseDown.bind(this);
this._handleMouseUp = this._handleMouseUp.bind(this);
this._handleMouseMove = this._handleMouseMove.bind(this);
this._handleTouchStart = this._handleTouchStart.bind(this);
this._handleTouchEnd = this._handleTouchEnd.bind(this);
this._handleTouchMove = this._handleTouchMove.bind(this);
this._handleKeyDown = this._handleKeyDown.bind(this);
this._updateThumbPosition = this._updateThumbPosition.bind(this);
if (!this._sliderEle) {
this._createElements();
}
else {
var thumbs = this._sliderEle.querySelectorAll(".finn-slider-thumb");
this._startThumbEle = thumbs[0];
this._endThumbEle = thumbs[1] || null;
this._trackInnerEle = this._sliderEle.querySelector(".finn-slider-track-inner");
this._outputEle = this._sliderEle.querySelector(".finn-slider-output");
}
if (this._isRangeSlider) {
this._setupRangeSlider(startInputEle, endInputEle);
}
else {
this._setupSlider(startInputEle);
}
this._output(this.startValue, this.endValue);
startInputEle.parentNode.insertBefore(this._sliderEle, startInputEle);
this.layout();
window.addEventListener("resize", this.layout.bind(this));
}
RangeSlider.prototype = {
/**
* Sets the start value of the slider.
*/
setStartValue: function(value) {
if (typeof value != "number") {
throw TypeError("Value must be a number.");
}
this._setStartValue(value);
this._setStartThumbPosition(this._scaleValueToWidth(this.startValue));
},
/**
* Sets the end value of the slider.
*/
setEndValue: function(value) {
if (typeof value != "number") {
throw TypeError("Value must be a number.");
}
this._setEndValue(value);
this._setEndThumbPosition(this._scaleValueToWidth(this.endValue));
},
getOutputValue: function(start, end) {
var formattedStartValue = this._format(this._toFixed(start));
var formattedEndValue = this._format(this._toFixed(end));
var startLabel = this._outputLabel;
var endLabel = this._outputLabel;
var value;
if (this._isRangeSlider) {
if (start == this.minValue && this._startUnboundedOutputLabel) {
startLabel = this._startUnboundedOutputLabel;
}
if (end == this.maxValue && this._endUnboundedOutputLabel) {
endLabel = this._endUnboundedOutputLabel;
}
if ((end - start) == 0) {
var label = start == this.minValue ? startLabel : endLabel;
value = label.replace("{value}", formattedStartValue);
}
else {
value = (startLabel.replace("{value}", formattedStartValue) + " – " +
endLabel.replace("{value}", formattedEndValue));
}
}
else {
if (start == this.maxValue && this._startUnboundedOutputLabel) {
startLabel = this._startUnboundedOutputLabel;
}
value = startLabel.replace("{value}", formattedStartValue);
}
return value + " " + this._outputSuffix;
},
/**
* Sets the size of the slider. In cases where the slider element has
* display set to none while the slider initializes, this has to be run
* after the display is set to something else.
*/
layout: function() {
this._sliderWidth = this._sliderEle.offsetWidth;
this._sliderLeft = this._sliderEle.offsetLeft;
this._thumbWidth = this._startThumbEle.offsetWidth;
this._endThumbLeft = this._sliderWidth - this._thumbWidth;
this._setStartThumbPosition(this._scaleValueToWidth(this.startValue));
if (this._isRangeSlider) {
this._setEndThumbPosition(this._scaleValueToWidth(this.endValue));
}
},
// Simplistic implementation
dispatchEvent: function(event) {
this._sliderEle.dispatchEvent(event);
},
// Simplistic implementation
addEventListener: function(type, callback, capture) {
this._sliderEle.addEventListener(type, callback, capture || false);
},
// Simplistic implementation
removeEventListener: function(type, callback, capture) {
this._sliderEle.removeEventListener(type, callback, capture || false);
},
_setupSlider: function(startInputEle) {
var step = startInputEle.step.trim();
var isFraction = step.indexOf(".") != -1;
this.minValue = Number(startInputEle.min);
this.maxValue = Number(startInputEle.max);
this.startValue = startInputEle.value != ""
? this._clamp(Number(startInputEle.value), this.minValue, this.maxValue)
: this.minValue;
this.endValue = this.maxValue;
this._startInputEle = startInputEle;
this._step = Number(step) || 1;
this._fractionDigits = isFraction ? step.slice(step.indexOf(".") + 1).length : 0;
this._outputLabel = startInputEle.getAttribute("data-output") || "{value}";
this._outputSuffix = startInputEle.getAttribute("data-output-suffix") || "";
var unboundedAttr = startInputEle.getAttribute("data-unbounded-end");
this._isLeftUnboundedInterval = unboundedAttr != null && unboundedAttr != "false";
this._startUnboundedOutputLabel = startInputEle.getAttribute("data-unbounded-output") || "{value}";
if (!this._startThumbEle.hasAttribute("tabindex")) {
this._startThumbEle.tabIndex = 0;
}
this._startThumbEle.setAttribute("role", "slider");
this._startThumbEle.setAttribute("aria-valuemin", this.minValue);
this._startThumbEle.setAttribute("aria-valuemax", this.maxValue);
this._startThumbEle.setAttribute("aria-valuenow", this._toFixed(this.startValue));
this._startThumbEle.setAttribute("aria-valuetext", this._toFixed(this.startValue));
if (!this._startThumbEle.hasAttribute("aria-label")) {
this._startThumbEle.setAttribute("aria-label", "Start");
}
this._trackInnerEle.style.right = "0";
this._setStartInputValue(this.startValue);
this._startInputEle.addEventListener("input", function(event) {
this.setStartValue(Number(event.target.value));
}.bind(this));
this._startThumbEle.addEventListener("mousedown", this._handleMouseDown);
this._startThumbEle.addEventListener("touchstart", this._handleTouchStart);
this._startThumbEle.addEventListener("keydown", this._handleKeyDown);
},
_setupRangeSlider: function(startInputEle, endInputEle) {
startInputEle.max = endInputEle.max;
endInputEle.min = startInputEle.min;
this._setupSlider(startInputEle);
this.maxValue = Number(endInputEle.max);
endInputEle.step = this._step;
this.endValue = endInputEle.value != ""
// Clamping with min=startValue is intentional, to avoid endValue being lower than startValue
? this._clamp(Number(endInputEle.value), this.startValue, this.maxValue)
: this.maxValue;
this._endInputEle = endInputEle;
var unboundedAttr = endInputEle.getAttribute("data-unbounded-end");
this._isRightUnboundedInterval = unboundedAttr != null && unboundedAttr != "false";
this._endUnboundedOutputLabel = endInputEle.getAttribute("data-unbounded-output") || "{value}";
if (!this._endThumbEle.hasAttribute("tabindex")) {
this._endThumbEle.tabIndex = 0;
}
this._endThumbEle.setAttribute("role", "slider");
this._endThumbEle.setAttribute("aria-valuemin", this.minValue);
this._endThumbEle.setAttribute("aria-valuemax", this.maxValue);
this._endThumbEle.setAttribute("aria-valuenow", this._toFixed(this.endValue));
this._endThumbEle.setAttribute("aria-valuetext", this._toFixed(this.endValue));
if (!this._endThumbEle.hasAttribute("aria-label")) {
this._endThumbEle.setAttribute("aria-label", "End");
}
this._endThumbEle.style.removeProperty("right");
this._setEndInputValue(this.endValue);
this._endInputEle.addEventListener("input", function(event) {
this.setEndValue(Number(event.target.value));
}.bind(this));
this._endThumbEle.addEventListener("mousedown", this._handleMouseDown);
this._endThumbEle.addEventListener("touchstart", this._handleTouchStart);
this._endThumbEle.addEventListener("keydown", this._handleKeyDown);
},
_createElements: function() {
this._sliderEle = document.createElement("div");
this._sliderEle.className = "finn-slider";
var trackEle = document.createElement("div");
trackEle.className = "finn-slider-track";
this._trackInnerEle = document.createElement("div");
this._trackInnerEle.className = "finn-slider-track-inner";
this._startThumbEle = document.createElement("div");
this._startThumbEle.className = "finn-slider-thumb";
this._outputEle = document.createElement("output");
this._outputEle.className = "finn-slider-output";
this._sliderEle.appendChild(trackEle);
this._sliderEle.appendChild(this._trackInnerEle);
this._sliderEle.appendChild(this._startThumbEle);
if (this._isRangeSlider) {
this._endThumbEle = document.createElement("div");
this._endThumbEle.className = "finn-slider-thumb";
this._sliderEle.appendChild(this._endThumbEle);
}
this._sliderEle.insertBefore(this._outputEle, this._sliderEle.firstChild);
},
_setStartValue: function(value) {
var newValue = this._clamp(value, this.minValue, this.endValue);
if (this.startValue != value) {
this.startValue = newValue;
this._output(newValue, this.endValue);
this._setStartInputValue(newValue);
this._startThumbEle.setAttribute("aria-valuenow", this._toFixed(newValue));
this._startThumbEle.setAttribute("aria-valuetext", this._toFixed(newValue));
}
},
_setEndValue: function(value) {
var newValue = this._clamp(value, this.startValue, this.maxValue);
if (this.endValue != value) {
this.endValue = newValue;
this._output(this.startValue, newValue);
this._setEndInputValue(newValue);
this._endThumbEle.setAttribute("aria-valuenow", this._toFixed(newValue));
this._endThumbEle.setAttribute("aria-valuetext", this._toFixed(newValue));
}
},
_setStartInputValue: function(value) {
if (value == this.minValue && this._isLeftUnboundedInterval) {
value = "";
}
else {
value = this._toFixed(value);
}
this._startInputEle.value = value;
this._fireSyntheticEvent("input", "start");
},
_setEndInputValue: function(value) {
if (value == this.maxValue && this._isRightUnboundedInterval) {
value = "";
}
else {
value = this._toFixed(value);
}
this._endInputEle.value = value;
this._fireSyntheticEvent("input", "end");
},
_setOutputValue: function(start, end) {
this._outputEle.textContent = this.getOutputValue(start, end);
},
_setStartThumbPosition: function(value) {
this._startThumbLeft = value;
this._startThumbEle.style.left = value.toFixed(1) + "px";
this._trackInnerEle.style.left = (value + (this._thumbWidth / 2)).toFixed(1) + "px";
},
_setEndThumbPosition: function(value) {
this._endThumbLeft = value;
this._endThumbEle.style.left = value.toFixed(1) + "px";
this._trackInnerEle.style.right = (this._sliderWidth - value - (this._thumbWidth / 2)).toFixed(1) + "px";
},
_updateThumbPosition: function() {
// TODO: clean this up
var thumbLeft = this._pageX - this._sliderLeft - this._dragStartX;
var thumbPercent = thumbLeft / (this._sliderWidth - this._thumbWidth);
var value = this.minValue + ((this.maxValue - this.minValue) * thumbPercent);
var stepValue = Math.round(value / this._step) * this._step;
if (this._activeThumb == this._startThumbEle) {
this._setStartValue(stepValue);
this._setStartThumbPosition(this._clamp(thumbLeft, 0, this._endThumbLeft));
}
else if (this._activeThumb == this._endThumbEle) {
this._setEndValue(stepValue);
this._setEndThumbPosition(this._clamp(thumbLeft, this._startThumbLeft, this._sliderWidth - this._thumbWidth));
}
},
_handleMouseDown: function(event) {
event.stopPropagation();
event.preventDefault();
if (event.button != 0) {
return;
}
var target = event.target;
// Could be event.offsetX, but not all browsers support
this._dragStartX = event.pageX - target.offsetLeft - this._sliderLeft;
this._pageX = event.pageX;
// TODO: within some threshold?
if (this._isRangeSlider && this._startThumbLeft == this._endThumbLeft) {
this._observeMouseMoveDirection(target, event.pageX);
}
this._addMouseListeners(target);
},
_handleMouseUp: function(event) {
event.stopPropagation();
// TODO: only update if some value has changed
this._fireSyntheticEvent("change", this._getActiveThumbLabel());
this._removeTemporaryStyles(this._activeThumb);
this._activeThumb = null;
this._dragStartX = 0;
document.removeEventListener("mouseup", this._handleMouseUp);
document.removeEventListener("mousemove", this._handleMouseMove);
},
_handleMouseMove: function(event) {
event.stopPropagation();
event.preventDefault();
this._pageX = event.pageX;
this._updateThumbPosition();
},
_handleTouchStart: function(event) {
event.stopPropagation();
event.preventDefault();
// Prevent more than one touch on the same slider
if (this._activeThumb) {
return;
}
var target = event.target;
var touch = event.targetTouches[0];
this._dragStartX = touch.pageX - target.offsetLeft - this._sliderLeft;
this._pageX = touch.pageX;
// TODO: within some threshold?
if (this._isRangeSlider && this._startThumbLeft == this._endThumbLeft) {
this._observeTouchMoveDirection(target, touch.pageX);
}
// We want to skip the next mousedown handler at this point, we'll
// add it back later.
// Just set a skipNextMouseDown flag instead?
target.removeEventListener("mousedown", this._handleMouseDown);
this._addTouchListeners(target);
},
_handleTouchEnd: function(event) {
event.stopPropagation();
// TODO: only update if some value has changed
this._fireSyntheticEvent("change", this._getActiveThumbLabel());
this._removeTemporaryStyles(this._activeThumb);
this._activeThumb = null;
this._dragStartX = 0;
var target = event.target;
target.addEventListener("mousedown", this._handleMouseDown);
target.removeEventListener("touchend", this._handleTouchEnd);
target.removeEventListener("touchcancel", this._handleTouchEnd);
target.removeEventListener("touchmove", this._handleTouchMove);
},
_handleTouchMove: function(event) {
event.stopPropagation();
event.preventDefault();
var touches = event.targetTouches;
var touch = touches[0];
if (!touch) { // TODO: can this ever happen?
return;
}
this._pageX = touch.pageX;
this._updateThumbPosition();
},
_handleKeyDown: function(event) {
event.stopPropagation();
var isStartThumb = event.target == this._startThumbEle;
var value = isStartThumb ? this.startValue : this.endValue;
switch (event.key || event.keyIdentifier) {
case "Left":
case "Down":
value -= this._step;
break;
case "Right":
case "Up":
value += this._step;
break;
case "PageDown":
value -= this._step * 10;
break;
case "PageUp":
value += this._step * 10;
break;
case "Home":
value = this.minValue;
break;
case "End":
value = this.maxValue;
break;
default:
return;
}
if (isStartThumb) {
this.setStartValue(value);
}
else {
this.setEndValue(value);
}
this._fireSyntheticEvent("change", isStartThumb ? "start" : "end");
event.preventDefault();
},
_addMouseListeners: function(target) {
this._activeThumb = target;
this._addTemporaryStyles(target);
document.addEventListener("mouseup", this._handleMouseUp);
document.addEventListener("mousemove", this._handleMouseMove);
},
_addTouchListeners: function(target) {
this._activeThumb = target;
this._addTemporaryStyles(target);
target.addEventListener("touchend", this._handleTouchEnd);
target.addEventListener("touchcancel", this._handleTouchEnd);
target.addEventListener("touchmove", this._handleTouchMove);
},
_observeMouseMoveDirection: function(target, start) {
var handleMouseMove = function(event) {
var actualTarget = event.pageX < start
? this._startThumbEle
: this._endThumbEle;
target.removeEventListener("mousemove", handleMouseMove);
this._removeTemporaryStyles(target);
this._addMouseListeners(actualTarget);
}.bind(this);
target.addEventListener("mousemove", handleMouseMove);
},
_observeTouchMoveDirection: function(target, start) {
var handleTouchMove = function(event) {
var actualTarget = event.targetTouches[0].pageX < start
? this._startThumbEle
: this._endThumbEle;
target.removeEventListener("touchmove", handleTouchMove);
this._removeTemporaryStyles(target);
this._addTouchListeners(actualTarget);
}.bind(this);
target.addEventListener("touchmove", handleTouchMove);
},
_getActiveThumbLabel: function() {
return this._activeThumb == this._startThumbEle ? "start" : "end";
},
_addTemporaryStyles: function(target) {
var style = target.style;
style.setProperty("z-index", "1");
style.setProperty("-webkit-backface-visibility", "hidden");
style.setProperty("backface-visibility", "hidden");
},
_removeTemporaryStyles: function(target) {
var style = target.style;
style.removeProperty("z-index");
style.removeProperty("-webkit-backface-visibility");
style.removeProperty("backface-visibility");
},
_fireSyntheticEvent: function(type, which) {
var event = document.createEvent("CustomEvent");
var isStart = which == "start";
var details = {
changedInput: isStart ? this._startInputEle : this._endInputEle,
changedValue: isStart ? this.startValue : this.endValue
};
event.initCustomEvent(type, true, true, details);
this._sliderEle.dispatchEvent(event);
},
_toFixed: function(value) {
return value.toFixed(this._fractionDigits);
},
_scaleValueToWidth: function(value) {
var valueIntervalLength = this.maxValue - this.minValue;
return (value - this.minValue) * (this._sliderWidth - this._thumbWidth) / valueIntervalLength;
},
_clamp: function(actual, min, max) {
return Math.min(Math.max(actual, min), max);
}
};
window.RangeSlider = RangeSlider;
}());