UNPKG

enketo-core

Version:

Extensible Enketo form engine

309 lines (280 loc) 10.9 kB
/** * 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;