A dual slider for number inputs.
Option name | Type | Description |
---|---|---|
module | components/range-slider.js |
new RangeSlider(el, {
// Optional. Slide along the x or y-axis?
isX: true,
// onChange callback
onChange: function(inst, index, value){},
});
RangeSlider constructor.
Option name | Type | Description |
---|---|---|
el | Element | |
params | Object |
var RangeSlider = function(el, params) {
if (!el) {
return;
}
this._setParams(this.defaults, true);
this._cacheElements(el);
this._setParams(params || {});
this._bindEventListenerCallbacks();
this._addEventListeners();
this._updateDisabledClasses();
};
RangeSlider.prototype = {
Include common functionality.
_setParams: Base.setParams,
_toggleClass: Base.toggleClass,
_getElementOffset: Base.getElementOffset,
_getNodeListIndex: Base.getNodeListIndex,
Whitelisted parameters which can be set on construction.
Option name | Type | Description |
---|
_whitelistedParams: ['isX', 'onChange', 'onWillChange'],
Default values for internal properties we will be setting.
These are set on each construction so we don't leak properties
into the prototype chain.
Option name | Type | Description |
---|
defaults: {
el: null,
controlsEl: null,
inputEls: null,
handleEls: null,
trackEl: null,
trackFillEl: null,
isActive: null,
isX: true,
onChange: null,
onWillChange: null,
position: 0,
width: 0,
height: 0,
mins: null,
maxes: null,
steps: null,
values: null,
percentages: null,
offsetLeft: 0,
offsetTop: 0,
handleSizePercentage: 0,
currentIndex: null,
lastIndex: null,
_oldVal: null,
_onTouchStartBound: null,
_onTouchMoveBound: null,
_onTouchEndBound: null,
_onMouseDownBound: null,
_onMouseMoveBound: null,
_onMouseUpBound: null,
_onMouseOutBound: null,
_onFocusBound: null,
_onKeydownBound: null,
_onBlurBound: null,
_onChangeBound: null,
_onResizeBound: null,
_onClickBound: null,
_onVisibleChildrenBound: null
},
Start the slider moving.
Option name | Type | Description |
---|---|---|
index | Number | The index of the handle or input element. |
position | Number | The position of the pointer. |
type | String | Optional Which type of events to listen for. |
start: function(index, position, type) {
// Noop if we're disabled or an invalid index was passed
if (index < 0 || this.inputEls[index].getAttribute('disabled') !== null) {
return;
}
this._addMoveEventListeners(type || 'mouse');
this._cacheSize();
this.isActive = this.isActive || [];
this.isActive[index] = true;
this.currentIndex = index;
this._updateActiveClasses(index);
this._updateDisabledClasses();
this._oldVal = this.values[index];
this.move(position);
},
Move the value to a given position
Option name | Type | Description |
---|---|---|
position | Number | |
force | Boolean | Force the move Optional |
move: function(position, force) {
// Noop if an invalid index was passed we haven't yet started dragging
if ((!position || !this.isActive || !this.isActive[this.currentIndex]) && !force) {
return;
}
// Treat positions beyond the boundaries as the boundaries
if (this.isX) {
// Too far left
if (position < this.offsetLeft) {
position = this.offsetLeft;
}
// Too far right
else if (position > this.offsetLeft + this.width) {
position = this.offsetLeft + this.width;
}
} else {
// Too far top
if (position < this.offsetTop) {
position = this.offsetTop;
}
// Too far bottom
else if (position > this.offsetTop + this.height) {
position = this.offsetTop + this.height;
}
}
// The percentage of the new position relative to slider-container width or height.
var percentage = this.isX ? (position - this.offsetLeft) / (this.width - this.handleSize) : (position - this.offsetTop) / this.height;
// The value of the input as a percentage of the value range.
this.setValue(this.currentIndex, Math.round((percentage - this.handleSizePercentage / 2) * (this.highestMax - this.lowestMin)) + this.lowestMin);
},
Stop listening to movements.
Option name | Type | Description |
---|---|---|
index | Number | Optional The index of the handle or input element. |
type | String | Optional Which type of events to listen for. |
stop: function(index, type) {
if (index !== null && index !== undefined && this.currentIndex !== index) {
return;
}
this.isActive[this.currentIndex] = false;
this.lastIndex = this.currentIndex;
if(this._oldVal !== this.values[this.currentIndex]) {
(this.onChange || noop)(this, this.currentIndex, this.values[this.currentIndex]);
}
this.currentIndex = null;
this._updateActiveClasses(index);
this._removeMoveEventListeners(type || 'mouse');
},
Set the value of the handle.
Option name | Type | Description |
---|---|---|
index | Number | The index of the input element. |
value | Number |
setValue: function(index, value) {
// We don't have an input element at that index, so something went wrong.
if (!this.inputEls[index]) {
throw new Error('Cannot set value on input element with an index of ' + index + '. That element does not exist.');
}
// Move in increments if we have a defined step size
if (this.steps[index]) {
value = value - (value % this.steps[index]);
}
this.values = this.values || [];
// Check bounds of the new value
if (value > this.maxes[index]) {
value = this.maxes[index];
} else if (value < this.mins[index]) {
value = this.mins[index];
}
// If there is an input that comes after this, make sure we aren't going beyond it
if (this.values[index + 1] !== undefined && value >= this.values[index + 1]) {
value = this.values[index + 1] - (this.steps[index] || 1);
}
// If there is an input that comes before this, make sure we aren't going below it
else if (this.values[index - 1] !== undefined && value <= this.values[index - 1]) {
value = this.values[index - 1] + (this.steps[index] || 1);
}
// If there is an onWillChange callback, run it. If it returns
// false, then this new value should be considered invalid.
if (typeof this.onWillChange === 'function') {
var change = this.onWillChange(this, index, value);
if (typeof change === 'number') {
value = change;
}
}
// Store value
this.values[index] = value;
// Update elements
this.inputEls[index].value = this.values[index];
this.handleEls[index].setAttribute('data-value', this.values[index]);
// Set the percentage
this.percentages = this.percentages || [];
this.percentages[index] = (this.values[index] - this.lowestMin) / (this.highestMax - this.lowestMin);
// Update the position of the handle
this._updateHandlePosition(index);
},
Increment the value by the step size.
Option name | Type | Description |
---|---|---|
useMultiplier | Boolean | Optional Increment by a multiplied version of the step |
increment: function(useMultiplier) {
this.setValue(this.currentIndex, this.values[this.currentIndex] + this.steps[this.currentIndex] * (useMultiplier ? 10 : 1));
},
Decrement the value by the step size.
Option name | Type | Description |
---|---|---|
useMultiplier | Boolean | Optional Increment by a multiplied version of the step |
decrement: function(useMultiplier) {
this.setValue(this.currentIndex, this.values[this.currentIndex] - this.steps[this.currentIndex] * (useMultiplier ? 10 : 1));
},
Remove the element from the DOM and prepare for garbage collection by dereferencing values.
Option name | Type | Description |
---|---|---|
leaveElement | Boolean | Leave the element intact. |
remove: function(leaveElement) {
this._removeMoveEventListeners('touch');
this._removeMoveEventListeners('mouse');
this._removeMoveEventListeners('keyboard');
Base.remove.call(this, leaveElement);
},
Store a reference to the whole slider, as well as the
input element. Also, get some default values from the input
element (min, max, steps).
Option name | Type | Description |
---|---|---|
el | Element |
_cacheElements: function(el) {
this.el = el;
this.controlsEl = this.el.querySelector('.spark-slider__controls');
this.inputEls = this.el.querySelectorAll('input[type="number"]');
this.handleEls = this.el.querySelectorAll('.spark-slider__handle');
this.trackEl = this.el.querySelector('.spark-slider__track');
this.trackFillEl = this.trackEl.querySelector('.spark-slider__track-fill');
if (!this.inputEls || this.inputEls.length <= 1) {
throw new Error('Tried to create a slider instance without two number inputs.');
}
if (!this.handleEls || this.handleEls.length <= 1) {
throw new Error('Tried to create a slider instance without two handle buttons.');
}
var lowestMin = Infinity;
var highestMax = -Infinity;
var i = 0;
var len = this.inputEls.length;
var values = [];
this.mins = [];
this.maxes = [];
this.steps = [];
// Cache the size of the element so that we can properly set values on handles.
this._cacheSize();
// Set the minimum and max values for each element. Also set any predefined value.
for (; i < len; i++) {
this.mins[i] = parseInt(this.inputEls[i].getAttribute('min'), 10) || null;
this.maxes[i] = parseInt(this.inputEls[i].getAttribute('max'), 10) || null;
this.steps[i] = parseInt(this.inputEls[i].getAttribute('step'), 10) || 1;
if (this.mins[i] < lowestMin) {
lowestMin = this.mins[i];
}
if (this.maxes[i] > highestMax) {
highestMax = this.maxes[i];
}
}
this.lowestMin = lowestMin;
this.highestMax = highestMax;
i = 0;
// If we have a default value, set it.
for (; i < len; i++) {
values[i] = parseInt(this.inputEls[i].getAttribute('value'), 10);
// It's a number
if (!isNaN(values[i])) {
this.setValue(i, values[i]);
} else {
// Set as the minimum unless this is the last handle.
if (i + 1 === len) {
this.setValue(i, this.maxes[i] !== null ? this.maxes[i] : 0);
} else {
this.setValue(i, this.mins[i] !== null ? this.mins[i] : 0);
}
}
}
},
Save the element dimensions.
_cacheSize: function() {
this.width = this.trackEl.clientWidth;
this.height = this.trackEl.clientHeight;
this.handleSize = this.isX ? this.handleEls[0].clientWidth : this.handleEls[0].clientHeight;
this.handleSizePercentage = this.isX ? this.handleEls[0].clientWidth / this.width : this.handleEls[0].clientHeight / this.height;
var offset = this._getElementOffset(this.controlsEl);
this.offsetLeft = offset.left;
this.offsetTop = offset.top;
},
Set the position of the handle.
Option name | Type | Description |
---|---|---|
index | Number | The index of the handle element to update. |
_updateHandlePosition: function(index) {
// Adjust the percentage of the total to be the percentage of the total less the size of the handle.
var percentage = this.percentages[index] * (1 - this.handleSizePercentage) + (this.handleSizePercentage / 2);
percentage = Math.round(Math.min(percentage, 1) * 100);
this.handleEls[index].setAttribute('style', 'left: ' + percentage + '%;');
var firstPercentage = this.percentages[0];
var lastPercentage = this.percentages[this.percentages.length - 1];
this.trackFillEl.setAttribute('style', 'width: ' + ((lastPercentage - firstPercentage) * 100) + '%; left: ' + (firstPercentage * 100) + '%;');
},
Update the active class on the handle.
Option name | Type | Description |
---|---|---|
index | Number | The index of the handle element to update. |
_updateActiveClasses: function(index) {
this._toggleClass(this.handleEls, 'active', false);
this._toggleClass(this.handleEls[index], 'active', this.isActive[index]);
if (this.isActive.indexOf(true) !== -1) {
this.el.setAttribute('data-active-index', this.isActive.indexOf(true));
} else {
this.el.removeAttribute('data-active-index');
}
},
Update which handles are disabled.
_updateDisabledClasses: function() {
var disabledCount = 0;
for (var i = 0, len = this.inputEls.length; i < len; i++) {
if (this.inputEls[i].getAttribute('disabled') !== null) {
this._toggleClass(this.handleEls[i], 'disabled', true);
disabledCount++;
} else {
this._toggleClass(this.handleEls[i], 'disabled', false);
}
}
this._toggleClass(this.el, 'all-disabled', disabledCount === this.handleEls.length);
},
Create bound versions of event listener callbacks and store them.
Otherwise we can't unbind from these events later because the
function signatures won't match.
_bindEventListenerCallbacks: function() {
this._onTouchStartBound = this._onTouchStart.bind(this);
this._onTouchMoveBound = this._onTouchMove.bind(this);
this._onTouchEndBound = this._onTouchEnd.bind(this);
this._onClickBound = this._onClick.bind(this);
this._onMouseDownBound = this._onMouseDown.bind(this);
this._onMouseMoveBound = this._onMouseMove.bind(this);
this._onMouseUpBound = this._onMouseUp.bind(this);
this._onFocusBound = this._onFocus.bind(this);
this._onKeydownBound = this._onKeydown.bind(this);
this._onBlurBound = this._onBlur.bind(this);
this._onChangeBound = this._onChange.bind(this);
this._onResizeBound = this._onResize.bind(this);
this._onVisibleChildrenBound = this._onVisibleChildren.bind(this);
},
Add event listeners for touchstart and mouse click.
_addEventListeners: function() {
this.controlsEl.addEventListener('touchstart', this._onTouchStartBound);
this.controlsEl.addEventListener('mousedown', this._onMouseDownBound);
for (var i = 0, len = this.inputEls.length; i < len; i++) {
this.inputEls[i].addEventListener('change', this._onChangeBound);
}
for (var j = 0, len2 = this.handleEls.length; j < len2; j++) {
this.handleEls[j].addEventListener('focus', this._onFocusBound);
this.handleEls[j].addEventListener('click', this._onClickBound);
}
document.addEventListener('spark.visible-children', this._onVisibleChildrenBound, true);
},
Remove event listeners for touchstart and mouse click.
_removeEventListeners: function() {
this.controlsEl.removeEventListener('touchstart', this._onTouchStartBound);
this.controlsEl.removeEventListener('mousedown', this._onMouseDownBound);
document.removeEventListener('spark.visible-children', this._onVisibleChildrenBound);
for (var i = 0, len = this.inputEls.length; i < len; i++) {
this.inputEls[i].removeEventListener('change', this._onChangeBound);
}
for (var j = 0, len2 = this.handleEls.length; i < len2; i++) {
this.handleEls[j].removeEventListener('focus', this._onFocusBound);
this.handleEls[j].removeEventListener('click', this._onClickBound);
}
},
Add event listeners for touchmove, touchend, mousemove and mouseup.
We add these to the window so that the user can move off of the element
but keep dragging the slider handle. Otherwise it's really hard to
use the slider unless it's massive.
Option name | Type | Description |
---|---|---|
type | String | Which type of listeners to add |
_addMoveEventListeners: function(type) {
// Only listen for events of the type we asked for.
switch (type) {
case 'mouse':
window.addEventListener('mousemove', this._onMouseMoveBound);
window.addEventListener('mouseout', this._onMouseOutBound);
window.addEventListener('mouseup', this._onMouseUpBound);
break;
case 'touch':
window.addEventListener('touchmove', this._onTouchMoveBound);
window.addEventListener('touchend', this._onTouchEndBound);
break;
case 'keyboard':
window.addEventListener('keydown', this._onKeydownBound);
for (var i = 0, len = this.handleEls.length; i < len; i++) {
this.handleEls[i].addEventListener('blur', this._onBlurBound);
}
break;
}
window.addEventListener('resize', this._onResizeBound);
window.addEventListener('orientationchange', this._onResizeBound);
},
Remove event listeners for move events.
Option name | Type | Description |
---|---|---|
type | String | Which type of listeners to add |
_removeMoveEventListeners: function(type) {
// Only unbind events of the type we asked for.
switch (type) {
case 'mouse':
window.removeEventListener('mousemove', this._onMouseMoveBound);
window.removeEventListener('mouseup', this._onMouseUpBound);
break;
case 'touch':
window.removeEventListener('touchmove', this._onTouchMoveBound);
window.removeEventListener('touchend', this._onTouchEndBound);
break;
case 'keyboard':
window.removeEventListener('keydown', this._onKeydownBound);
for (var i = 0, len = this.handleEls.length; i < len; i++) {
this.handleEls[i].removeEventListener('blur', this._onBlurBound);
}
break;
}
window.removeEventListener('resize', this._onResizeBound);
window.removeEventListener('orientationchange', this._onResizeBound);
},
When the touch starts, start the slider.
Option name | Type | Description |
---|---|---|
e | Object |
_onTouchStart: function(e) {
this.start(this._getNodeListIndex(this.handleEls, e.target), this.isX ? e.touches[0].pageX : e.touches[0].pageY, 'touch');
},
When the window fires a touchmove event, adjust our value accordingly
Option name | Type | Description |
---|---|---|
e | Object |
_onTouchMove: function(e) {
if (!this.isActive[this.currentIndex]) {
return;
}
e.preventDefault();
this.move(this.isX ? e.touches[0].pageX : e.touches[0].pageY);
},
When the window fires a touchend event, stop tracking touches
Option name | Type | Description |
---|---|---|
e | Object |
_onTouchEnd: function(e) {
if (!this.isActive[this.currentIndex]) {
return;
}
e.preventDefault();
this.stop(this._getNodeListIndex(this.handleEls, e.target), 'touch');
},
When the mouse presses down, start the slider.
Option name | Type | Description |
---|---|---|
e | Object |
_onMouseDown: function(e) {
this.start(this._getNodeListIndex(this.handleEls, e.target), this.isX ? e.pageX : e.pageY, 'mouse');
},
When the window fires a mousemove event, adjust our value accordingly
Option name | Type | Description |
---|---|---|
e | Object |
_onMouseMove: function(e) {
if (!this.isActive[this.currentIndex]) {
return;
}
e.preventDefault();
this.move(this.isX ? e.pageX : e.pageY);
},
When the window fires a mouseup event, stop tracking
Option name | Type | Description |
---|---|---|
e | Object |
_onMouseUp: function() {
if (!this.isActive[this.currentIndex]) {
return;
}
this.stop(null, 'mouse');
},
Handle the spark.visible-children event
Option name | Type | Description |
---|---|---|
e | Object |
_onVisibleChildren: function(e) {
if(e.target.contains(this.el)) {
window.setTimeout(function() {
this._onResize();
}.bind(this),0);
}
},
When the window resizes, cache size values for the slider.
Option name | Type | Description |
---|---|---|
e | Object |
_onResize: function() {
this._cacheSize();
this._updateDisabledClasses();
for(var i = 0; i < this.handleEls.length; i++) {
this._updateHandlePosition(i);
}
},
When the element receives focus, start listening for keyboard events
Option name | Type | Description |
---|---|---|
e | Object |
_onFocus: function(e) {
this.start(this._getNodeListIndex(this.handleEls, e.target), null, 'keyboard');
},
When a key is pressed, see if it's a left or right arrow and move the
handle accordingly. If the shift key is pressed, we'll increment and
decrement by bigger values.
Option name | Type | Description |
---|---|---|
e | Object |
_onKeydown: function(e) {
if (this._getNodeListIndex(this.inputEls, e.target) !== -1) {
return;
}
// Left for x or up for y
var increment = (this.isX && e.keyCode === 39) || (!this.isX && e.keyCode === 38);
// Right for x or down for y
var decrement = (this.isX && e.keyCode === 37) || (!this.isX && e.keyCode === 40);
if (increment) {
this.increment(e.shiftKey);
} else if (decrement) {
this.decrement(e.shiftKey);
}
},
When the element loses focus, stop listening for keyboard events
Option name | Type | Description |
---|---|---|
e | Object |
_onBlur: function(e) {
this.stop(this._getNodeListIndex(this.handleEls, e.target), 'keyboard');
},
When the input value changes, set our interal value if it's not already our value.
Option name | Type | Description |
---|---|---|
e | Object |
_onChange: function(e) {
var index = this._getNodeListIndex(this.inputEls, e.target);
this._updateDisabledClasses();
if (e.target.value !== this.values[index]) {
this.setValue(index, e.target.value);
}
(this.onChange || noop)(this, index, this.values[index]);
},
Prevent click events on the button. This way we don't accidentally submit the form.
Option name | Type | Description |
---|---|---|
e | Object |
_onClick: function(e) {
e.preventDefault();
}
};
Base.exportjQuery(RangeSlider, 'RangeSlider');
return RangeSlider;
}));