import { UiElement } from "../UiElement.js";
import { isFunction } from "../../utils/utils.js";
import "./Slider.css";
export { Slider };
/**
* A Generic numeric slider Element.
*
* Events:
* * change
* * stop
* * slide
*
* @author rkoppe <roland.koppe@awi.de>
* @author rhess <robin.hess@awi.de>
* @author sjaswal <shahzeib.jaswal@awi.de>
* @memberof vef.ui.slider
*/
class Slider extends UiElement {
/**
* <pre><code>
* options = {
* handles: 1 // amount of handles (1 or 2)
* min: 0 // minimum slider value
* max: 1 // maximums slider values
* value: 0 // initial numeric slider value or an Object
* // containing the properties "left" and "right"
* collision: "stop" // collision mode of handles
* // stop - handles collide and stop (default)
* // push - moving a handle pushes the other one
* // none - no collision
* orientation: "horizontal" // "horizontal" or "vertical"
* // event listeners
* stop: function
* change: function
* slide: function
* }
* <code><pre>
*
* @param {string | HTMLElement} target target DOM id or HTMLElement
* @param {object} options
*/
constructor(target, options) {
// call parent contructor
super(target, {
'slide': [],
'change': [],
'stop': [],
"arrow_right": [],
"arrow_left": [],
"ArrowRight_Shift": [],
"ArrowLeft_Shift": [],
"ArrowRight_Shift_cntrl": [],
"ArrowLeft_Shift_cntrl": [],
"home": [],
"end": [],
"arrow_up": [],
"arrow_down": []
});
const defaultOptions = {
handles: 1,
min: 0,
max: 1,
step: 0.1,
collision: "stop",
orientation: "horizontal"
};
this.handles_ = {};
this.bar_ = null;
this.options_ = Object.assign(defaultOptions, options);
// assign event listeners
if (isFunction(options.stop)) this.on("stop", options.stop);
if (isFunction(options.change)) this.on("change", options.change);
if (isFunction(options.slide)) this.on("slide", options.slide);
this.validate_();
this.createElement_();
this.setValue(this.options_.value);
// update slider scale when the window gets resized
const resizeObserver = new ResizeObserver(() => this.updateHandles_());
resizeObserver.observe(this.getElement());
}
/**
* Validates options and throws Error for invalid inputs.
*
* @private
*/
validate_() {
const handles = this.options_.handles;
const min = this.options_.min;
const max = this.options_.max;
const value = this.options_.value;
// validate handles
if (![1, 2].includes(handles)) {
throw new Error(`invalid value ${handles} for options.handles`);
}
// validate min / max
if (typeof min !== "number") {
throw new Error('min is not a number');
}
if (typeof max !== "number") {
throw new Error('max is not a number');
}
if (min >= max) {
throw new Error('min is not smaller than max');
}
// validate collision mode
if (!["stop", "push", "none"].includes(this.options_.collision)) {
throw new Error('Invalid collision mode: ' + this.options_.collision)
}
// validate orientation
if (!["horizontal", "vertical"].includes(this.options_.orientation)) {
throw new Error('Invalid orientation: ' + this.options_.orientation)
}
// validate values
if (handles === 1) {
if (value === undefined) {
this.options_.value = min;
} else if (typeof value !== "number") {
throw new Error('value is not a number');
} else {
this.options_.value = this.setToRange_(value, min, max);
}
} else {
if (value === undefined) {
this.options_.value = {
left: min,
right: max
};
} else if ((typeof value !== 'object')) {
throw new Error('value is not an object');
} else if (typeof value.left !== "number") {
throw new Error('value.left is not a number');
} else if (typeof value.right !== "number") {
throw new Error('value.right is not a number');
} else {
this.options_.value.left = this.setToRange_(value.left, min, max);
this.options_.value.right = this.setToRange_(value.right, min, max);
}
}
}
/**
* Create HTML DOM content of the slider
*
* @private
*/
createElement_() {
const element = this.getElement();
element.classList.add('slider');
element.classList.add(this.options_.orientation);
this.slider_ = document.createElement("div");
this.slider_.classList.add("slider-inner");
element.appendChild(this.slider_);
this.initHandles_();
}
/**
* Creates and returns a handle for the slider.
*
* @param {string} name
*
* @private
*/
_createHandle(name) {
const handle = document.createElement('div');
handle.classList.add('handle');
handle.setAttribute("tabindex", "0");
const horizontal = this.options_.orientation == "horizontal";
let down, width, startPosition, handleWidth, maxPosition, clickOffset, valueExtent;
const slideMove = (event) => {
if (!down) return;
let clientPos;
if (event.type == "mousemove") clientPos = horizontal ? (event.clientX - clickOffset) : (event.clientY - clickOffset);
else clientPos = horizontal ? (event.touches[0].clientX - clickOffset) : (event.touches[0].clientY - clickOffset);
let position = clientPos - startPosition;
if (clientPos < startPosition) {
position = 0;
} else if (clientPos > startPosition + maxPosition) {
position = maxPosition;
}
//1-year 31556926000;
let value = valueExtent * (position / maxPosition);
if (Number.isFinite(this.options_.step) && (this.options_.step > 0)) {
const result = value / this.options_.step;
value = this.options_.min + Math.round(result) * this.options_.step;
position = ((value - this.options_.min) / valueExtent) * maxPosition;
}
if (this.options_.handles === 1) {
this.options_.value = value;
} else {
// check collision modes
if (this.options_.collision == "stop") {
if (name == "left" && (value >= this.options_.value.right)) {
value = this.options_.value.right;
position = ((value - this.options_.min) / valueExtent) * maxPosition;
} else if (name == "right" && (value <= this.options_.value.left)) {
value = this.options_.value.left;
position = ((value - this.options_.min) / valueExtent) * maxPosition;
}
} else if (this.options_.collision == "push") {
if (name == "left" && (value >= this.options_.value.right)) {
this.options_.value.right = value;
this.handles_.right.style.left = position + "px";
} else if (name == "right" && (value <= this.options_.value.left)) {
this.options_.value.left = value;
this.handles_.left.style.left = position + "px";
}
}
this.options_.value[name] = value;
}
if (horizontal) {
handle.style.left = position + 'px';
} else {
handle.style.top = position + 'px';
}
this.updateBar_();
this.slide_();
};
const slideStart = (event) => {
if (event.type == "mousedown") event.preventDefault();
const sliderRect = this.slider_.getBoundingClientRect();
const handleRect = handle.getBoundingClientRect();
down = true;
width = horizontal ? this.slider_.offsetWidth : this.slider_.offsetHeight;
startPosition = horizontal ? sliderRect.x : sliderRect.y;
handleWidth = horizontal ? handle.offsetWidth : handle.offsetHeight;
maxPosition = width - handleWidth;
valueExtent = this.options_.max - this.options_.min;
if (event.type == "mousedown") {
clickOffset = horizontal ? (event.clientX - handleRect.left) : (event.clientY - handleRect.top);
document.addEventListener('mouseup', slideEnd);
document.addEventListener('mousemove', slideMove);
} else {
clickOffset = horizontal ? (event.touches[0].clientX - handleRect.left) : (event.touches[0].clientY - handleRect.top);
document.addEventListener('touchend', slideEnd);
document.addEventListener('touchmove', slideMove);
}
// move handle to front
this.slider_.appendChild(handle);
};
const slideEnd = (event) => {
down = false;
if (event.type == "mouseup") {
document.removeEventListener('mousemove', slideMove);
document.removeEventListener('mouseup', slideEnd);
} else {
document.removeEventListener('touchmove', slideMove);
document.removeEventListener('touchend', slideEnd);
}
handle.focus();
this.stop_();
};
handle.addEventListener('touchstart', slideStart, { passive: true });
handle.addEventListener('mousedown', slideStart);
handle.addEventListener('keydown', (e) => {
if ((e.ctrlKey && e.shiftKey && e.code == "ArrowRight")) {
e.preventDefault();
this.fire("ArrowRight_Shift_cntrl", name);
} else if ((e.ctrlKey && e.shiftKey && e.code == "ArrowLeft")) {
e.preventDefault();
this.fire("ArrowLeft_Shift_cntrl", name);
} else if ((e.code == "Home")) {
e.preventDefault();
this.fire("home", name);
} else if ((e.shiftKey && e.code == "ArrowRight")) {
e.preventDefault();
this.fire("ArrowRight_Shift", name);
} else if ((e.shiftKey && e.code == "ArrowLeft")) {
e.preventDefault();
this.fire("ArrowLeft_Shift", name);
} else if ((e.code == "End")) {
e.preventDefault();
this.fire("end", name);
} else if (e.code == "ArrowLeft") {
e.preventDefault();
this.fire("arrow_left", name);
} else if ((e.code == "ArrowRight")) {
e.preventDefault();
this.fire("arrow_right", name);
}
});
return handle;
}
/**
* Creates and returns the draggable bar between two handles
*
* @private
*/
_createBar() {
const bar = document.createElement('div');
bar.classList.add('bar');
const horizontal = this.options_.orientation == "horizontal";
let down, width, startPosition, barWidth, handleWidth, maxPosition, clickOffset, valueExtent;
const slideMove = (event) => {
if (!down || maxPosition <= 0) return;
let clientPos;
if (event.type == "mousemove") clientPos = horizontal ? (event.clientX - clickOffset) : (event.clientY - clickOffset);
else clientPos = horizontal ? (event.touches[0].clientX - clickOffset) : (event.touches[0].clientY - clickOffset);
let position = clientPos - startPosition;
if (clientPos < startPosition) {
position = 0;
} else if (clientPos > (startPosition + maxPosition)) {
position = maxPosition;
}
// calculate slider positions and values
let positionRight = position + barWidth - handleWidth;
this.options_.value.right = this.options_.min + (valueExtent * (positionRight / (width - handleWidth)));
this.options_.value.left = this.options_.min + (valueExtent * (position / (width - handleWidth)));
if (horizontal) {
bar.style.left = position + "px";
this.handles_.left.style.left = position + "px";
this.handles_.right.style.left = positionRight + "px";
} else {
bar.style.top = position + "px";
this.handles_.left.style.top = position + "px";
this.handles_.right.style.top = positionRight + "px";
}
this.slide_();
};
const slideStart = (event) => {
if (event.type == "mousedown") event.preventDefault();
const sliderRect = this.slider_.getBoundingClientRect();
const barRect = bar.getBoundingClientRect();
down = true;
width = horizontal ? this.slider_.offsetWidth : this.slider_.offsetHeight;
startPosition = horizontal ? sliderRect.x : sliderRect.y;
barWidth = horizontal ? bar.offsetWidth : bar.offsetHeight;
handleWidth = horizontal ? this.handles_.left.offsetWidth : this.handles_.left.offsetHeight;
maxPosition = width - barWidth;
valueExtent = this.options_.max - this.options_.min;
if (event.type == "mousedown") {
clickOffset = horizontal ? (event.clientX - barRect.left) : (event.clientY - barRect.top);
document.addEventListener('mouseup', slideEnd);
document.addEventListener('mousemove', slideMove);
} else {
clickOffset = horizontal ? (event.touches[0].clientX - barRect.left) : (event.touches[0].clientY - barRect.top);
document.addEventListener('touchend', slideEnd);
document.addEventListener('touchmove', slideMove);
}
};
const slideEnd = (event) => {
down = false;
if (event.type == "mouseup") {
document.removeEventListener('mousemove', slideMove);
document.removeEventListener('mouseup', slideEnd);
} else {
document.removeEventListener('touchmove', slideMove);
document.removeEventListener('touchend', slideEnd);
}
this.stop_();
};
bar.addEventListener('touchstart', slideStart, { passive: true });
bar.addEventListener('mousedown', slideStart);
return bar;
}
/**
* Validates the given value and return the given value or
* min or max if range is raised.
*
* @param {number} given
* @param {number} min
* @param {number} max
*
* @private
*/
setToRange_(given, min, max) {
if (given < min) {
return min;
} else if (given > max) {
return max;
} else {
return given;
}
}
/**
* Update the colored bar between the two slider handles
* @private
*/
updateBar_() {
const horizontal = this.options_.orientation == "horizontal";
if (this.bar_) {
let start = horizontal ? this.handles_.left.style.left : this.handles_.left.style.top;
start = Number.parseInt(start.substring(0, start.length - 2));
if (this.handles_.right) {
let end = horizontal ? this.handles_.right.style.left : this.handles_.right.style.top;
end = Number.parseInt(end.substring(0, end.length - 2));
end += horizontal ? this.handles_.right.offsetWidth : this.handles_.right.offsetHeight;
if (horizontal) {
this.bar_.style.left = this.handles_.left.style.left;
this.bar_.style.width = end - start + "px";
} else {
this.bar_.style.top = this.handles_.left.style.top;
this.bar_.style.height = end - start + "px";
}
} else {
let end = horizontal ? this.slider_.offsetWidth : this.slider_.offsetTop;
if (horizontal) {
const parent = this.getElement().parentElement;
if (parent) {
const dropDownTitle = parent.querySelector(".drop-down-title");
const operator = (dropDownTitle != null) ? dropDownTitle.innerHTML : null;
// check active operator
// highlights bar range
if ((operator == ">") || (operator == "≥")) {
this.bar_.style.left = this.handles_.left.style.left;
this.bar_.style.width = end - start + "px";
} else if ((operator == "<") || (operator == "≤")) {
this.bar_.style.left = this.slider_.offsetLeft;
this.bar_.style.width = this.handles_.left.style.left;
}
}
} else {
// to be checked and done
this.bar_.style.top = this.handles_.left.style.top;
this.bar_.style.height = end - start + "px";
}
}
}
}
/**
* Initializes the handle based on the handle options
* @private
*/
initHandles_() {
this.handles_.left = this._createHandle('left');
this.bar_ = this._createBar();
this.slider_.appendChild(this.bar_);
this.slider_.appendChild(this.handles_.left);
if (this.options_.handles === 2) {
this.handles_.right = this._createHandle('right');
this.slider_.appendChild(this.handles_.right);
}
}
/**
* Update the handle positions based on the values
* @private
*/
updateHandles_() {
const horizontal = this.options_.orientation == "horizontal";
const rangeSize = horizontal ? this.slider_.offsetWidth : this.slider_.offsetHeight;
const valueRange = this.options_.max - this.options_.min;
const valueToPosition = (val, handle) => {
const handleSize = horizontal ? handle.offsetWidth : handle.offsetHeight;
const pixelRange = rangeSize - handleSize;
const position = pixelRange * ((val - this.options_.min) / valueRange);
if (horizontal) {
handle.style.left = position + "px";
} else {
handle.style.top = position + "px";
}
};
const val1 = (this.options_.handles === 1) ? this.options_.value : this.options_.value.left;
valueToPosition(val1, this.handles_.left);
if (this.options_.handles === 2) {
valueToPosition(this.options_.value.right, this.handles_.right);
}
this.updateBar_();
}
/**
* Internal slide event.
* @private
*/
slide_() {
// using getValue for allowing override
this.fire('slide', this.getValue());
this.change_();
}
/**
* Internal stop event.
* @private
*/
stop_() {
// using getValue for allowing override
this.fire('stop', this.getValue());
}
/**
* Internal change event.
* @private
*/
change_() {
// using getValue for allowing override
this.fire('change', this.getValue());
}
/**
* Update the current value of the slider handle.
* For sliders with two handles {@code value} has to be an
* Object with the properties {@code left} and {@code right}
*
* @param {number | object} value
*/
setValue(value) {
this.options_.value = value;
this.validate_();
this.updateHandles_();
this.change_();
}
/**
* Return the current value of the slider handle.
* returns an Object with properties "left" and "right"
* if the slider has two handles
*/
getValue() {
// return a new object to prevent changing values from outside
const value = this.options_.value;
if (this.options_.handles === 1) {
return value;
} else {
return {
left: value.left,
right: value.right
};
}
}
/**
* Update the min max of the slider
*
* @param {number} min
* @param {number} max
*/
setMinMax(min, max) {
this.options_.min = min;
this.options_.max = max;
this.validate_();
this.updateHandles_();
this.change_();
}
/**
* Set the number of handles and reinitialize the handle
* @param {*} num
*/
setHandles(num) {
// remove previous handles
let elements = this.slider_.querySelectorAll(".handle");
const removeElements = (elms) => elms.forEach(el => el.remove());
removeElements(elements);
this.options_.handles = num;
if (num === 1) {
this.options_.value = this.options_.value.left;
}
this.validate_();
// add new handles
this.initHandles_();
this.change_();
}
}