enketo-core
Version:
Extensible Enketo form engine
261 lines (231 loc) • 8.65 kB
JavaScript
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() {
// analog-scale selector is included as courtesy to OpenClinica
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 /= 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;
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;