widget/select-autocomplete/autocomplete.js

import $ from 'jquery';
import Widget from '../../js/widget';
import events from '../../js/event';
import { getSiblingElement } from '../../js/dom-utils';

const sadExcuseForABrowser = !( 'list' in document.createElement( 'input' ) &&
    'options' in document.createElement( 'datalist' ) &&
    typeof window.HTMLDataListElement !== 'undefined' );

import './jquery.relevant-dropdown';

/**
 * Autocomplete select1 picker for modern browsers.
 *
 * @augments Widget
 */
class AutocompleteSelectpicker extends Widget {
    /**
     * @type {string}
     */
    static get selector() {
        return '.question input[list]';
    }

    /**
     * @type {boolean}
     */
    static get list() {
        return true;
    }

    _init() {
        const listId = this.element.getAttribute( 'list' );

        if ( !getSiblingElement( this.element, 'datalist' ) ) {
            const info = getSiblingElement( this.element.closest( '.or-repeat' ), '.or-repeat-info' );
            this.options = info ? [ ...info.querySelectorAll( `datalist#${CSS.escape( listId )} > option` ) ] : [];
        } else {
            this.options = [ ...this.question.querySelectorAll( `datalist#${CSS.escape( listId )} > option` ) ];
        }

        // This value -> data-value change is not slow, so no need to move to enketo-xslt as that would
        // increase itemset complexity even further.
        this.options.forEach( item => {
            const value = item.getAttribute( 'value' );
            /**
             * We're changing the original datalist here, so have to make sure we don't do anything
             * if dataset.value is already populated.
             *
             * However, for some reason !item.dataset.value is failing in Safari, which as a result sets all dataset.value attributes to "null"
             * To workaround this, we check for the value attribute instead.
             */
            if ( !item.classList.contains( 'itemset-template' ) && item.textContent && value !== undefined && value !== null ) {
                item.dataset.value = value;
                item.setAttribute( 'value', item.textContent );
                item.textContent = '';
            }
        } );

        const fragment = document
            .createRange()
            .createContextualFragment( `<input type="text" class="ignore widget autocomplete" list="${listId}" />` );
        this.element.classList.add( 'hide' );
        this.element.after( fragment );

        this.fakeInput = this.element.parentElement.querySelector( 'input.autocomplete' );

        if ( this.props.readonly ) {
            this.fakeInput.setAttribute( 'readonly', 'readonly' );
        }
        if ( this.props.disabled ) {
            this.fakeInput.setAttribute( 'disabled', 'disabled' );
        }

        if ( sadExcuseForABrowser ) {
            //console.debug( 'Polyfill required' );
            // don't bother de-jqueryfying this I think, since it's only for IE11 now I think (and we'll remove IE11 support).
            $( this.fakeInput ).relevantDropdown();
        }

        this._setFakeInputListener();
        this._setFocusListener();
        this._showCurrentLabel(); // after setting fakeInputListener!
    }

    /**
     * Displays current label
     */
    _showCurrentLabel() {
        const inputValue = this.originalInputValue;
        const label = this._findLabel( inputValue );

        this.fakeInput.value = label;

        // If a corresponding label cannot be found the value is invalid,
        // and should be cleared. For this we trigger an 'input' event.
        if ( inputValue && !label ) {
            this.fakeInput.dispatchEvent( events.Input() );
        }
    }

    /**
     * Sets fake input listener
     */
    _setFakeInputListener() {
        this.fakeInput.addEventListener( 'input', e => {
            const input = e.target;
            const label = input.value;
            const value = this._findValue( label ) || '';
            if ( this.originalInputValue !== value ) {
                this.originalInputValue = value;
            }
            input.classList.toggle( 'notfound', label && !value );
        } );
    }

    /**
     * @param {string} label - label value
     * @return {string} value
     */
    _findValue( label ) {
        let value = '';

        if ( !label ) {
            return '';
        }

        this.options.forEach( option => {
            if ( option.value === label ) {
                value = option.getAttribute( 'data-value' );

                return false;
            }
        } );

        return value;
    }

    /**
     * @param {string} value - option value
     * @return {string} label
     */
    _findLabel( value ) {
        let label = '';

        if ( !value ) {
            return '';
        }

        this.options.forEach( option => {
            if ( option.dataset.value === value ) {
                label = option.value;

                return false;
            }
        } );

        return label;
    }

    /**
     * Handles focus listener
     */
    _setFocusListener() {
        // Handle original input focus
        this.element.addEventListener( events.ApplyFocus().type, () => {
            this.fakeInput.focus();
        } );
    }

    /**
     * Disables widget
     */
    disable() {
        this.fakeInput.classList.add( 'disabled' );
    }

    /**
     * Enables widget
     */
    enable() {
        this.fakeInput.classList.remove( 'disabled' );
    }

    /**
     * Updates widget
     *
     * There are 3 scenarios for which method is called:
     * 1. The options change (dynamic itemset)
     * 2. The language changed. (just this._showCurrentLabel() would be more efficient)
     * 3. The value of the underlying original input changed due a calculation. (same as #2?)
     *
     * For now we just dumbly reinstantiate it (including the polyfill).
     */
    update() {
        this.element.parentElement.querySelector( '.widget' ).remove();
        this._init();
    }
}

export default AutocompleteSelectpicker;