widget/range/range-widget.js

import Widget from '../../js/widget';
import { isNumber } from '../../js/utils';
import events from '../../js/event';

/**
 * @augments Widget
 */
class RangeWidget extends Widget {
    /**
     * @type {string}
     */
    static get selector() {
        return '.or-appearance-distress input[type="number"], .question:not(.or-appearance-analog-scale):not(.or-appearance-rating) > input[type="number"][min][max][step]';
    }

    _init() {
        const that = this;

        const fragment = document.createRange().createContextualFragment( this._getHtmlStr() );
        fragment.querySelector( '.range-widget__scale__end' ).before( this.resetButtonHtml );
        fragment.querySelector( '.range-widget__scale__start' ).textContent = this.props.min;
        fragment.querySelector( '.range-widget__scale__end' ).textContent = this.props.max;

        this.element.after( fragment );
        this.element.classList.add( 'hide' );
        this.element.addEventListener( 'applyfocus', () => {
            this.range.focus();
        } );

        this.widget = this.question.querySelector( '.widget' );
        this.range = this.widget.querySelector( 'input' );
        this.current = this.widget.querySelector( '.range-widget__current' );

        if ( this.props.readonly ) {
            this.disable();
        }

        this.range.addEventListener( 'change', () => {
            this.current.textContent = this.value;
            this._updateMercury( ( this.value - this.props.min ) / ( that.props.max - that.props.min ) );

            // Avoid unnecessary change events on original input as these can have big negative consequences
            // https://github.com/OpenClinica/enketo-express-oc/issues/209
            if ( this.originalInputValue !== this.value ) {
                this.originalInputValue = this.value;
            }
        } );

        // Do not use change handler for this because this doesn't fire if the user clicks on the internal DEFAULT
        // value of the range input.
        this.widget.querySelector( 'input.empty' ).addEventListener( 'click', () => {
            this.range.classList.remove( 'empty' );
            this.range.dispatchEvent( events.Change() );
        } );
        this.widget.querySelector( 'input.empty' ).addEventListener( 'touchstart', () => {
            this.range.classList.remove( 'empty' );
            this.range.dispatchEvent( events.Change() );
        } );

        this.widget.querySelector( '.btn-reset' ).addEventListener( 'click', this._reset.bind( this ) );

        // Loads the default value if exists, else resets
        this.update();

        let ticks = this.props.ticks ? Math.ceil( Math.abs( ( this.props.max - this.props.min ) / this.props.step ) ) : 1;
        // Now reduce to a number < 50 to avoid showing a solid black tick line.
        let divisor = Math.ceil( ticks / this.props.maxTicks );
        while ( ticks % divisor && divisor < ticks ) {
            divisor++;
        }
        ticks = ticks / divisor;

        // Various attempts to use more elegant CSS background on the __ticks div, have failed due to little
        // issues seemingly related to rounding or browser sloppiness. This is far less elegant but robust:
        this.widget.querySelector( '.range-widget__ticks' )
            .append( document.createRange().createContextualFragment( new Array( ticks ).fill( '<span></span>' ).join( '' ) ) );
    }

    /**
     * This is separated so it can be extended (in the analog-scale widget)
     *
     * @return {string} HTML string
     */
    _getHtmlStr() {
        const html =
            `<div class="widget range-widget">
                <div class="range-widget__wrap">
                    <div class="range-widget__current"></div>
                    <div class="range-widget__bg"></div>
                    <div class="range-widget__ticks"></div>
                    <div class="range-widget__scale">
                        <span class="range-widget__scale__start"></span>
                        ${this._stepsBetweenHtmlStr( this.props )}
                        <span class="range-widget__scale__end"></span>
                    </div>
                    <div class="range-widget__bulb">
                        <div class="range-widget__bulb__inner"></div>
                        <div class="range-widget__bulb__mercury"></div>
                    </div>
                </div>
                <input type="range" class="ignore empty" min="${this.props.min}" max="${this.props.max}" step="${this.props.step}"/>
            </div>`;

        return html;
    }

    /**
     * @param {number} completeness - level of mercury
     */
    _updateMercury( completeness ) {
        const trackHeight = this.widget.querySelector( '.range-widget__ticks' ).clientHeight;
        const bulbHeight = this.widget.querySelector( '.range-widget__bulb' ).clientHeight;
        this.widget.querySelector( '.range-widget__bulb__mercury' ).style.height = `${( completeness * trackHeight ) + ( 0.5 * bulbHeight )}px`;
    }

    /**
     * @param {object} props - The range properties.
     * @return {string} HTML string
     */
    _stepsBetweenHtmlStr( props ) {
        let html = '';
        if ( props.showScale ) {
            const stepsCount = ( props.max - props.min ) / props.step;
            if ( stepsCount <= 10 && ( props.max - props.min ) % props.step === 0 ) {
                for ( let i = props.min + props.step; i < props.max; i += props.step ) {
                    html += `<span class="range-widget__scale__between">${i}</span>`;
                }
            }
        }

        return html;
    }

    /**
     * Resets widget
     */
    _reset() {
        // Update UI stuff before the actual value to avoid issues in custom clients that may want to programmatically undo a reset ("strict required" in OpenClinica)
        // as that is subtly different from updating a value with a calculation since this.originalInputValue=  sets the evaluation cascade in motion.
        this.current.textContent = '';
        this._updateMercury( 0 );
        this.value = '';
        this.originalInputValue = '';
    }

    /**
     * Disables widget
     */
    disable() {
        this.widget.querySelectorAll( 'input, button' ).forEach( el => el.disabled = true );
    }

    /**
     * Enables widget
     */
    enable() {
        this.widget.querySelectorAll( 'input, button' ).forEach( el => el.disabled = false );
    }

    /**
     * Updates widget
     */
    update() {
        const value = this.element.value;

        if ( isNumber( value ) ) {
            this.value = value;
            this.range.dispatchEvent( events.Change() );
        } else {
            this._reset();
        }
    }

    /**
     * @type {object}
     */
    get props() {
        const props = this._props;
        const min = isNumber( this.element.getAttribute( 'min' ) ) ? this.element.getAttribute( 'min' ) : 0;
        const max = isNumber( this.element.getAttribute( 'max' ) ) ? this.element.getAttribute( 'max' ) : 10;
        const step = isNumber( this.element.getAttribute( 'step' ) ) ? this.element.getAttribute( 'step' ) : 1;
        const distress = props.appearances.includes( 'distress' );

        props.min = Number( min );
        props.max = Number( max );
        props.step = Number( step );
        props.vertical = props.appearances.includes( 'vertical' ) || distress;
        props.ticks = !props.appearances.includes( 'no-ticks' );
        props.showScale = distress;
        props.maxTicks = 50;

        return props;
    }

    /**
     * @type {string}
     */
    get value() {
        return this.range.classList.contains( 'empty' ) ? '' : this.range.value;
    }

    set value( value ) {
        this.range.value = value;
        // value '' actually sets the value to some default value in html range input, not really helpful
        this.range.classList.toggle( 'empty', value === '' );
    }


}

export default RangeWidget;