UNPKG

samagra-x-enketo-core

Version:

Extensible Enketo form engine

291 lines (260 loc) 10.6 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 Widget from '../../js/widget'; import support from '../../js/support'; import events from '../../js/event'; import { getSiblingElementsAndSelf } from '../../js/dom-utils'; import event from '../../js/event'; import { encodeHtmlEntities } from '../../js/utils'; import { t } from 'enketo/translator'; 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.value; 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>`; } else { 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="${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 event.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;