Source: ui/slider/Slider.js

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 == "&gt;") || (operator == "≥")) {
                            this.bar_.style.left = this.handles_.left.style.left;
                            this.bar_.style.width = end - start + "px";
                        } else if ((operator == "&lt;") || (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_();
    }
}