UNPKG

samagra-x-enketo-core

Version:

Extensible Enketo form engine

212 lines (180 loc) 7.99 kB
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;