rangeslider-pure
Version:
Simple, small and fast vanilla JavaScript polyfill for the HTML5 <input type="range"> slider element
644 lines (525 loc) • 19.2 kB
JavaScript
import * as dom from './utils/dom';
import * as func from './utils/functions';
import './range-slider.css';
const newLineAndTabRegexp = new RegExp('/[\\n\\t]/', 'g');
const MAX_SET_BY_DEFAULT = 100;
const HANDLE_RESIZE_DELAY = 300;
const HANDLE_RESIZE_DEBOUNCE = 50;
const pluginName = 'rangeSlider';
const inputrange = dom.supportsRange();
const defaults = {
polyfill: true,
root: document,
rangeClass: 'rangeSlider',
disabledClass: 'rangeSlider--disabled',
fillClass: 'rangeSlider__fill',
bufferClass: 'rangeSlider__buffer',
handleClass: 'rangeSlider__handle',
startEvent: ['mousedown', 'touchstart', 'pointerdown'],
moveEvent: ['mousemove', 'touchmove', 'pointermove'],
endEvent: ['mouseup', 'touchend', 'pointerup'],
min: null,
max: null,
step: null,
value: null,
buffer: null,
stick: null,
borderRadius: 10,
vertical: false
};
let verticalSlidingFixRegistered = false;
/**
* Plugin
* @param {HTMLElement} element
* @param {this} options
*/
export default class RangeSlider {
constructor(element, options) {
let minSetByDefault;
let maxSetByDefault;
let stepSetByDefault;
let stickAttribute;
let stickValues;
RangeSlider.instances.push(this);
this.element = element;
this.options = func.simpleExtend(defaults, options);
this.polyfill = this.options.polyfill;
this.vertical = this.options.vertical;
this.onInit = this.options.onInit;
this.onSlide = this.options.onSlide;
this.onSlideStart = this.options.onSlideStart;
this.onSlideEnd = this.options.onSlideEnd;
this.onSlideEventsCount = -1;
this.isInteractsNow = false;
this.needTriggerEvents = false;
this._addVerticalSlideScrollFix();
// Plugin should only be used as a polyfill
if (!this.polyfill) {
// Input range support?
if (inputrange) {
return;
}
}
this.options.buffer = this.options.buffer || parseFloat(this.element.getAttribute('data-buffer'));
this.identifier = 'js-' + pluginName + '-' + func.uuid();
this.min = func.getFirsNumberLike(
this.options.min,
parseFloat(this.element.getAttribute('min')),
(minSetByDefault = 0)
);
this.max = func.getFirsNumberLike(
this.options.max,
parseFloat(this.element.getAttribute('max')),
(maxSetByDefault = MAX_SET_BY_DEFAULT)
);
this.value = func.getFirsNumberLike(this.options.value, this.element.value,
parseFloat(this.element.value || this.min + (this.max - this.min) / 2));
this.step = func.getFirsNumberLike(this.options.step,
parseFloat(this.element.getAttribute('step')) || (stepSetByDefault = 1));
this.percent = null;
if (func.isArray(this.options.stick) && this.options.stick.length >= 1) {
this.stick = this.options.stick;
} else if ((stickAttribute = this.element.getAttribute('stick'))) {
stickValues = stickAttribute.split(' ');
if (stickValues.length >= 1) {
this.stick = stickValues.map(parseFloat);
}
}
if (this.stick && this.stick.length === 1) {
this.stick.push(this.step * 1.5);
}
this._updatePercentFromValue();
this.toFixed = this._toFixed(this.step);
let directionClass;
this.container = document.createElement('div');
dom.addClass(this.container, this.options.fillClass);
directionClass = this.vertical ? this.options.fillClass + '__vertical' : this.options.fillClass + '__horizontal';
dom.addClass(this.container, directionClass);
this.handle = document.createElement('div');
dom.addClass(this.handle, this.options.handleClass);
directionClass = this.vertical ?
this.options.handleClass + '__vertical' :
this.options.handleClass + '__horizontal';
dom.addClass(this.handle, directionClass);
this.range = document.createElement('div');
dom.addClass(this.range, this.options.rangeClass);
this.range.id = this.identifier;
const elementTitle = element.getAttribute('title');
if (elementTitle && elementTitle.length > 0) {
this.range.setAttribute('title', elementTitle);
}
if (this.options.bufferClass) {
this.buffer = document.createElement('div');
dom.addClass(this.buffer, this.options.bufferClass);
this.range.appendChild(this.buffer);
directionClass = this.vertical ?
this.options.bufferClass + '__vertical' :
this.options.bufferClass + '__horizontal';
dom.addClass(this.buffer, directionClass);
}
this.range.appendChild(this.container);
this.range.appendChild(this.handle);
directionClass = this.vertical ? this.options.rangeClass + '__vertical' : this.options.rangeClass + '__horizontal';
dom.addClass(this.range, directionClass);
if (func.isNumberLike(this.options.value)) {
this._setValue(this.options.value, true);
this.element.value = this.options.value;
}
if (func.isNumberLike(this.options.buffer)) {
this.element.setAttribute('data-buffer', this.options.buffer);
}
if (func.isNumberLike(this.options.min) || minSetByDefault) {
this.element.setAttribute('min', '' + this.min);
}
if (func.isNumberLike(this.options.max) || maxSetByDefault) {
this.element.setAttribute('max', '' + this.max);
}
if (func.isNumberLike(this.options.step) || stepSetByDefault) {
this.element.setAttribute('step', '' + this.step);
}
dom.insertAfter(this.element, this.range);
// hide the input visually
dom.setCss(this.element, {
'position': 'absolute',
'width': '1px',
'height': '1px',
'overflow': 'hidden',
'opacity': '0'
});
// Store context
this._handleDown = this._handleDown.bind(this);
this._handleMove = this._handleMove.bind(this);
this._handleEnd = this._handleEnd.bind(this);
this._startEventListener = this._startEventListener.bind(this);
this._changeEventListener = this._changeEventListener.bind(this);
this._handleResize = this._handleResize.bind(this);
this._init();
// Attach Events
window.addEventListener('resize', this._handleResize, false);
dom.addEventListeners(this.options.root, this.options.startEvent, this._startEventListener);
// Listen to programmatic value changes
this.element.addEventListener('change', this._changeEventListener, false);
}
/**
* A lightweight plugin wrapper around the constructor,preventing against multiple instantiations
* @param {Element} el
* @param {Object} options
*/
static create(el, options) {
const createInstance = (el) => {
let data = el[pluginName];
// Create a new instance.
if (!data) {
data = new RangeSlider(el, options);
el[pluginName] = data;
}
};
if (el.length) {
Array.prototype.slice.call(el).forEach(function (el) {
createInstance(el);
});
} else {
createInstance(el);
}
}
static _touchMoveScrollHandler (event) {
if (RangeSlider.slidingVertically) {
event.preventDefault();
}
}
/* public methods */
/**
* @param {Object} obj like {min : Number, max : Number, value : Number, step : Number, buffer : [String|Number]}
* @param {Boolean} triggerEvents
* @returns {RangeSlider}
*/
update(obj, triggerEvents) {
if (triggerEvents) {
this.needTriggerEvents = true;
}
if (func.isObject(obj)) {
if (func.isNumberLike(obj.min)) {
this.element.setAttribute('min', '' + obj.min);
this.min = obj.min;
}
if (func.isNumberLike(obj.max)) {
this.element.setAttribute('max', '' + obj.max);
this.max = obj.max;
}
if (func.isNumberLike(obj.step)) {
this.element.setAttribute('step', '' + obj.step);
this.step = obj.step;
this.toFixed = this._toFixed(obj.step);
}
if (func.isNumberLike(obj.buffer)) {
this._setBufferPosition(obj.buffer);
}
if (func.isNumberLike(obj.value)) {
this._setValue(obj.value);
}
}
this._update();
this.onSlideEventsCount = 0;
this.needTriggerEvents = false;
return this;
};
destroy() {
dom.removeAllListenersFromEl(this, this.options.root);
window.removeEventListener('resize', this._handleResize, false);
this.element.removeEventListener('change', this._changeEventListener, false);
this.element.style.cssText = '';
delete this.element[pluginName];
// Remove the generated markup
if (this.range) {
this.range.parentNode.removeChild(this.range);
}
RangeSlider.instances = RangeSlider.instances.filter((plugin) => plugin !== this);
if (!RangeSlider.instances.some((plugin) => plugin.vertical)) {
this._removeVerticalSlideScrollFix();
}
}
/* private methods */
_toFixed(step) {
return (step + '').replace('.', '').length - 1;
}
_init() {
if (this.onInit && typeof this.onInit === 'function') {
this.onInit();
}
this._update(false);
}
_updatePercentFromValue() {
this.percent = (this.value - this.min) / (this.max - this.min);
}
/**
* This method check if this.identifier exists in ev.target's ancestors
* @param ev
* @param data
*/
_startEventListener(ev, data) {
const el = ev.target;
let isEventOnSlider = false;
if (ev.which !== 1 && !('touches' in ev)) {
return;
}
dom.forEachAncestors(
el,
el => (isEventOnSlider = el.id === this.identifier && !dom.hasClass(el, this.options.disabledClass)),
true
);
if (isEventOnSlider) {
this._handleDown(ev, data);
}
}
_changeEventListener(ev, data) {
if (data && data.origin === this.identifier) {
return;
}
const value = ev.target.value;
const pos = this._getPositionFromValue(value);
this._setPosition(pos);
}
_update(triggerEvent) {
const sizeProperty = this.vertical ? 'offsetHeight' : 'offsetWidth';
this.handleSize = dom.getDimension(this.handle, sizeProperty);
this.rangeSize = dom.getDimension(this.range, sizeProperty);
this.maxHandleX = this.rangeSize - this.handleSize;
this.grabX = this.handleSize / 2;
this.position = this._getPositionFromValue(this.value);
// Consider disabled state
if (this.element.disabled) {
dom.addClass(this.range, this.options.disabledClass);
} else {
dom.removeClass(this.range, this.options.disabledClass);
}
this._setPosition(this.position);
if (this.options.bufferClass && this.options.buffer) {
this._setBufferPosition(this.options.buffer);
}
this._updatePercentFromValue();
if (triggerEvent !== false) {
dom.triggerEvent(this.element, 'change', { origin: this.identifier });
}
}
_addVerticalSlideScrollFix() {
if (this.vertical && !verticalSlidingFixRegistered) {
document.addEventListener('touchmove', RangeSlider._touchMoveScrollHandler, { passive: false });
verticalSlidingFixRegistered = true;
}
}
_removeVerticalSlideScrollFix() {
document.removeEventListener('touchmove', RangeSlider._touchMoveScrollHandler);
verticalSlidingFixRegistered = false;
}
_handleResize() {
return func.debounce(() => {
// Simulate resizeEnd event.
func.delay(() => {
this._update();
}, HANDLE_RESIZE_DELAY);
}, HANDLE_RESIZE_DEBOUNCE)();
}
_handleDown(e) {
this.isInteractsNow = true;
e.preventDefault();
dom.addEventListeners(this.options.root, this.options.moveEvent, this._handleMove);
dom.addEventListeners(this.options.root, this.options.endEvent, this._handleEnd);
// If we click on the handle don't set the new position
if ((' ' + e.target.className + ' ').replace(newLineAndTabRegexp, ' ').indexOf(this.options.handleClass) > -1) {
return;
}
const boundingClientRect = this.range.getBoundingClientRect();
const posX = this._getRelativePosition(e);
const rangeX = this.vertical ? boundingClientRect.bottom : boundingClientRect.left;
const handleX = this._getPositionFromNode(this.handle) - rangeX;
const position = posX - this.grabX;
this._setPosition(position);
if (posX >= handleX && posX < handleX + this.options.borderRadius * 2) {
this.grabX = posX - handleX;
}
this._updatePercentFromValue();
}
_handleMove(e) {
const posX = this._getRelativePosition(e);
this.isInteractsNow = true;
e.preventDefault();
this._setPosition(posX - this.grabX);
}
_handleEnd(e) {
e.preventDefault();
dom.removeEventListeners(this.options.root, this.options.moveEvent, this._handleMove);
dom.removeEventListeners(this.options.root, this.options.endEvent, this._handleEnd);
// Ok we're done fire the change event
dom.triggerEvent(this.element, 'change', { origin: this.identifier });
if (this.isInteractsNow || this.needTriggerEvents) {
if (this.onSlideEnd && typeof this.onSlideEnd === 'function') {
this.onSlideEnd(this.value, this.percent, this.position);
}
if (this.vertical) {
RangeSlider.slidingVertically = false;
}
}
this.onSlideEventsCount = 0;
this.isInteractsNow = false;
}
_setPosition(pos) {
let position;
let stickRadius;
let restFromValue;
let stickTo;
// Snapping steps
let value = this._getValueFromPosition(func.between(pos, 0, this.maxHandleX));
// Stick to stick[0] in radius stick[1]
if (this.stick) {
stickTo = this.stick[0];
stickRadius = this.stick[1] || 0.1;
restFromValue = value % stickTo;
if (restFromValue < stickRadius) {
value = value - restFromValue;
} else if (Math.abs(stickTo - restFromValue) < stickRadius) {
value = value - restFromValue + stickTo;
}
}
position = this._getPositionFromValue(value);
// Update ui
if (this.vertical) {
this.container.style.height = (position + this.grabX) + 'px';
this.handle.style['webkitTransform'] = 'translateY(-' + position + 'px)';
this.handle.style['msTransform'] = 'translateY(-' + position + 'px)';
this.handle.style.transform = 'translateY(-' + position + 'px)';
} else {
this.container.style.width = (position + this.grabX) + 'px';
this.handle.style['webkitTransform'] = 'translateX(' + position + 'px)';
this.handle.style['msTransform'] = 'translateX(' + position + 'px)';
this.handle.style.transform = 'translateX(' + position + 'px)';
}
this._setValue(value);
// Update globals
this.position = position;
this.value = value;
this._updatePercentFromValue();
if (this.isInteractsNow || this.needTriggerEvents) {
if (this.onSlideStart && typeof this.onSlideStart === 'function' && this.onSlideEventsCount === 0) {
this.onSlideStart(this.value, this.percent, this.position);
}
if (this.onSlide && typeof this.onSlide === 'function') {
this.onSlide(this.value, this.percent, this.position);
}
if (this.vertical) {
RangeSlider.slidingVertically = true;
}
}
this.onSlideEventsCount++;
}
_setBufferPosition(pos) {
let isPercent = true;
if (isFinite(pos)) {
pos = parseFloat(pos);
} else if (func.isString(pos)) {
if (pos.indexOf('px') > 0) {
isPercent = false;
}
pos = parseFloat(pos);
} else {
console.warn('New position must be XXpx or XX%');
return;
}
if (isNaN(pos)) {
console.warn('New position is NaN');
return;
}
if (!this.options.bufferClass) {
console.warn('You disabled buffer, it\'s className is empty');
return;
}
let bufferSize = isPercent ? pos : (pos / this.rangeSize * 100);
if (bufferSize < 0) {
bufferSize = 0;
}
if (bufferSize > 100) {
bufferSize = 100;
}
this.options.buffer = bufferSize;
let paddingSize = this.options.borderRadius / this.rangeSize * 100;
let bufferSizeWithPadding = bufferSize - paddingSize;
if (bufferSizeWithPadding < 0) {
bufferSizeWithPadding = 0;
}
if (this.vertical) {
this.buffer.style.height = bufferSizeWithPadding + '%';
this.buffer.style.bottom = paddingSize * 0.5 + '%';
} else {
this.buffer.style.width = bufferSizeWithPadding + '%';
this.buffer.style.left = paddingSize * 0.5 + '%';
}
this.element.setAttribute('data-buffer', bufferSize);
}
/**
*
* @param {Element} node
* @returns {*} Returns element position relative to the parent
* @private
*/
_getPositionFromNode(node) {
let i = this.vertical ? this.maxHandleX : 0;
while (node !== null) {
i += this.vertical ? node.offsetTop : node.offsetLeft;
node = node.offsetParent;
}
return i;
}
/**
*
* @param {(MouseEvent|TouchEvent)}e
* @returns {number}
*/
_getRelativePosition(e) {
const boundingClientRect = this.range.getBoundingClientRect();
// Get the offset relative to the viewport
const rangeSize = this.vertical ? boundingClientRect.bottom : boundingClientRect.left;
let pageOffset = 0;
const pagePositionProperty = this.vertical ? 'pageY' : 'pageX';
if (typeof e[pagePositionProperty] !== 'undefined') {
pageOffset = (e.touches && e.touches.length) ? e.touches[0][pagePositionProperty] : e[pagePositionProperty];
} else if (typeof e.originalEvent !== 'undefined') {
if (typeof e.originalEvent[pagePositionProperty] !== 'undefined') {
pageOffset = e.originalEvent[pagePositionProperty];
} else if (e.originalEvent.touches && e.originalEvent.touches[0] &&
typeof e.originalEvent.touches[0][pagePositionProperty] !== 'undefined') {
pageOffset = e.originalEvent.touches[0][pagePositionProperty];
}
} else if (e.touches && e.touches[0] && typeof e.touches[0][pagePositionProperty] !== 'undefined') {
pageOffset = e.touches[0][pagePositionProperty];
} else if (e.currentPoint && (typeof e.currentPoint.x !== 'undefined' || typeof e.currentPoint.y !== 'undefined')) {
pageOffset = this.vertical ? e.currentPoint.y : e.currentPoint.x;
}
if (this.vertical) {
pageOffset -= window.pageYOffset;
}
return this.vertical ? rangeSize - pageOffset : pageOffset - rangeSize;
}
_getPositionFromValue(value) {
const percentage = (value - this.min) / (this.max - this.min);
const pos = percentage * this.maxHandleX;
return isNaN(pos) ? 0 : pos;
}
_getValueFromPosition(pos) {
const percentage = ((pos) / (this.maxHandleX || 1));
const value = this.step * Math.round(percentage * (this.max - this.min) / this.step) + this.min;
return Number((value).toFixed(this.toFixed));
}
_setValue(value, force) {
if (value === this.value && !force) {
return;
}
// Set the new value and fire the `input` event
this.element.value = value;
this.value = value;
dom.triggerEvent(this.element, 'input', { origin: this.identifier });
}
}
RangeSlider.version = VERSION;
RangeSlider.dom = dom;
RangeSlider.functions = func;
RangeSlider.instances = [];
RangeSlider.slidingVertically = false;