lib/amd/src/form-autocomplete.js

// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Autocomplete wrapper for select2 library.
 *
 * @module     core/form-autocomplete
 * @copyright  2015 Damyon Wiese <damyon@moodle.com>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 * @since      3.0
 */
define([
    'jquery',
    'core/log',
    'core/str',
    'core/templates',
    'core/notification',
    'core/loadingicon',
    'core/aria',
    'core_form/changechecker',
    'core/popper2',
], function(
    $,
    log,
    str,
    templates,
    notification,
    LoadingIcon,
    Aria,
    FormChangeChecker,
    Popper
) {
    // Private functions and variables.
    /** @var {Object} KEYS - List of keycode constants. */
    var KEYS = {
        DOWN: 40,
        ENTER: 13,
        SPACE: 32,
        ESCAPE: 27,
        COMMA: 44,
        UP: 38,
        LEFT: 37,
        RIGHT: 39
    };

    var uniqueId = Date.now();

    /**
     * Make an item in the selection list "active".
     *
     * @method activateSelection
     * @private
     * @param {Number} index The index in the current (visible) list of selection.
     * @param {Object} state State variables for this autocomplete element.
     * @return {Promise}
     */
    var activateSelection = function(index, state) {
        // Find the elements in the DOM.
        var selectionElement = $(document.getElementById(state.selectionId));

        index = wrapListIndex(index, selectionElement.children('[aria-selected=true]').length);
        // Find the specified element.
        var element = $(selectionElement.children('[aria-selected=true]').get(index));
        // Create an id we can assign to this element.
        var itemId = state.selectionId + '-' + index;

        // Deselect all the selections.
        selectionElement.children().attr('data-active-selection', null).attr('id', '');

        // Select only this suggestion and assign it the id.
        element.attr('data-active-selection', true).attr('id', itemId);

        // Tell the input field it has a new active descendant so the item is announced.
        selectionElement.attr('aria-activedescendant', itemId);
        selectionElement.attr('data-active-value', element.attr('data-value'));

        return $.Deferred().resolve();
    };

    /**
     * Get the actively selected element from the state object.
     *
     * @param   {Object} state
     * @returns {jQuery}
     */
    var getActiveElementFromState = function(state) {
        var selectionRegion = $(document.getElementById(state.selectionId));
        var activeId = selectionRegion.attr('aria-activedescendant');

        if (activeId) {
            var activeElement = $(document.getElementById(activeId));
            if (activeElement.length) {
                // The active descendent still exists.
                return activeElement;
            }
        }

        // Ensure we are creating a properly formed selector based on the active value.
        var activeValue = selectionRegion.attr('data-active-value')?.replace(/"/g, '\\"');
        return selectionRegion.find('[data-value="' + activeValue + '"]');
    };

    /**
     * Update the active selection from the given state object.
     *
     * @param   {Object} state
     */
    var updateActiveSelectionFromState = function(state) {
        var activeElement = getActiveElementFromState(state);
        var activeValue = activeElement.attr('data-value');

        var selectionRegion = $(document.getElementById(state.selectionId));
        if (activeValue) {
            // Find the index of the currently selected index.
            var activeIndex = selectionRegion.find('[aria-selected=true]').index(activeElement);

            if (activeIndex !== -1) {
                activateSelection(activeIndex, state);
                return;
            }
        }

        // Either the active index was not set, or it could not be found.
        // Select the first value instead.
        activateSelection(0, state);
    };

    /**
     * Update the element that shows the currently selected items.
     *
     * @method updateSelectionList
     * @private
     * @param {Object} options Original options for this autocomplete element.
     * @param {Object} state State variables for this autocomplete element.
     * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
     * @return {Promise}
     */
    var updateSelectionList = function(options, state, originalSelect) {
        var pendingKey = 'form-autocomplete-updateSelectionList-' + state.inputId;
        M.util.js_pending(pendingKey);

        // Build up a valid context to re-render the template.
        var items = rebuildOptions(originalSelect.children('option:selected'), false);
        var newSelection = $(document.getElementById(state.selectionId));

        if (!hasItemListChanged(state, items)) {
            M.util.js_complete(pendingKey);
            return Promise.resolve();
        }

        state.items = items;

        var context = $.extend(options, state);
        // Render the template.
        return templates.render(options.templates.items, context)
        .then(function(html, js) {
            // Add it to the page.
            templates.replaceNodeContents(newSelection, html, js);

            updateActiveSelectionFromState(state);

            return;
        })
        .then(function() {
            return M.util.js_complete(pendingKey);
        })
        .catch(notification.exception);
    };

    /**
     * Check whether the list of items stored in the state has changed.
     *
     * @param   {Object} state
     * @param   {Array} items
     * @returns {Boolean}
     */
    var hasItemListChanged = function(state, items) {
        if (state.items.length !== items.length) {
            return true;
        }

        // Check for any items in the state items which are not present in the new items list.
        return state.items.filter(item => items.indexOf(item) === -1).length > 0;
    };

    /**
     * Notify of a change in the selection.
     *
     * @param {jQuery} originalSelect The jQuery object matching the hidden select list.
     */
    var notifyChange = function(originalSelect) {
        FormChangeChecker.markFormChangedFromNode(originalSelect[0]);

        // Note, jQuery .change() was not working here. Better to
        // use plain JavaScript anyway.
        originalSelect[0].dispatchEvent(new Event('change', {bubbles: true}));
    };

    /**
     * Remove the given item from the list of selected things.
     *
     * @method deselectItem
     * @private
     * @param {Object} options Original options for this autocomplete element.
     * @param {Object} state State variables for this autocomplete element.
     * @param {Element} item The item to be deselected.
     * @param {Element} originalSelect The original select list.
     * @return {Promise}
     */
    var deselectItem = function(options, state, item, originalSelect) {
        var selectedItemValue = $(item).attr('data-value');

        // Preprend an empty option to the select list to avoid having a default selected option.
        if (originalSelect.find('option').first().attr('value') !== undefined) {
            originalSelect.prepend($('<option>'));
        }

        // Look for a match, and toggle the selected property if there is a match.
        originalSelect.children('option').each(function(index, ele) {
            if ($(ele).attr('value') == selectedItemValue) {
                $(ele).prop('selected', false);
                // We remove newly created custom tags from the suggestions list when they are deselected.
                if ($(ele).attr('data-iscustom')) {
                    $(ele).remove();
                }
            }
        });
        // Rerender the selection list.
        return updateSelectionList(options, state, originalSelect)
        .then(function() {
            // Notify that the selection changed.
            notifyChange(originalSelect);

            return;
        });
    };

    /**
     * Make an item in the suggestions "active" (about to be selected).
     *
     * @method activateItem
     * @private
     * @param {Number} index The index in the current (visible) list of suggestions.
     * @param {Object} state State variables for this instance of autocomplete.
     * @return {Promise}
     */
    var activateItem = function(index, state) {
        // Find the elements in the DOM.
        var inputElement = $(document.getElementById(state.inputId));
        var suggestionsElement = $(document.getElementById(state.suggestionsId));

        // Count the visible items.
        var length = suggestionsElement.children(':not([aria-hidden])').length;
        // Limit the index to the upper/lower bounds of the list (wrap in both directions).
        index = index % length;
        while (index < 0) {
            index += length;
        }
        // Find the specified element.
        var element = $(suggestionsElement.children(':not([aria-hidden])').get(index));
        // Find the index of this item in the full list of suggestions (including hidden).
        var globalIndex = $(suggestionsElement.children('[role=option]')).index(element);
        // Create an id we can assign to this element.
        var itemId = state.suggestionsId + '-' + globalIndex;

        // Deselect all the suggestions.
        suggestionsElement.children().attr('aria-selected', false).attr('id', '');
        // Select only this suggestion and assign it the id.
        element.attr('aria-selected', true).attr('id', itemId);
        // Tell the input field it has a new active descendant so the item is announced.
        inputElement.attr('aria-activedescendant', itemId);

        // Scroll it into view.
        var scrollPos = element.offset().top
                       - suggestionsElement.offset().top
                       + suggestionsElement.scrollTop()
                       - (suggestionsElement.height() / 2);
        return suggestionsElement.animate({
            scrollTop: scrollPos
        }, 100).promise();
    };

    /**
     * Return the index of the currently selected item in the suggestions list.
     *
     * @param {jQuery} suggestionsElement
     * @return {Integer}
     */
    var getCurrentItem = function(suggestionsElement) {
        // Find the active one.
        var element = suggestionsElement.children('[aria-selected=true]');
        // Find its index.
        return suggestionsElement.children(':not([aria-hidden])').index(element);
    };

    /**
     * Limit the index to the upper/lower bounds of the list (wrap in both directions).
     *
     * @param {Integer} index The target index.
     * @param {Integer} length The length of the list of visible items.
     * @return {Integer} The resulting index with necessary wrapping applied.
     */
    var wrapListIndex = function(index, length) {
        index = index % length;
        while (index < 0) {
            index += length;
        }
        return index;
    };

    /**
     * Return the index of the next item in the list without aria-disabled=true.
     *
     * @param {Integer} current The index of the current item.
     * @param {Array} suggestions The list of suggestions.
     * @return {Integer}
     */
    var getNextEnabledItem = function(current, suggestions) {
        var nextIndex = wrapListIndex(current + 1, suggestions.length);
        if (suggestions[nextIndex].getAttribute('aria-disabled')) {
            return getNextEnabledItem(nextIndex, suggestions);
        }
        return nextIndex;
    };

    /**
     * Return the index of the previous item in the list without aria-disabled=true.
     *
     * @param {Integer} current The index of the current item.
     * @param {Array} suggestions The list of suggestions.
     * @return {Integer}
     */
    var getPreviousEnabledItem = function(current, suggestions) {
        var previousIndex = wrapListIndex(current - 1, suggestions.length);
        if (suggestions[previousIndex].getAttribute('aria-disabled')) {
            return getPreviousEnabledItem(previousIndex, suggestions);
        }
        return previousIndex;
    };

    /**
     * Build a list of renderable options based on a set of option elements from the original select list.
     *
     * @param {jQuery} originalOptions
     * @param {Boolean} includeEmpty
     * @return {Array}
     */
    var rebuildOptions = function(originalOptions, includeEmpty) {
        var options = [];
        originalOptions.each(function(index, ele) {
            var label;
            if ($(ele).data('html')) {
                label = $(ele).data('html');
            } else {
                label = $(ele).html();
            }
            if (includeEmpty || label !== '') {
                options.push({
                    label: label,
                    value: $(ele).attr('value'),
                    disabled: ele.disabled,
                    classes: ele.classList,
                });
            }
        });
        return options;
    };

    /**
     * Find the index of the current active suggestion, and activate the next one.
     *
     * @method activateNextItem
     * @private
     * @param {Object} state State variable for this auto complete element.
     * @return {Promise}
     */
    var activateNextItem = function(state) {
        // Find the list of suggestions.
        var suggestionsElement = $(document.getElementById(state.suggestionsId));
        var suggestions = suggestionsElement.children(':not([aria-hidden])');
        var current = getCurrentItem(suggestionsElement);
        // Activate the next one.
        return activateItem(getNextEnabledItem(current, suggestions), state);
    };

    /**
     * Find the index of the current active selection, and activate the previous one.
     *
     * @method activatePreviousSelection
     * @private
     * @param {Object} state State variables for this instance of autocomplete.
     * @return {Promise}
     */
    var activatePreviousSelection = function(state) {
        // Find the list of selections.
        var selectionsElement = $(document.getElementById(state.selectionId));
        // Find the active one.
        var element = selectionsElement.children('[data-active-selection]');
        if (!element) {
            return activateSelection(0, state);
        }
        // Find it's index.
        var current = selectionsElement.children('[aria-selected=true]').index(element);
        // Activate the next one.
        return activateSelection(current - 1, state);
    };

    /**
     * Find the index of the current active selection, and activate the next one.
     *
     * @method activateNextSelection
     * @private
     * @param {Object} state State variables for this instance of autocomplete.
     * @return {Promise}
     */
    var activateNextSelection = function(state) {
        // Find the list of selections.
        var selectionsElement = $(document.getElementById(state.selectionId));

        // Find the active one.
        var element = selectionsElement.children('[data-active-selection]');
        var current = 0;

        if (element) {
            // The element was found. Determine the index and move to the next one.
            current = selectionsElement.children('[aria-selected=true]').index(element);
            current = current + 1;
        } else {
            // No selected item found. Move to the first.
            current = 0;
        }

        return activateSelection(current, state);
    };

    /**
     * Find the index of the current active suggestion, and activate the previous one.
     *
     * @method activatePreviousItem
     * @private
     * @param {Object} state State variables for this autocomplete element.
     * @return {Promise}
     */
    var activatePreviousItem = function(state) {
        var suggestionsElement = $(document.getElementById(state.suggestionsId));
        var suggestions = suggestionsElement.children(':not([aria-hidden])');
        var current = getCurrentItem(suggestionsElement);
        // Activate the previous one.
        return activateItem(getPreviousEnabledItem(current, suggestions), state);
    };

    /**
     * Close the list of suggestions.
     *
     * @method closeSuggestions
     * @private
     * @param {Object} state State variables for this autocomplete element.
     * @return {Promise}
     */
    var closeSuggestions = function(state) {
        // Find the elements in the DOM.
        var inputElement = $(document.getElementById(state.inputId));
        var suggestionsElement = $(document.getElementById(state.suggestionsId));

        if (inputElement.attr('aria-expanded') === "true") {
            // Announce the list of suggestions was closed.
            inputElement.attr('aria-expanded', false);
        }
        // Read the current list of selections.
        inputElement.attr('aria-activedescendant', state.selectionId);

        // Hide the suggestions list (from screen readers too).
        Aria.hide(suggestionsElement.get());
        suggestionsElement.hide();

        return $.Deferred().resolve();
    };

    /**
     * Rebuild the list of suggestions based on the current values in the select list, and the query.
     * Any options in the original select with [data-enabled=disabled] will not be included
     * as a suggestion option in the enhanced field.
     *
     * @method updateSuggestions
     * @private
     * @param {Object} options The original options for this autocomplete.
     * @param {Object} state The state variables for this autocomplete.
     * @param {String} query The current text for the search string.
     * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
     * @return {Promise}
     */
    var updateSuggestions = function(options, state, query, originalSelect) {
        var pendingKey = 'form-autocomplete-updateSuggestions-' + state.inputId;
        M.util.js_pending(pendingKey);

        // Find the elements in the DOM.
        var inputElement = $(document.getElementById(state.inputId));
        var suggestionsElement = $(document.getElementById(state.suggestionsId));

        // Used to track if we found any visible suggestions.
        var matchingElements = false;
        // Options is used by the context when rendering the suggestions from a template.
        var suggestions = rebuildOptions(originalSelect.children('option:not(:selected, [data-enabled="disabled"])'), true);

        // Re-render the list of suggestions.
        var searchquery = state.caseSensitive ? query : query.toLocaleLowerCase();
        var context = $.extend({options: suggestions}, options, state);
        var returnVal = templates.render(
            'core/form_autocomplete_suggestions',
            context
        )
        .then(function(html, js) {
            // We have the new template, insert it in the page.
            templates.replaceNode(suggestionsElement, html, js);

            // Get the element again.
            suggestionsElement = $(document.getElementById(state.suggestionsId));

            // Show it if it is hidden.
            Aria.unhide(suggestionsElement.get());
            Popper.createPopper(inputElement[0], suggestionsElement[0], {
                placement: 'bottom-start',
                modifiers: [{name: 'flip', enabled: false}],
            });

            // For each option in the list, hide it if it doesn't match the query.
            suggestionsElement.children().each(function(index, node) {
                node = $(node);
                if ((options.caseSensitive && node.text().indexOf(searchquery) > -1) ||
                        (!options.caseSensitive && node.text().toLocaleLowerCase().indexOf(searchquery) > -1)) {
                    Aria.unhide(node.get());
                    node.show();
                    matchingElements = true;
                } else {
                    node.hide();
                    Aria.hide(node.get());
                }
            });
            // If we found any matches, show the list.
            inputElement.attr('aria-expanded', true);
            if (originalSelect.attr('data-notice')) {
                // Display a notice rather than actual suggestions.
                suggestionsElement.html(originalSelect.attr('data-notice'));
            } else if (matchingElements) {
                // We only activate the first item in the list if tags is false,
                // because otherwise "Enter" would select the first item, instead of
                // creating a new tag.
                if (!options.tags) {
                    activateItem(0, state);
                }
            } else {
                // Nothing matches. Tell them that.
                str.get_string('nosuggestions', 'form').done(function(nosuggestionsstr) {
                    suggestionsElement.html(nosuggestionsstr);
                });
            }

            return suggestionsElement;
        })
        .then(function() {
            return M.util.js_complete(pendingKey);
        })
        .catch(notification.exception);

        return returnVal;
    };

    /**
     * Create a new item for the list (a tag).
     *
     * @method createItem
     * @private
     * @param {Object} options The original options for the autocomplete.
     * @param {Object} state State variables for the autocomplete.
     * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
     * @return {Promise}
     */
    var createItem = function(options, state, originalSelect) {
        // Find the element in the DOM.
        var inputElement = $(document.getElementById(state.inputId));
        // Get the current text in the input field.
        var query = inputElement.val();
        var tags = query.split(',');
        var found = false;

        $.each(tags, function(tagindex, tag) {
            // If we can only select one at a time, deselect any current value.
            tag = tag.trim();
            if (tag !== '') {
                if (!options.multiple) {
                    originalSelect.children('option').prop('selected', false);
                }
                // Look for an existing option in the select list that matches this new tag.
                originalSelect.children('option').each(function(index, ele) {
                    if ($(ele).attr('value') == tag) {
                        found = true;
                        $(ele).prop('selected', true);
                    }
                });
                // Only create the item if it's new.
                if (!found) {
                    var option = $('<option>');
                    option.append(document.createTextNode(tag));
                    option.attr('value', tag);
                    originalSelect.append(option);
                    option.prop('selected', true);
                    // We mark newly created custom options as we handle them differently if they are "deselected".
                    option.attr('data-iscustom', true);
                }
            }
        });

        return updateSelectionList(options, state, originalSelect)
        .then(function() {
            // Notify that the selection changed.
            notifyChange(originalSelect);

            return;
        })
        .then(function() {
            // Clear the input field.
            inputElement.val('');

            return;
        })
        .then(function() {
            // Close the suggestions list.
            return closeSuggestions(state);
        });
    };

    /**
     * Select the currently active item from the suggestions list.
     *
     * @method selectCurrentItem
     * @private
     * @param {Object} options The original options for the autocomplete.
     * @param {Object} state State variables for the autocomplete.
     * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
     * @return {Promise}
     */
    var selectCurrentItem = function(options, state, originalSelect) {
        // Find the elements in the page.
        var inputElement = $(document.getElementById(state.inputId));
        var suggestionsElement = $(document.getElementById(state.suggestionsId));
        // Here loop through suggestions and set val to join of all selected items.

        var selectedItemValue = suggestionsElement.children('[aria-selected=true]').attr('data-value');
        // The select will either be a single or multi select, so the following will either
        // select one or more items correctly.
        // Take care to use 'prop' and not 'attr' for selected properties.
        // If only one can be selected at a time, start by deselecting everything.
        if (!options.multiple) {
            originalSelect.children('option').prop('selected', false);
        }
        // Look for a match, and toggle the selected property if there is a match.
        originalSelect.children('option').each(function(index, ele) {
            if ($(ele).attr('value') == selectedItemValue) {
                $(ele).prop('selected', true);
            }
        });

        return updateSelectionList(options, state, originalSelect)
        .then(function() {
            // Notify that the selection changed.
            notifyChange(originalSelect);

            return;
        })
        .then(function() {
            if (options.closeSuggestionsOnSelect) {
                // Clear the input element.
                inputElement.val('');
                // Close the list of suggestions.
                return closeSuggestions(state);
            } else {
                // Focus on the input element so the suggestions does not auto-close.
                inputElement.focus();
                // Remove the last selected item from the suggestions list.
                return updateSuggestions(options, state, inputElement.val(), originalSelect);
            }
        });
    };

    /**
     * Fetch a new list of options via ajax.
     *
     * @method updateAjax
     * @private
     * @param {Event} e The event that triggered this update.
     * @param {Object} options The original options for the autocomplete.
     * @param {Object} state The state variables for the autocomplete.
     * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
     * @param {Object} ajaxHandler This is a module that does the ajax fetch and translates the results.
     * @return {Promise}
     */
    var updateAjax = function(e, options, state, originalSelect, ajaxHandler) {
        var pendingPromise = addPendingJSPromise('updateAjax');
        // We need to show the indicator outside of the hidden select list.
        // So we get the parent id of the hidden select list.
        var parentElement = $(document.getElementById(state.selectId)).parent();
        LoadingIcon.addIconToContainerRemoveOnCompletion(parentElement, pendingPromise);

        // Get the query to pass to the ajax function.
        var query = $(e.currentTarget).val();
        // Call the transport function to do the ajax (name taken from Select2).
        ajaxHandler.transport(options.selector, query, function(results) {
            // We got a result - pass it through the translator before using it.
            var processedResults = ajaxHandler.processResults(options.selector, results);
            var existingValues = [];

            // Now destroy all options that are not current
            originalSelect.children('option').each(function(optionIndex, option) {
                option = $(option);
                if (!option.prop('selected')) {
                    option.remove();
                } else {
                    existingValues.push(String(option.attr('value')));
                }
            });

            if (!options.multiple && originalSelect.children('option').length === 0) {
                // If this is a single select - and there are no current options
                // the first option added will be selected by the browser. This causes a bug!
                // We need to insert an empty option so that none of the real options are selected.
                var option = $('<option>');
                originalSelect.append(option);
            }
            if ($.isArray(processedResults)) {
                // Add all the new ones returned from ajax.
                $.each(processedResults, function(resultIndex, result) {
                    if (existingValues.indexOf(String(result.value)) === -1) {
                        var option = $('<option>');
                        option.append(result.label);
                        option.attr('value', result.value);
                        originalSelect.append(option);
                    }
                });
                originalSelect.attr('data-notice', '');
            } else {
                // The AJAX handler returned a string instead of the array.
                originalSelect.attr('data-notice', processedResults);
            }
            // Update the list of suggestions now from the new values in the select list.
            pendingPromise.resolve(updateSuggestions(options, state, '', originalSelect));
        }, function(error) {
            pendingPromise.reject(error);
        });

        return pendingPromise;
    };

    /**
     * Add all the event listeners required for keyboard nav, blur clicks etc.
     *
     * @method addNavigation
     * @private
     * @param {Object} options The options used to create this autocomplete element.
     * @param {Object} state State variables for this autocomplete element.
     * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
     */
    var addNavigation = function(options, state, originalSelect) {
        // Start with the input element.
        var inputElement = $(document.getElementById(state.inputId));
        // Add keyboard nav with keydown.
        inputElement.on('keydown', function(e) {
            var pendingJsPromise = addPendingJSPromise('addNavigation-' + state.inputId + '-' + e.keyCode);

            switch (e.keyCode) {
                case KEYS.DOWN:
                    // If the suggestion list is open, move to the next item.
                    if (!options.showSuggestions) {
                        // Do not consume this event.
                        pendingJsPromise.resolve();
                        return true;
                    } else if (inputElement.attr('aria-expanded') === "true") {
                        pendingJsPromise.resolve(activateNextItem(state));
                    } else {
                        // Handle ajax population of suggestions.
                        if (!inputElement.val() && options.ajax) {
                            require([options.ajax], function(ajaxHandler) {
                                pendingJsPromise.resolve(updateAjax(e, options, state, originalSelect, ajaxHandler));
                            });
                        } else {
                            // Open the suggestions list.
                            pendingJsPromise.resolve(updateSuggestions(options, state, inputElement.val(), originalSelect));
                        }
                    }
                    // We handled this event, so prevent it.
                    e.preventDefault();
                    return false;
                case KEYS.UP:
                    // Choose the previous active item.
                    pendingJsPromise.resolve(activatePreviousItem(state));

                    // We handled this event, so prevent it.
                    e.preventDefault();
                    return false;
                case KEYS.ENTER:
                    var suggestionsElement = $(document.getElementById(state.suggestionsId));
                    if ((inputElement.attr('aria-expanded') === "true") &&
                            (suggestionsElement.children('[aria-selected=true]').length > 0)) {
                        // If the suggestion list has an active item, select it.
                        pendingJsPromise.resolve(selectCurrentItem(options, state, originalSelect));
                    } else if (options.tags) {
                        // If tags are enabled, create a tag.
                        pendingJsPromise.resolve(createItem(options, state, originalSelect));
                    } else {
                        pendingJsPromise.resolve();
                    }

                    // We handled this event, so prevent it.
                    e.preventDefault();
                    return false;
                case KEYS.ESCAPE:
                    if (inputElement.attr('aria-expanded') === "true") {
                        // If the suggestion list is open, close it.
                        pendingJsPromise.resolve(closeSuggestions(state));
                    } else {
                        pendingJsPromise.resolve();
                    }
                    // We handled this event, so prevent it.
                    e.preventDefault();
                    return false;
            }
            pendingJsPromise.resolve();
            return true;
        });
        // Support multi lingual COMMA keycode (44).
        inputElement.on('keypress', function(e) {

            if (e.keyCode === KEYS.COMMA) {
                if (options.tags) {
                    // If we are allowing tags, comma should create a tag (or enter).
                    addPendingJSPromise('keypress-' + e.keyCode)
                    .resolve(createItem(options, state, originalSelect));
                }
                // We handled this event, so prevent it.
                e.preventDefault();
                return false;
            }
            return true;
        });
        // Support submitting the form without leaving the autocomplete element,
        // or submitting too quick before the blur handler action is completed.
        inputElement.closest('form').on('submit', function() {
            if (options.tags) {
                // If tags are enabled, create a tag.
                addPendingJSPromise('form-autocomplete-submit')
                .resolve(createItem(options, state, originalSelect));
            }

            return true;
        });
        inputElement.on('blur', function() {
            var pendingPromise = addPendingJSPromise('form-autocomplete-blur');
            window.setTimeout(function() {
                // Get the current element with focus.
                var focusElement = $(document.activeElement);
                var timeoutPromise = $.Deferred();

                // Only close the menu if the input hasn't regained focus and if the element still exists,
                // and regain focus if the scrollbar is clicked.
                // Due to the half a second delay, it is possible that the input element no longer exist
                // by the time this code is being executed.
                if (focusElement.is(document.getElementById(state.suggestionsId))) {
                    inputElement.focus(); // Probably the scrollbar is clicked. Regain focus.
                } else if (!focusElement.is(inputElement) && $(document.getElementById(state.inputId)).length) {
                    if (options.tags) {
                        timeoutPromise.then(function() {
                            return createItem(options, state, originalSelect);
                        })
                        .catch();
                    }
                    timeoutPromise.then(function() {
                        return closeSuggestions(state);
                    })
                    .catch();
                }

                timeoutPromise.then(function() {
                    return pendingPromise.resolve();
                })
                .catch();
                timeoutPromise.resolve();
            }, 500);
        });
        if (options.showSuggestions) {
            var arrowElement = $(document.getElementById(state.downArrowId));
            arrowElement.on('click', function(e) {
                var pendingPromise = addPendingJSPromise('form-autocomplete-show-suggestions');

                // Prevent the close timer, or we will open, then close the suggestions.
                inputElement.focus();

                // Handle ajax population of suggestions.
                if (!inputElement.val() && options.ajax) {
                    require([options.ajax], function(ajaxHandler) {
                        pendingPromise.resolve(updateAjax(e, options, state, originalSelect, ajaxHandler));
                    });
                } else {
                    // Else - open the suggestions list.
                    pendingPromise.resolve(updateSuggestions(options, state, inputElement.val(), originalSelect));
                }
            });
        }

        var suggestionsElement = $(document.getElementById(state.suggestionsId));
        // Remove any click handler first.
        suggestionsElement.parent().prop("onclick", null).off("click");
        suggestionsElement.parent().on('click', `#${state.suggestionsId} [role=option]`, function(e) {
            var pendingPromise = addPendingJSPromise('form-autocomplete-parent');
            // Handle clicks on suggestions.
            var element = $(e.currentTarget).closest('[role=option]');
            var suggestionsElement = $(document.getElementById(state.suggestionsId));
            // Find the index of the clicked on suggestion.
            var current = suggestionsElement.children(':not([aria-hidden])').index(element);

            // Activate it.
            activateItem(current, state)
            .then(function() {
                // And select it.
                return selectCurrentItem(options, state, originalSelect);
            })
            .then(function() {
                return pendingPromise.resolve();
            })
            .catch();
        });
        var selectionElement = $(document.getElementById(state.selectionId));

        // Handle clicks on the selected items (will unselect an item).
        selectionElement.on('click', '[role=option]', function(e) {
            var pendingPromise = addPendingJSPromise('form-autocomplete-clicks');

            // Remove it from the selection.
            pendingPromise.resolve(deselectItem(options, state, $(e.currentTarget), originalSelect));
        });

        // When listbox is focused, focus on the first option if there is no focused option.
        selectionElement.on('focus', function() {
            updateActiveSelectionFromState(state);
        });

        // Keyboard navigation for the selection list.
        selectionElement.on('keydown', function(e) {
            var pendingPromise = addPendingJSPromise('form-autocomplete-keydown-' + e.keyCode);
            switch (e.keyCode) {
                case KEYS.RIGHT:
                case KEYS.DOWN:
                    // We handled this event, so prevent it.
                    e.preventDefault();

                    // Choose the next selection item.
                    pendingPromise.resolve(activateNextSelection(state));
                    return;
                case KEYS.LEFT:
                case KEYS.UP:
                    // We handled this event, so prevent it.
                    e.preventDefault();

                    // Choose the previous selection item.
                    pendingPromise.resolve(activatePreviousSelection(state));
                    return;
                case KEYS.SPACE:
                case KEYS.ENTER:
                    // Get the item that is currently selected.
                    var selectedItem = $(document.getElementById(state.selectionId)).children('[data-active-selection]');
                    if (selectedItem) {
                        e.preventDefault();

                        // Unselect this item.
                        pendingPromise.resolve(deselectItem(options, state, selectedItem, originalSelect));
                    }
                    return;
            }

            // Not handled. Resolve the promise.
            pendingPromise.resolve();
        });
        // Whenever the input field changes, update the suggestion list.
        if (options.showSuggestions) {
            // Store the value of the field as its last value, when the field gains focus.
            inputElement.on('focus', function(e) {
                var query = $(e.currentTarget).val();
                $(e.currentTarget).data('last-value', query);
            });

            // If this field uses ajax, set it up.
            if (options.ajax) {
                require([options.ajax], function(ajaxHandler) {
                    // Creating throttled handlers free of race conditions, and accurate.
                    // This code keeps track of a throttleTimeout, which is periodically polled.
                    // Once the throttled function is executed, the fact that it is running is noted.
                    // If a subsequent request comes in whilst it is running, this request is re-applied.
                    var throttleTimeout = null;
                    var inProgress = false;
                    var pendingKey = 'autocomplete-throttledhandler';
                    var handler = function(e) {
                        // Empty the current timeout.
                        throttleTimeout = null;

                        // Mark this request as in-progress.
                        inProgress = true;

                        // Process the request.
                        updateAjax(e, options, state, originalSelect, ajaxHandler)
                        .then(function() {
                            // Check if the throttleTimeout is still empty.
                            // There's a potential condition whereby the JS request takes long enough to complete that
                            // another task has been queued.
                            // In this case another task will be kicked off and we must wait for that before marking htis as
                            // complete.
                            if (null === throttleTimeout) {
                                // Mark this task as complete.
                                M.util.js_complete(pendingKey);
                            }
                            inProgress = false;

                            return arguments[0];
                        })
                        .catch(notification.exception);
                    };

                    // For input events, we do not want to trigger many, many updates.
                    var throttledHandler = function(e) {
                        window.clearTimeout(throttleTimeout);
                        if (inProgress) {
                            // A request is currently ongoing.
                            // Delay this request another 100ms.
                            throttleTimeout = window.setTimeout(throttledHandler.bind(this, e), 100);
                            return;
                        }

                        if (throttleTimeout === null) {
                            // There is currently no existing timeout handler, and it has not been recently cleared, so
                            // this is the start of a throttling check.
                            M.util.js_pending(pendingKey);
                        }

                        // There is currently no existing timeout handler, and it has not been recently cleared, so this
                        // is the start of a throttling check.
                        // Queue a call to the handler.
                        throttleTimeout = window.setTimeout(handler.bind(this, e), 300);
                    };

                    // Trigger an ajax update after the text field value changes.
                    inputElement.on('input', function(e) {
                        var query = $(e.currentTarget).val();
                        var last = $(e.currentTarget).data('last-value');
                        // IE11 fires many more input events than required - even when the value has not changed.
                        if (last !== query) {
                            throttledHandler(e);
                        }
                        $(e.currentTarget).data('last-value', query);
                    });
                });
            } else {
                inputElement.on('input', function(e) {
                    var query = $(e.currentTarget).val();
                    var last = $(e.currentTarget).data('last-value');
                    // IE11 fires many more input events than required - even when the value has not changed.
                    // We need to only do this for real value changed events or the suggestions will be
                    // unclickable on IE11 (because they will be rebuilt before the click event fires).
                    // Note - because of this we cannot close the list when the query is empty or it will break
                    // on IE11.
                    if (last !== query) {
                        updateSuggestions(options, state, query, originalSelect);
                    }
                    $(e.currentTarget).data('last-value', query);
                });
            }
        }
    };

    /**
     * Create and return an unresolved Promise for some pending JS.
     *
     * @param   {String} key The unique identifier for this promise
     * @return  {Promise}
     */
    var addPendingJSPromise = function(key) {
            var pendingKey = 'form-autocomplete:' + key;

            M.util.js_pending(pendingKey);

            var pendingPromise = $.Deferred();

            pendingPromise
            .then(function() {
                M.util.js_complete(pendingKey);

                return arguments[0];
            })
            .catch(notification.exception);

            return pendingPromise;
    };

    /**
     * Turn a boring select box into an auto-complete beast.
     *
     * @method enhanceField
     * @param {string} selector The selector that identifies the select box.
     * @param {boolean} tags Whether to allow support for tags (can define new entries).
     * @param {string} ajax Name of an AMD module to handle ajax requests. If specified, the AMD
     *                      module must expose 2 functions "transport" and "processResults".
     *                      These are modeled on Select2 see: https://select2.github.io/options.html#ajax
     * @param {String|Promise<string>} placeholder - The text to display before a selection is made.
     * @param {Boolean} caseSensitive - If search has to be made case sensitive.
     * @param {Boolean} showSuggestions - If suggestions should be shown
     * @param {String|Promise<string>} noSelectionString - Text to display when there is no selection
     * @param {Boolean} closeSuggestionsOnSelect - Whether to close the suggestions immediately after making a selection.
     * @param {Object} templateOverrides A set of templates to use instead of the standard templates
     * @return {Promise}
     */
     var enhanceField = async function(selector, tags, ajax, placeholder, caseSensitive, showSuggestions, noSelectionString,
                          closeSuggestionsOnSelect, templateOverrides) {
            // Set some default values.
            var options = {
                selector: selector,
                tags: false,
                ajax: false,
                placeholder: await placeholder,
                caseSensitive: false,
                showSuggestions: true,
                noSelectionString: await noSelectionString,
                templates: $.extend({
                        input: 'core/form_autocomplete_input',
                        items: 'core/form_autocomplete_selection_items',
                        layout: 'core/form_autocomplete_layout',
                        selection: 'core/form_autocomplete_selection',
                        suggestions: 'core/form_autocomplete_suggestions',
                    }, templateOverrides),
            };
            var pendingKey = 'autocomplete-setup-' + selector;
            M.util.js_pending(pendingKey);
            if (typeof tags !== "undefined") {
                options.tags = tags;
            }
            if (typeof ajax !== "undefined") {
                options.ajax = ajax;
            }
            if (typeof caseSensitive !== "undefined") {
                options.caseSensitive = caseSensitive;
            }
            if (typeof showSuggestions !== "undefined") {
                options.showSuggestions = showSuggestions;
            }
            if (typeof noSelectionString === "undefined") {
                str.get_string('noselection', 'form').done(function(result) {
                    options.noSelectionString = result;
                }).fail(notification.exception);
            }

            // Look for the select element.
            var originalSelect = $(selector);
            if (!originalSelect) {
                log.debug('Selector not found: ' + selector);
                M.util.js_complete(pendingKey);
                return false;
            }

            // Ensure we enhance the element only once.
            if (originalSelect.data('enhanced') === 'enhanced') {
                M.util.js_complete(pendingKey);
                return false;
            }
            originalSelect.data('enhanced', 'enhanced');

            // Hide the original select.
            Aria.hide(originalSelect.get());
            originalSelect.css('visibility', 'hidden');

            // Find or generate some ids.
            var state = {
                selectId: originalSelect.attr('id'),
                inputId: 'form_autocomplete_input-' + uniqueId,
                suggestionsId: 'form_autocomplete_suggestions-' + uniqueId,
                selectionId: 'form_autocomplete_selection-' + uniqueId,
                downArrowId: 'form_autocomplete_downarrow-' + uniqueId,
                items: [],
                required: originalSelect[0]?.ariaRequired === 'true',
            };

            // Increment the unique counter so we don't get duplicates ever.
            uniqueId++;

            options.multiple = originalSelect.attr('multiple');
            if (!options.multiple) {
                // If this is a single select then there is no way to de-select the current value -
                // unless we add a bogus blank option to be selected when nothing else is.
                // This matches similar code in updateAjax above.
                originalSelect.prepend('<option>');
            }

            if (typeof closeSuggestionsOnSelect !== "undefined") {
                options.closeSuggestionsOnSelect = closeSuggestionsOnSelect;
            } else {
                // If not specified, this will close suggestions by default for single-select elements only.
                options.closeSuggestionsOnSelect = !options.multiple;
            }

            var originalLabel = $('[for=' + state.selectId + ']');
            // Create the new markup and insert it after the select.
            var suggestions = rebuildOptions(originalSelect.children('option'), true);

            // Render all the parts of our UI.
            var context = $.extend({}, options, state);
            context.options = suggestions;
            context.items = [];

            // Collect rendered inline JS to be executed once the HTML is shown.
            var collectedjs = '';

            var renderLayout = templates.render(options.templates.layout, {})
            .then(function(html) {
                return $(html);
            });

            var renderInput = templates.render(options.templates.input, context).then(function(html, js) {
                collectedjs += js;
                return $(html);
            });

            var renderDatalist = templates.render(options.templates.suggestions, context).then(function(html, js) {
                collectedjs += js;
                return $(html);
            });

            var renderSelection = templates.render(options.templates.selection, context).then(function(html, js) {
                collectedjs += js;
                return $(html);
            });

            return Promise.all([renderLayout, renderInput, renderDatalist, renderSelection])
            .then(function([layout, input, suggestions, selection]) {
                originalSelect.hide();
                var container = originalSelect.parent();

                // Ensure that the data-fieldtype is set for behat.
                input.find('input').attr('data-fieldtype', 'autocomplete');

                container.append(layout);
                container.find('[data-region="form_autocomplete-input"]').replaceWith(input);
                container.find('[data-region="form_autocomplete-suggestions"]').replaceWith(suggestions);
                container.find('[data-region="form_autocomplete-selection"]').replaceWith(selection);

                templates.runTemplateJS(collectedjs);

                // Update the form label to point to the text input.
                originalLabel.attr('for', state.inputId);
                // Add the event handlers.
                addNavigation(options, state, originalSelect);

                var suggestionsElement = $(document.getElementById(state.suggestionsId));
                // Hide the suggestions by default.
                suggestionsElement.hide();
                Aria.hide(suggestionsElement.get());

                return;
            })
            .then(function() {
                // Show the current values in the selection list.
                return updateSelectionList(options, state, originalSelect);
            })
            .then(function() {
                return M.util.js_complete(pendingKey);
            })
            .catch(function(error) {
                M.util.js_complete(pendingKey);
                notification.exception(error);
            });
    };

    return {
        // Public variables and functions.
        enhanceField: enhanceField,

        /**
         * We need to use jQuery here as some calling code uses .done() and .fail() rather than native .then() and .catch()
         *
         * @method enhance
         * @return {Promise} A jQuery promise
         */
        enhance: function() {
            return $.when(enhanceField(...arguments));
        }
    };
});