widget/select-desktop/selectpicker.js

/**
 * 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;