enketo-core
Version:
Extensible Enketo form engine
309 lines (280 loc) • 10.9 kB
JavaScript
/**
* This widget is one gigantic mess. It should be replaced entirely.
* The replacement should have and use getters and setters for `value` and `originalInputValue`
*/
/**
* Copyright 2012 Silvio Moreto, Martijn van de Rijdt & Modilabs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import $ from 'jquery';
import { t } from 'enketo/translator';
import Widget from '../../js/widget';
import support from '../../js/support';
import events from '../../js/event';
import { getSiblingElementsAndSelf } from '../../js/dom-utils';
import { encodeHtmlEntities } from '../../js/utils';
import '../../js/dropdown.jquery';
const range = document.createRange();
/**
* Bootstrap Select picker that supports single and multiple selects
* A port of https://github.com/silviomoreto/bootstrap-select
*
* @augments Widget
*/
class DesktopSelectpicker extends Widget {
/**
* @type {string}
*/
static get selector() {
return '.question select';
}
/**
* @type {boolean}
*/
static get list() {
return true;
}
/**
* @return {boolean} Whether additional condition to instantiate the widget is met.
*/
static condition() {
return !support.touch;
}
_init() {
const select = this.element;
select.style.display = 'none';
const template = this._getTemplate();
select.after(template);
this.picker = this.question.querySelector('.bootstrap-select');
if (this.props.readonly) {
this.disable();
}
this._clickListener();
this._focusListener();
}
/**
* @return {Element} HTML fragment
*/
_getTemplate() {
const template = range.createContextualFragment(`
<div class="btn-group bootstrap-select widget clearfix">
<button type="button" class="btn btn-default dropdown-toggle clearfix" data-toggle="dropdown">
<span class="selected"></span><span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu">${this._getLisHtml()}</ul>
</div>`);
this._showSelected(template.querySelector('.selected'));
return template;
}
/**
* Generates HTML text for <li> elements
*/
_getLisHtml() {
const inputAttr = this.props.multiple
? 'type="checkbox"'
: `type="radio" name="${Math.random() * 100000}"`;
return [...this.element.querySelectorAll('option')]
.map((option) => {
const label = option.textContent;
const selected = option.matches(':checked');
const { value } = option;
if (value) {
const checkedInputAttr = selected
? ' checked="checked"'
: '';
const checkedLiAttr = selected ? 'class="active"' : '';
/**
* e.g.:
* <li checked="checked">
* <a class="option-wrapper" tabindex="-1" href="#">
* <label>
* <input class="ignore" type="checkbox" checked="checked" value="a"/>
* </label>
* </a>
* </li>
*/
return `
<li ${checkedLiAttr}>
<a class="option-wrapper" tabindex="-1" href="#">
<label>
<input class="ignore" ${inputAttr}${checkedInputAttr} value="${encodeHtmlEntities(
value
)}" />
<span class="option-label">${encodeHtmlEntities(
label
)}</span>
</label>
</a>
</li>`;
}
return '';
})
.join('');
}
/**
* Update text to show in closed picker
*
* @param {Element} el - HTML element to show text in
*/
_showSelected(el) {
const selectedLabels = [
...this.element.querySelectorAll('option:checked'),
]
.filter((option) => option.getAttribute('value').length)
.map((option) => option.textContent);
// keys for i18next parser to pick up:
// t( 'selectpicker.numberselected' );
if (selectedLabels.length === 0) {
// do not use variable for translation key to not confuse i18next-parser
el.textContent = t('selectpicker.noneselected');
el.dataset.i18n = 'selectpicker.noneselected';
delete el.dataset.i18nNumber;
} else if (selectedLabels.length === 1) {
el.textContent = selectedLabels[0];
delete el.dataset.i18n;
delete el.dataset.i18nNumber;
} else {
const number = selectedLabels.length;
// do not use variable for translation key to not confuse i18next-parser
el.textContent = t('selectpicker.numberselected', { number });
el.dataset.i18n = 'selectpicker.numberselected';
el.dataset.i18nNumber = number;
}
}
/**
* Handles click listener
*/
_clickListener() {
const _this = this;
$(this.picker)
.on('click', 'li:not(.disabled)', function (e) {
const li = this;
const input = li.querySelector('input');
const select = _this.element;
const option = select.querySelector(
`option[value="${CSS.escape(input.value)}"]`
);
const selectedBefore = option.matches(':checked');
// We need to prevent default unless click was on an input
// Without this 'fix', clicks on radiobuttons/checkboxes themselves will update the value
// but will not show checked status.
if (e.target.nodeName.toLowerCase() !== 'input') {
e.preventDefault();
}
if (!_this.props.multiple) {
_this.picker
.querySelectorAll('li')
.forEach((li) => li.classList.remove('active'));
getSiblingElementsAndSelf(option, 'option').forEach(
(option) => {
option.selected = false;
}
);
_this.picker
.querySelectorAll('input')
.forEach((input) => (input.checked = false));
} else {
// don't close dropdown for multiple select
e.stopPropagation();
}
// For issue https://github.com/kobotoolbox/enketo-express/issues/1122 in FF,
// we had to use event.preventDefault() on <a> tag click events.
// This broke view updates when clicking on the radiobuttons and checkboxes directly
// although the underlying values did change correctly.
//
// It has to do with event propagation. I could not figure out how to fix it.
// Therefore I used a workaround by slightly delaying the status changes.
setTimeout(() => {
if (selectedBefore) {
li.classList.remove('active');
input.checked = false;
option.selected = false;
} else {
li.classList.add('active');
option.selected = true;
input.checked = true;
}
const showSelectedEl =
_this.picker.querySelector('.selected');
_this._showSelected(showSelectedEl);
select.dispatchEvent(new events.Change());
}, 10);
})
.on('keydown', 'li:not(.disabled)', (e) => {
const keyCode = e.keyCode.toString(10);
// Enter/Space keys
if (/(13|32)/.test(keyCode)) {
if (!/(32)/.test(keyCode)) {
e.preventDefault();
}
const elem = $(':focus');
elem.click();
// Bring back focus for multiselects
elem.focus();
// Prevent screen from scrolling if the user hit the spacebar
e.preventDefault();
}
})
.on('click', 'li.disabled', (e) => {
e.stopPropagation();
return false;
})
.on('click', 'a', (e) => {
// Prevent FF from adding empty anchor to URL if checkbox or radiobutton is clicked.
// https://github.com/kobotoolbox/enketo-express/issues/1122
e.preventDefault();
});
}
/**
* Handles focus listener
*/
_focusListener() {
const _this = this;
// Focus on original element (form.goTo functionality)
this.element.addEventListener(events.ApplyFocus().type, () => {
_this.picker.querySelector('.dropdown-toggle').focus();
});
}
/**
* Disables widget
*/
disable() {
this.picker.querySelectorAll('li').forEach((el) => {
el.classList.add('disabled');
const input = el.querySelector('input');
// are both below necessary?
input.disabled = true;
input.readOnly = true;
});
}
/**
* Enables widget
*/
enable() {
this.picker.querySelectorAll('li').forEach((el) => {
el.classList.remove('disabled');
const input = el.querySelector('input');
input.disabled = false;
input.readOnly = false;
});
}
/**
* Updates widget
*/
update() {
this.picker.remove();
this._init();
}
}
export default DesktopSelectpicker;