omni-slider
Version:
A multirange vanilla js slider
508 lines (420 loc) • 17 kB
JavaScript
/* Constructor function
* elementContainer - this acts as a wrapper for the slider, all of the sliders DOM elements will be transcluded inside the <element> provided
* options - contains the options for the slider
*/
class Slider {
constructor(elementContainer = {}, options = {}) {
// Validation of element, the only required argument
if (!elementContainer || (elementContainer.nodeName !== 'DIV' && elementContainer.tagName !== 'DIV')) return;
// Contains the options for this slider
this.options = {
isOneWay: null,
isDate: null,
overlap: null,
callbackFunction: null,
min: null,
max: null,
start: null,
end: null,
};
// Custom Logic for 1 way
let oneWay = false;
if (options.isOneWay) {
options.isOneWay = false;
options.overlap = true;
if (options.start && !options.end) {
options.end = options.start;
}
options.start = null;
oneWay = true;
}
// Handles this.options creation and options initialization
this.init(options);
// Contain pub/sub listeners
this.topics = {
start: [],
moving: [],
stop: [],
};
// Contains the DOM elements for the slider
this.UI = {
slider: null,
handleLeft: null,
handleRight: null,
fill: null,
};
// Slider element
const sliderElem = document.createElement('div');
if (oneWay) {
sliderElem.className = 'slider one-way';
} else {
sliderElem.className = 'slider';
}
this.UI.slider = sliderElem;
// Left handle
const handleLeft = document.createElement('div');
handleLeft.className = 'handle handle-left';
const circleLeft = document.createElement('div');
circleLeft.className = 'slider-circle';
handleLeft.appendChild(circleLeft);
this.UI.handleLeft = handleLeft;
this.UI.slider.appendChild(this.UI.handleLeft);
// Right handle
const handleRight = document.createElement('div');
handleRight.className = 'handle handle-right';
const circleRight = document.createElement('div');
circleRight.className = 'slider-circle';
handleRight.appendChild(circleRight);
this.UI.handleRight = handleRight;
this.UI.slider.appendChild(this.UI.handleRight);
// Fill element
const fill = document.createElement('div');
fill.className = 'slider-fill';
this.UI.fill = fill;
this.UI.slider.appendChild(this.UI.fill);
elementContainer.appendChild(this.UI.slider);
// Move handles to have it's center as the end pointer point
this.UI.handleLeft.style.marginLeft = '-' + (handleLeft.offsetWidth / 2) + 'px';
this.UI.handleRight.style.marginRight = '-' + (handleRight.offsetWidth / 2) + 'px';
// Push elements to starting positions
const data = {
left: this.options.start,
right: this.options.end,
};
this.move.bind(this)(data, true);
// Bind events to start listening
this.startingHandler = this.starting.bind(this);
this.UI.handleLeft.onmousedown = this.startingHandler;
this.UI.handleLeft.ontouchstart = this.startingHandler;
this.UI.handleRight.onmousedown = this.startingHandler;
this.UI.handleRight.ontouchstart = this.startingHandler;
}
/* Default config
* isOneWay (Boolean denotes if slider only has one handle)
* isDate (Boolean denotes if returning a moment wrapped value)
* overlap (Boolean denotes if handles will overlap or just sit next to each other)
* min and max (isDate ? typeof String [yyyy-mm-ddThh:mm] : typeof Number) - bounds
* start and end (isDate ? typeof String [yyyy-mm-ddThh:mm] : typeof Number) - starting position
*/
get defaultOptions() {
return {
isOneWay: false,
isDate: false,
overlap: false,
callbackFunction: null,
min: 0,
max: 100,
};
}
/* Helper method (replace with shared function from library) */
extend(defaults = {}, options = {}) {
const extended = {};
let prop;
for (prop in defaults) {
if (Object.prototype.hasOwnProperty.call(defaults, prop)) {
extended[prop] = defaults[prop];
}
}
for (prop in options) {
if (Object.prototype.hasOwnProperty.call(options, prop)) {
extended[prop] = options[prop];
}
}
return extended;
}
// Initialize options and browser sniffing
init(options = {}) {
// Extend default options
if (typeof options === 'object') {
this.options = this.extend(this.defaultOptions, options);
} else {
this.options = this.defaultOptions;
}
// Default start/end
this.options.start = this.options.start || this.options.min;
this.options.end = this.options.end || this.options.max;
// Handle currency vs date type sanitization
if (this.options.isDate) {
this.options.max = new Date(this.options.max).valueOf();
this.options.min = new Date(this.options.min).valueOf();
// Check if max and min are proper
if (this.options.max < this.options.min) {
this.options.min = this.options.max;
}
// Check if start and end are within bounds
if (typeof this.options.start !== 'undefined' &&
typeof this.options.end !== 'undefined' &&
this.options.start <= this.options.end &&
new Date(this.options.start).valueOf() >= this.options.min &&
new Date(this.options.end).valueOf() <= this.options.max) {
this.options.start = new Date(this.options.start).valueOf();
this.options.end = new Date(this.options.end).valueOf();
} else {
this.options.start = new Date(this.options.min).valueOf();
this.options.end = new Date(this.options.max).valueOf();
}
} else {
this.options.max = parseFloat(this.options.max);
this.options.min = parseFloat(this.options.min);
// Check if max and min are proper
if (this.options.max < this.options.min) {
this.options.min = this.options.max;
}
// Check if start and end are within bounds
if (typeof this.options.start !== 'undefined' &&
typeof this.options.end !== 'undefined' &&
this.options.start <= this.options.end &&
this.options.start >= this.options.min &&
this.options.end <= this.options.max) {
this.options.start = parseFloat(this.options.start);
this.options.end = parseFloat(this.options.end);
} else {
this.options.start = this.options.min;
this.options.end = this.options.max;
}
}
}
/* Provide information about the slider value
* Returns an Object with property left and right denoting left and right values */
getInfo() {
let info = {};
const total = this.options.max - this.options.min;
const left = this.UI.fill.style.left ? parseFloat(this.UI.fill.style.left.replace('%', '')) : 0;
const right = this.UI.fill.style.right ? parseFloat(this.UI.fill.style.right.replace('%', '')) : 0;
if (this.options.isDate) {
info = {
left: new Date(this.options.min + (left / 100) * total),
right: new Date(this.options.max - (right / 100) * total),
};
} else {
info = {
left: this.options.min + (left / 100) * total,
right: this.options.max - (right / 100) * total,
};
}
if (typeof this.options.callbackFunction === 'function') {
info.left = this._applyCallback_(info.left, this.options.callbackFunction);
info.right = this._applyCallback_(info.right, this.options.callbackFunction);
}
return info;
}
/*
* Apply call back using data provided
**/
_applyCallback_(data = null, callback = null) {
try {
if (!callback) return null;
return callback.call(undefined, data);
} catch (error) {
throw error;
}
}
/* When handle is pressed
* Attach all the necessary event handlers */
starting(event = null) {
if (!event) return;
// Exit if disabled
if (this.isDisabled) return;
let x = 0;
let y = 0;
// Initialize drag object
this.dragObj = {};
// Get handle element node not the child nodes
// If this is a child of the parent try to find the handle element
this.dragObj.elNode = event.target;
while (!this.dragObj.elNode.classList.contains('handle')) {
this.dragObj.elNode = this.dragObj.elNode.parentNode;
}
// Direction where the slider control is going
this.dragObj.dir = this.dragObj.elNode.classList.contains('handle-left') ? 'left' : 'right';
// Get cursor position wrt the page
// If touch screen (event.touches) and if IE11 (pageXOffset)
x = (typeof event.clientX !== 'undefined' ? event.clientX :
event.touches[0].pageX) + (window.scrollX || window.pageXOffset);
y = (typeof event.clientY !== 'undefined' ? event.clientY :
event.touches[0].pageY) + (window.scrollY || window.pageYOffset);
// Save starting positions of cursor and element
this.dragObj.cursorStartX = x;
this.dragObj.cursorStartY = y;
this.dragObj.elStartLeft = parseFloat(this.dragObj.elNode.style.left);
this.dragObj.elStartRight = parseFloat(this.dragObj.elNode.style.right);
if (isNaN(this.dragObj.elStartLeft)) this.dragObj.elStartLeft = 0;
if (isNaN(this.dragObj.elStartRight)) this.dragObj.elStartRight = 0;
// Update element's positioning for z-index
// The element last moved will have a higher positioning
this.UI.handleLeft.classList.remove('ontop');
this.UI.handleRight.classList.remove('ontop');
this.dragObj.elNode.classList.add('ontop');
// Capture mousemove and mouseup events on the page
this.movingHandler = this.moving.bind(this);
this.stopHandler = this.stop.bind(this);
document.addEventListener('mousemove', this.movingHandler, true);
document.addEventListener('mouseup', this.stopHandler, true);
document.addEventListener('touchmove', this.movingHandler, true);
document.addEventListener('touchend', this.stopHandler, true);
// Stop default events
this.stopDefault.bind(this)(event);
this.UI.fill.classList.remove('slider-transition');
this.UI.handleLeft.classList.remove('slider-transition');
this.UI.handleRight.classList.remove('slider-transition');
// Pub/sub lifecycle - start
this.publish('start', this.getInfo());
}
/* When handle is being moved */
moving(event) {
// Get cursor position with respect to the page
const x = (typeof event.clientX !== 'undefined' ? event.clientX :
event.touches[0].pageX) + (window.scrollX || window.pageXOffset);
// Move drag element by the same amount the cursor has moved
const sliderWidth = this.UI.slider.offsetWidth;
let calculatedVal = 0;
if (this.dragObj.dir === 'left') {
calculatedVal = this.dragObj.elStartLeft + ((x - this.dragObj.cursorStartX) / sliderWidth * 100);
} else if (this.dragObj.dir === 'right') {
calculatedVal = this.dragObj.elStartRight + ((this.dragObj.cursorStartX - x) / sliderWidth * 100);
}
// Keep handles within range
if (calculatedVal < 0) {
calculatedVal = 0;
} else if (calculatedVal > 100) {
calculatedVal = 100;
}
// Sanitize to work for both directions
// Since we are adding to left and right there should not be a negative number
calculatedVal = Math.abs(calculatedVal);
// Take into account the handle when calculating space left
let handleOffset = 0;
if (!this.options.overlap) {
handleOffset = (this.UI.handleRight.offsetWidth / this.UI.slider.offsetWidth) * 100;
}
// Add movement based on handle direction
let remaining = 0;
if (this.dragObj.dir === 'left') {
remaining = (100 - handleOffset) - this.UI.fill.style.right.replace('%', '');
if (remaining <= calculatedVal) {
calculatedVal = remaining;
}
this.dragObj.elNode.style.left = calculatedVal + '%';
this.UI.fill.style.left = calculatedVal + '%';
} else {
remaining = (100 - handleOffset) - this.UI.fill.style.left.replace('%', '');
if (remaining <= calculatedVal) {
calculatedVal = remaining;
}
this.dragObj.elNode.style.right = calculatedVal + '%';
this.UI.fill.style.right = calculatedVal + '%';
}
// Stop default events
this.stopDefault.bind(this)(event);
// Pub/sub lifecycle - moving
this.publish('moving', this.getInfo());
}
/* When handle is blured - do clean up */
stop(event) {
// Stop capturing mousemove and mouseup events
document.removeEventListener('mousemove', this.movingHandler, true);
document.removeEventListener('mouseup', this.stopHandler, true);
document.removeEventListener('touchmove', this.movingHandler, true);
document.removeEventListener('touchend', this.stopHandler, true);
// Stop default events
this.stopDefault.bind(this)(event);
// Pub/sub lifecycle - stop
this.publish('stop', this.getInfo());
}
/* Push elements to position based on data */
move(data, preventPublish) {
let importedData = data;
// Transition effects (cleaned up at Slider.prototype.starting);
this.UI.fill.classList.add('slider-transition');
this.UI.handleLeft.classList.add('slider-transition');
this.UI.handleRight.classList.add('slider-transition');
const total = this.options.max - this.options.min;
if (typeof importedData === 'object') {
if (importedData.left) {
if (importedData.left < this.options.min) importedData.left = this.options.min;
if (importedData.left > this.options.max) importedData.left = this.options.max;
const posLeft = (importedData.left - this.options.min) / total * 100;
this.UI.handleLeft.style.left = posLeft + '%';
this.UI.fill.style.left = posLeft + '%';
}
if (importedData.right) {
if (importedData.right < this.options.min) importedData.right = this.options.min;
if (importedData.right > this.options.max) importedData.right = this.options.max;
const posRight = (this.options.max - importedData.right) / total * 100;
this.UI.handleRight.style.right = posRight + '%';
this.UI.fill.style.right = posRight + '%';
}
// If overlap is not enabled then check if the starting positions are overlapping - reset to full
if (!this.options.overlap && this.UI.handleLeft.offsetLeft + this.UI.handleLeft.offsetWidth >
this.UI.handleRight.offsetLeft - 1) {
this.UI.fill.style.left = '0%';
this.UI.fill.style.right = '0%';
this.UI.handleLeft.style.left = '0%';
this.UI.handleRight.style.right = '0%';
}
} else if (!isNaN(importedData)) {
if (importedData < this.options.min) importedData = this.options.min;
if (importedData > this.options.max) importedData = this.options.max;
const pos = (importedData - this.options.min) / total * 100;
this.UI.handleLeft.style.left = pos + '%';
this.UI.fill.style.left = '0%';
this.UI.fill.style.right = (100 - pos) + '%';
}
// Pub/sub lifecycle - moving
if (!preventPublish) {
this.publish('moving', this.getInfo());
}
}
/* Utility function to stop default events */
stopDefault(event = null) {
if (!event) return;
event.preventDefault();
}
/* Accessor for disable property */
disable(boolean) {
this.isDisabled = boolean;
if (this.isDisabled) {
this.UI.slider.classList.add('slider-disabled');
} else {
this.UI.slider.classList.remove('slider-disabled');
}
}
/* Subscribe hook
* Topic - keyword (start, moving, end)
* Listener - function that will be called when topic is fired with argument of getInfo() data
*/
subscribe(topic = null, listener = null) {
if (!topic || !listener) return {};
// Check validity of topic and listener
if (!this.topics.hasOwnProperty.call(this.topics, topic) ||
typeof topic !== 'string' ||
typeof listener !== 'function') return {};
// Add the listener to queue
// Retrieve the index for deletion
const index = this.topics[topic].push(listener) - 1;
// Return instance of the subscription for deletion
return {
remove: (function() {
delete this.topics[topic][index];
}).bind(this),
};
}
/* Publish hook
* Topic - keyword (start, moving, end)
* Data - getInfo() result to pass into the listener
*/
publish(topic = null, data = null) {
if (!topic || !data) return;
// Check validity of topic
if (!this.topics.hasOwnProperty.call(this.topics, topic) || typeof topic !== 'string') return;
// Cycle through events in the queue and fire them with the slider data
this.topics[topic].forEach(function(event) {
event(data);
});
}
}
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
module.exports = Slider;
} else {
window.Slider = Slider;
}