Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
// This file is part of Moodle - http://moodle.org/
2
//
3
// Moodle is free software: you can redistribute it and/or modify
4
// it under the terms of the GNU General Public License as published by
5
// the Free Software Foundation, either version 3 of the License, or
6
// (at your option) any later version.
7
//
8
// Moodle is distributed in the hope that it will be useful,
9
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
// GNU General Public License for more details.
12
//
13
// You should have received a copy of the GNU General Public License
14
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
15
 
16
/**
17
 * Autocomplete wrapper for select2 library.
18
 *
19
 * @module     core/form-autocomplete
20
 * @copyright  2015 Damyon Wiese <damyon@moodle.com>
21
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22
 * @since      3.0
23
 */
24
define([
25
    'jquery',
26
    'core/log',
27
    'core/str',
28
    'core/templates',
29
    'core/notification',
30
    'core/loadingicon',
31
    'core/aria',
32
    'core_form/changechecker',
33
], function(
34
    $,
35
    log,
36
    str,
37
    templates,
38
    notification,
39
    LoadingIcon,
40
    Aria,
41
    FormChangeChecker
42
) {
43
    // Private functions and variables.
44
    /** @var {Object} KEYS - List of keycode constants. */
45
    var KEYS = {
46
        DOWN: 40,
47
        ENTER: 13,
48
        SPACE: 32,
49
        ESCAPE: 27,
50
        COMMA: 44,
51
        UP: 38,
52
        LEFT: 37,
53
        RIGHT: 39
54
    };
55
 
56
    var uniqueId = Date.now();
57
 
58
    /**
59
     * Make an item in the selection list "active".
60
     *
61
     * @method activateSelection
62
     * @private
63
     * @param {Number} index The index in the current (visible) list of selection.
64
     * @param {Object} state State variables for this autocomplete element.
65
     * @return {Promise}
66
     */
67
    var activateSelection = function(index, state) {
68
        // Find the elements in the DOM.
69
        var selectionElement = $(document.getElementById(state.selectionId));
70
 
71
        index = wrapListIndex(index, selectionElement.children('[aria-selected=true]').length);
72
        // Find the specified element.
73
        var element = $(selectionElement.children('[aria-selected=true]').get(index));
74
        // Create an id we can assign to this element.
75
        var itemId = state.selectionId + '-' + index;
76
 
77
        // Deselect all the selections.
78
        selectionElement.children().attr('data-active-selection', null).attr('id', '');
79
 
80
        // Select only this suggestion and assign it the id.
81
        element.attr('data-active-selection', true).attr('id', itemId);
82
 
83
        // Tell the input field it has a new active descendant so the item is announced.
84
        selectionElement.attr('aria-activedescendant', itemId);
85
        selectionElement.attr('data-active-value', element.attr('data-value'));
86
 
87
        return $.Deferred().resolve();
88
    };
89
 
90
    /**
91
     * Get the actively selected element from the state object.
92
     *
93
     * @param   {Object} state
94
     * @returns {jQuery}
95
     */
96
    var getActiveElementFromState = function(state) {
97
        var selectionRegion = $(document.getElementById(state.selectionId));
98
        var activeId = selectionRegion.attr('aria-activedescendant');
99
 
100
        if (activeId) {
101
            var activeElement = $(document.getElementById(activeId));
102
            if (activeElement.length) {
103
                // The active descendent still exists.
104
                return activeElement;
105
            }
106
        }
107
 
108
        // Ensure we are creating a properly formed selector based on the active value.
109
        var activeValue = selectionRegion.attr('data-active-value')?.replace(/"/g, '\\"');
110
        return selectionRegion.find('[data-value="' + activeValue + '"]');
111
    };
112
 
113
    /**
114
     * Update the active selection from the given state object.
115
     *
116
     * @param   {Object} state
117
     */
118
    var updateActiveSelectionFromState = function(state) {
119
        var activeElement = getActiveElementFromState(state);
120
        var activeValue = activeElement.attr('data-value');
121
 
122
        var selectionRegion = $(document.getElementById(state.selectionId));
123
        if (activeValue) {
124
            // Find the index of the currently selected index.
125
            var activeIndex = selectionRegion.find('[aria-selected=true]').index(activeElement);
126
 
127
            if (activeIndex !== -1) {
128
                activateSelection(activeIndex, state);
129
                return;
130
            }
131
        }
132
 
133
        // Either the active index was not set, or it could not be found.
134
        // Select the first value instead.
135
        activateSelection(0, state);
136
    };
137
 
138
    /**
139
     * Update the element that shows the currently selected items.
140
     *
141
     * @method updateSelectionList
142
     * @private
143
     * @param {Object} options Original options for this autocomplete element.
144
     * @param {Object} state State variables for this autocomplete element.
145
     * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
146
     * @return {Promise}
147
     */
148
    var updateSelectionList = function(options, state, originalSelect) {
149
        var pendingKey = 'form-autocomplete-updateSelectionList-' + state.inputId;
150
        M.util.js_pending(pendingKey);
151
 
152
        // Build up a valid context to re-render the template.
153
        var items = rebuildOptions(originalSelect.children('option:selected'), false);
154
        var newSelection = $(document.getElementById(state.selectionId));
155
 
156
        if (!hasItemListChanged(state, items)) {
157
            M.util.js_complete(pendingKey);
158
            return Promise.resolve();
159
        }
160
 
161
        state.items = items;
162
 
163
        var context = $.extend(options, state);
164
        // Render the template.
165
        return templates.render(options.templates.items, context)
166
        .then(function(html, js) {
167
            // Add it to the page.
168
            templates.replaceNodeContents(newSelection, html, js);
169
 
170
            updateActiveSelectionFromState(state);
171
 
172
            return;
173
        })
174
        .then(function() {
175
            return M.util.js_complete(pendingKey);
176
        })
177
        .catch(notification.exception);
178
    };
179
 
180
    /**
181
     * Check whether the list of items stored in the state has changed.
182
     *
183
     * @param   {Object} state
184
     * @param   {Array} items
185
     * @returns {Boolean}
186
     */
187
    var hasItemListChanged = function(state, items) {
188
        if (state.items.length !== items.length) {
189
            return true;
190
        }
191
 
192
        // Check for any items in the state items which are not present in the new items list.
193
        return state.items.filter(item => items.indexOf(item) === -1).length > 0;
194
    };
195
 
196
    /**
197
     * Notify of a change in the selection.
198
     *
199
     * @param {jQuery} originalSelect The jQuery object matching the hidden select list.
200
     */
201
    var notifyChange = function(originalSelect) {
202
        FormChangeChecker.markFormChangedFromNode(originalSelect[0]);
203
 
204
        // Note, jQuery .change() was not working here. Better to
205
        // use plain JavaScript anyway.
206
        originalSelect[0].dispatchEvent(new Event('change', {bubbles: true}));
207
    };
208
 
209
    /**
210
     * Remove the given item from the list of selected things.
211
     *
212
     * @method deselectItem
213
     * @private
214
     * @param {Object} options Original options for this autocomplete element.
215
     * @param {Object} state State variables for this autocomplete element.
216
     * @param {Element} item The item to be deselected.
217
     * @param {Element} originalSelect The original select list.
218
     * @return {Promise}
219
     */
220
    var deselectItem = function(options, state, item, originalSelect) {
221
        var selectedItemValue = $(item).attr('data-value');
222
 
223
        // Preprend an empty option to the select list to avoid having a default selected option.
224
        if (originalSelect.find('option').first().attr('value') !== undefined) {
225
            originalSelect.prepend($('<option>'));
226
        }
227
 
228
        // Look for a match, and toggle the selected property if there is a match.
229
        originalSelect.children('option').each(function(index, ele) {
230
            if ($(ele).attr('value') == selectedItemValue) {
231
                $(ele).prop('selected', false);
232
                // We remove newly created custom tags from the suggestions list when they are deselected.
233
                if ($(ele).attr('data-iscustom')) {
234
                    $(ele).remove();
235
                }
236
            }
237
        });
238
        // Rerender the selection list.
239
        return updateSelectionList(options, state, originalSelect)
240
        .then(function() {
241
            // Notify that the selection changed.
242
            notifyChange(originalSelect);
243
 
244
            return;
245
        });
246
    };
247
 
248
    /**
249
     * Make an item in the suggestions "active" (about to be selected).
250
     *
251
     * @method activateItem
252
     * @private
253
     * @param {Number} index The index in the current (visible) list of suggestions.
254
     * @param {Object} state State variables for this instance of autocomplete.
255
     * @return {Promise}
256
     */
257
    var activateItem = function(index, state) {
258
        // Find the elements in the DOM.
259
        var inputElement = $(document.getElementById(state.inputId));
260
        var suggestionsElement = $(document.getElementById(state.suggestionsId));
261
 
262
        // Count the visible items.
263
        var length = suggestionsElement.children(':not([aria-hidden])').length;
264
        // Limit the index to the upper/lower bounds of the list (wrap in both directions).
265
        index = index % length;
266
        while (index < 0) {
267
            index += length;
268
        }
269
        // Find the specified element.
270
        var element = $(suggestionsElement.children(':not([aria-hidden])').get(index));
271
        // Find the index of this item in the full list of suggestions (including hidden).
272
        var globalIndex = $(suggestionsElement.children('[role=option]')).index(element);
273
        // Create an id we can assign to this element.
274
        var itemId = state.suggestionsId + '-' + globalIndex;
275
 
276
        // Deselect all the suggestions.
277
        suggestionsElement.children().attr('aria-selected', false).attr('id', '');
278
        // Select only this suggestion and assign it the id.
279
        element.attr('aria-selected', true).attr('id', itemId);
280
        // Tell the input field it has a new active descendant so the item is announced.
281
        inputElement.attr('aria-activedescendant', itemId);
282
 
283
        // Scroll it into view.
284
        var scrollPos = element.offset().top
285
                       - suggestionsElement.offset().top
286
                       + suggestionsElement.scrollTop()
287
                       - (suggestionsElement.height() / 2);
288
        return suggestionsElement.animate({
289
            scrollTop: scrollPos
290
        }, 100).promise();
291
    };
292
 
293
    /**
294
     * Return the index of the currently selected item in the suggestions list.
295
     *
296
     * @param {jQuery} suggestionsElement
297
     * @return {Integer}
298
     */
299
    var getCurrentItem = function(suggestionsElement) {
300
        // Find the active one.
301
        var element = suggestionsElement.children('[aria-selected=true]');
302
        // Find its index.
303
        return suggestionsElement.children(':not([aria-hidden])').index(element);
304
    };
305
 
306
    /**
307
     * Limit the index to the upper/lower bounds of the list (wrap in both directions).
308
     *
309
     * @param {Integer} index The target index.
310
     * @param {Integer} length The length of the list of visible items.
311
     * @return {Integer} The resulting index with necessary wrapping applied.
312
     */
313
    var wrapListIndex = function(index, length) {
314
        index = index % length;
315
        while (index < 0) {
316
            index += length;
317
        }
318
        return index;
319
    };
320
 
321
    /**
322
     * Return the index of the next item in the list without aria-disabled=true.
323
     *
324
     * @param {Integer} current The index of the current item.
325
     * @param {Array} suggestions The list of suggestions.
326
     * @return {Integer}
327
     */
328
    var getNextEnabledItem = function(current, suggestions) {
329
        var nextIndex = wrapListIndex(current + 1, suggestions.length);
330
        if (suggestions[nextIndex].getAttribute('aria-disabled')) {
331
            return getNextEnabledItem(nextIndex, suggestions);
332
        }
333
        return nextIndex;
334
    };
335
 
336
    /**
337
     * Return the index of the previous item in the list without aria-disabled=true.
338
     *
339
     * @param {Integer} current The index of the current item.
340
     * @param {Array} suggestions The list of suggestions.
341
     * @return {Integer}
342
     */
343
    var getPreviousEnabledItem = function(current, suggestions) {
344
        var previousIndex = wrapListIndex(current - 1, suggestions.length);
345
        if (suggestions[previousIndex].getAttribute('aria-disabled')) {
346
            return getPreviousEnabledItem(previousIndex, suggestions);
347
        }
348
        return previousIndex;
349
    };
350
 
351
    /**
352
     * Build a list of renderable options based on a set of option elements from the original select list.
353
     *
354
     * @param {jQuery} originalOptions
355
     * @param {Boolean} includeEmpty
356
     * @return {Array}
357
     */
358
    var rebuildOptions = function(originalOptions, includeEmpty) {
359
        var options = [];
360
        originalOptions.each(function(index, ele) {
361
            var label;
362
            if ($(ele).data('html')) {
363
                label = $(ele).data('html');
364
            } else {
365
                label = $(ele).html();
366
            }
367
            if (includeEmpty || label !== '') {
368
                options.push({
369
                    label: label,
370
                    value: $(ele).attr('value'),
371
                    disabled: ele.disabled,
372
                    classes: ele.classList,
373
                });
374
            }
375
        });
376
        return options;
377
    };
378
 
379
    /**
380
     * Find the index of the current active suggestion, and activate the next one.
381
     *
382
     * @method activateNextItem
383
     * @private
384
     * @param {Object} state State variable for this auto complete element.
385
     * @return {Promise}
386
     */
387
    var activateNextItem = function(state) {
388
        // Find the list of suggestions.
389
        var suggestionsElement = $(document.getElementById(state.suggestionsId));
390
        var suggestions = suggestionsElement.children(':not([aria-hidden])');
391
        var current = getCurrentItem(suggestionsElement);
392
        // Activate the next one.
393
        return activateItem(getNextEnabledItem(current, suggestions), state);
394
    };
395
 
396
    /**
397
     * Find the index of the current active selection, and activate the previous one.
398
     *
399
     * @method activatePreviousSelection
400
     * @private
401
     * @param {Object} state State variables for this instance of autocomplete.
402
     * @return {Promise}
403
     */
404
    var activatePreviousSelection = function(state) {
405
        // Find the list of selections.
406
        var selectionsElement = $(document.getElementById(state.selectionId));
407
        // Find the active one.
408
        var element = selectionsElement.children('[data-active-selection]');
409
        if (!element) {
410
            return activateSelection(0, state);
411
        }
412
        // Find it's index.
413
        var current = selectionsElement.children('[aria-selected=true]').index(element);
414
        // Activate the next one.
415
        return activateSelection(current - 1, state);
416
    };
417
 
418
    /**
419
     * Find the index of the current active selection, and activate the next one.
420
     *
421
     * @method activateNextSelection
422
     * @private
423
     * @param {Object} state State variables for this instance of autocomplete.
424
     * @return {Promise}
425
     */
426
    var activateNextSelection = function(state) {
427
        // Find the list of selections.
428
        var selectionsElement = $(document.getElementById(state.selectionId));
429
 
430
        // Find the active one.
431
        var element = selectionsElement.children('[data-active-selection]');
432
        var current = 0;
433
 
434
        if (element) {
435
            // The element was found. Determine the index and move to the next one.
436
            current = selectionsElement.children('[aria-selected=true]').index(element);
437
            current = current + 1;
438
        } else {
439
            // No selected item found. Move to the first.
440
            current = 0;
441
        }
442
 
443
        return activateSelection(current, state);
444
    };
445
 
446
    /**
447
     * Find the index of the current active suggestion, and activate the previous one.
448
     *
449
     * @method activatePreviousItem
450
     * @private
451
     * @param {Object} state State variables for this autocomplete element.
452
     * @return {Promise}
453
     */
454
    var activatePreviousItem = function(state) {
455
        var suggestionsElement = $(document.getElementById(state.suggestionsId));
456
        var suggestions = suggestionsElement.children(':not([aria-hidden])');
457
        var current = getCurrentItem(suggestionsElement);
458
        // Activate the previous one.
459
        return activateItem(getPreviousEnabledItem(current, suggestions), state);
460
    };
461
 
462
    /**
463
     * Close the list of suggestions.
464
     *
465
     * @method closeSuggestions
466
     * @private
467
     * @param {Object} state State variables for this autocomplete element.
468
     * @return {Promise}
469
     */
470
    var closeSuggestions = function(state) {
471
        // Find the elements in the DOM.
472
        var inputElement = $(document.getElementById(state.inputId));
473
        var suggestionsElement = $(document.getElementById(state.suggestionsId));
474
 
475
        if (inputElement.attr('aria-expanded') === "true") {
476
            // Announce the list of suggestions was closed.
477
            inputElement.attr('aria-expanded', false);
478
        }
479
        // Read the current list of selections.
480
        inputElement.attr('aria-activedescendant', state.selectionId);
481
 
482
        // Hide the suggestions list (from screen readers too).
483
        Aria.hide(suggestionsElement.get());
484
        suggestionsElement.hide();
485
 
486
        return $.Deferred().resolve();
487
    };
488
 
489
    /**
490
     * Rebuild the list of suggestions based on the current values in the select list, and the query.
491
     *
492
     * @method updateSuggestions
493
     * @private
494
     * @param {Object} options The original options for this autocomplete.
495
     * @param {Object} state The state variables for this autocomplete.
496
     * @param {String} query The current text for the search string.
497
     * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
498
     * @return {Promise}
499
     */
500
    var updateSuggestions = function(options, state, query, originalSelect) {
501
        var pendingKey = 'form-autocomplete-updateSuggestions-' + state.inputId;
502
        M.util.js_pending(pendingKey);
503
 
504
        // Find the elements in the DOM.
505
        var inputElement = $(document.getElementById(state.inputId));
506
        var suggestionsElement = $(document.getElementById(state.suggestionsId));
507
 
508
        // Used to track if we found any visible suggestions.
509
        var matchingElements = false;
510
        // Options is used by the context when rendering the suggestions from a template.
511
        var suggestions = rebuildOptions(originalSelect.children('option:not(:selected)'), true);
512
 
513
        // Re-render the list of suggestions.
514
        var searchquery = state.caseSensitive ? query : query.toLocaleLowerCase();
515
        var context = $.extend({options: suggestions}, options, state);
516
        var returnVal = templates.render(
517
            'core/form_autocomplete_suggestions',
518
            context
519
        )
520
        .then(function(html, js) {
521
            // We have the new template, insert it in the page.
522
            templates.replaceNode(suggestionsElement, html, js);
523
 
524
            // Get the element again.
525
            suggestionsElement = $(document.getElementById(state.suggestionsId));
526
 
527
            // Show it if it is hidden.
528
            Aria.unhide(suggestionsElement.get());
529
            suggestionsElement.show();
530
 
531
            // For each option in the list, hide it if it doesn't match the query.
532
            suggestionsElement.children().each(function(index, node) {
533
                node = $(node);
534
                if ((options.caseSensitive && node.text().indexOf(searchquery) > -1) ||
535
                        (!options.caseSensitive && node.text().toLocaleLowerCase().indexOf(searchquery) > -1)) {
536
                    Aria.unhide(node.get());
537
                    node.show();
538
                    matchingElements = true;
539
                } else {
540
                    node.hide();
541
                    Aria.hide(node.get());
542
                }
543
            });
544
            // If we found any matches, show the list.
545
            inputElement.attr('aria-expanded', true);
546
            if (originalSelect.attr('data-notice')) {
547
                // Display a notice rather than actual suggestions.
548
                suggestionsElement.html(originalSelect.attr('data-notice'));
549
            } else if (matchingElements) {
550
                // We only activate the first item in the list if tags is false,
551
                // because otherwise "Enter" would select the first item, instead of
552
                // creating a new tag.
553
                if (!options.tags) {
554
                    activateItem(0, state);
555
                }
556
            } else {
557
                // Nothing matches. Tell them that.
558
                str.get_string('nosuggestions', 'form').done(function(nosuggestionsstr) {
559
                    suggestionsElement.html(nosuggestionsstr);
560
                });
561
            }
562
 
563
            return suggestionsElement;
564
        })
565
        .then(function() {
566
            return M.util.js_complete(pendingKey);
567
        })
568
        .catch(notification.exception);
569
 
570
        return returnVal;
571
    };
572
 
573
    /**
574
     * Create a new item for the list (a tag).
575
     *
576
     * @method createItem
577
     * @private
578
     * @param {Object} options The original options for the autocomplete.
579
     * @param {Object} state State variables for the autocomplete.
580
     * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
581
     * @return {Promise}
582
     */
583
    var createItem = function(options, state, originalSelect) {
584
        // Find the element in the DOM.
585
        var inputElement = $(document.getElementById(state.inputId));
586
        // Get the current text in the input field.
587
        var query = inputElement.val();
588
        var tags = query.split(',');
589
        var found = false;
590
 
591
        $.each(tags, function(tagindex, tag) {
592
            // If we can only select one at a time, deselect any current value.
593
            tag = tag.trim();
594
            if (tag !== '') {
595
                if (!options.multiple) {
596
                    originalSelect.children('option').prop('selected', false);
597
                }
598
                // Look for an existing option in the select list that matches this new tag.
599
                originalSelect.children('option').each(function(index, ele) {
600
                    if ($(ele).attr('value') == tag) {
601
                        found = true;
602
                        $(ele).prop('selected', true);
603
                    }
604
                });
605
                // Only create the item if it's new.
606
                if (!found) {
607
                    var option = $('<option>');
608
                    option.append(document.createTextNode(tag));
609
                    option.attr('value', tag);
610
                    originalSelect.append(option);
611
                    option.prop('selected', true);
612
                    // We mark newly created custom options as we handle them differently if they are "deselected".
613
                    option.attr('data-iscustom', true);
614
                }
615
            }
616
        });
617
 
618
        return updateSelectionList(options, state, originalSelect)
619
        .then(function() {
620
            // Notify that the selection changed.
621
            notifyChange(originalSelect);
622
 
623
            return;
624
        })
625
        .then(function() {
626
            // Clear the input field.
627
            inputElement.val('');
628
 
629
            return;
630
        })
631
        .then(function() {
632
            // Close the suggestions list.
633
            return closeSuggestions(state);
634
        });
635
    };
636
 
637
    /**
638
     * Select the currently active item from the suggestions list.
639
     *
640
     * @method selectCurrentItem
641
     * @private
642
     * @param {Object} options The original options for the autocomplete.
643
     * @param {Object} state State variables for the autocomplete.
644
     * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
645
     * @return {Promise}
646
     */
647
    var selectCurrentItem = function(options, state, originalSelect) {
648
        // Find the elements in the page.
649
        var inputElement = $(document.getElementById(state.inputId));
650
        var suggestionsElement = $(document.getElementById(state.suggestionsId));
651
        // Here loop through suggestions and set val to join of all selected items.
652
 
653
        var selectedItemValue = suggestionsElement.children('[aria-selected=true]').attr('data-value');
654
        // The select will either be a single or multi select, so the following will either
655
        // select one or more items correctly.
656
        // Take care to use 'prop' and not 'attr' for selected properties.
657
        // If only one can be selected at a time, start by deselecting everything.
658
        if (!options.multiple) {
659
            originalSelect.children('option').prop('selected', false);
660
        }
661
        // Look for a match, and toggle the selected property if there is a match.
662
        originalSelect.children('option').each(function(index, ele) {
663
            if ($(ele).attr('value') == selectedItemValue) {
664
                $(ele).prop('selected', true);
665
            }
666
        });
667
 
668
        return updateSelectionList(options, state, originalSelect)
669
        .then(function() {
670
            // Notify that the selection changed.
671
            notifyChange(originalSelect);
672
 
673
            return;
674
        })
675
        .then(function() {
676
            if (options.closeSuggestionsOnSelect) {
677
                // Clear the input element.
678
                inputElement.val('');
679
                // Close the list of suggestions.
680
                return closeSuggestions(state);
681
            } else {
682
                // Focus on the input element so the suggestions does not auto-close.
683
                inputElement.focus();
684
                // Remove the last selected item from the suggestions list.
685
                return updateSuggestions(options, state, inputElement.val(), originalSelect);
686
            }
687
        });
688
    };
689
 
690
    /**
691
     * Fetch a new list of options via ajax.
692
     *
693
     * @method updateAjax
694
     * @private
695
     * @param {Event} e The event that triggered this update.
696
     * @param {Object} options The original options for the autocomplete.
697
     * @param {Object} state The state variables for the autocomplete.
698
     * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
699
     * @param {Object} ajaxHandler This is a module that does the ajax fetch and translates the results.
700
     * @return {Promise}
701
     */
702
    var updateAjax = function(e, options, state, originalSelect, ajaxHandler) {
703
        var pendingPromise = addPendingJSPromise('updateAjax');
704
        // We need to show the indicator outside of the hidden select list.
705
        // So we get the parent id of the hidden select list.
706
        var parentElement = $(document.getElementById(state.selectId)).parent();
707
        LoadingIcon.addIconToContainerRemoveOnCompletion(parentElement, pendingPromise);
708
 
709
        // Get the query to pass to the ajax function.
710
        var query = $(e.currentTarget).val();
711
        // Call the transport function to do the ajax (name taken from Select2).
712
        ajaxHandler.transport(options.selector, query, function(results) {
713
            // We got a result - pass it through the translator before using it.
714
            var processedResults = ajaxHandler.processResults(options.selector, results);
715
            var existingValues = [];
716
 
717
            // Now destroy all options that are not current
718
            originalSelect.children('option').each(function(optionIndex, option) {
719
                option = $(option);
720
                if (!option.prop('selected')) {
721
                    option.remove();
722
                } else {
723
                    existingValues.push(String(option.attr('value')));
724
                }
725
            });
726
 
727
            if (!options.multiple && originalSelect.children('option').length === 0) {
728
                // If this is a single select - and there are no current options
729
                // the first option added will be selected by the browser. This causes a bug!
730
                // We need to insert an empty option so that none of the real options are selected.
731
                var option = $('<option>');
732
                originalSelect.append(option);
733
            }
734
            if ($.isArray(processedResults)) {
735
                // Add all the new ones returned from ajax.
736
                $.each(processedResults, function(resultIndex, result) {
737
                    if (existingValues.indexOf(String(result.value)) === -1) {
738
                        var option = $('<option>');
739
                        option.append(result.label);
740
                        option.attr('value', result.value);
741
                        originalSelect.append(option);
742
                    }
743
                });
744
                originalSelect.attr('data-notice', '');
745
            } else {
746
                // The AJAX handler returned a string instead of the array.
747
                originalSelect.attr('data-notice', processedResults);
748
            }
749
            // Update the list of suggestions now from the new values in the select list.
750
            pendingPromise.resolve(updateSuggestions(options, state, '', originalSelect));
751
        }, function(error) {
752
            pendingPromise.reject(error);
753
        });
754
 
755
        return pendingPromise;
756
    };
757
 
758
    /**
759
     * Add all the event listeners required for keyboard nav, blur clicks etc.
760
     *
761
     * @method addNavigation
762
     * @private
763
     * @param {Object} options The options used to create this autocomplete element.
764
     * @param {Object} state State variables for this autocomplete element.
765
     * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
766
     */
767
    var addNavigation = function(options, state, originalSelect) {
768
        // Start with the input element.
769
        var inputElement = $(document.getElementById(state.inputId));
770
        // Add keyboard nav with keydown.
771
        inputElement.on('keydown', function(e) {
772
            var pendingJsPromise = addPendingJSPromise('addNavigation-' + state.inputId + '-' + e.keyCode);
773
 
774
            switch (e.keyCode) {
775
                case KEYS.DOWN:
776
                    // If the suggestion list is open, move to the next item.
777
                    if (!options.showSuggestions) {
778
                        // Do not consume this event.
779
                        pendingJsPromise.resolve();
780
                        return true;
781
                    } else if (inputElement.attr('aria-expanded') === "true") {
782
                        pendingJsPromise.resolve(activateNextItem(state));
783
                    } else {
784
                        // Handle ajax population of suggestions.
785
                        if (!inputElement.val() && options.ajax) {
786
                            require([options.ajax], function(ajaxHandler) {
787
                                pendingJsPromise.resolve(updateAjax(e, options, state, originalSelect, ajaxHandler));
788
                            });
789
                        } else {
790
                            // Open the suggestions list.
791
                            pendingJsPromise.resolve(updateSuggestions(options, state, inputElement.val(), originalSelect));
792
                        }
793
                    }
794
                    // We handled this event, so prevent it.
795
                    e.preventDefault();
796
                    return false;
797
                case KEYS.UP:
798
                    // Choose the previous active item.
799
                    pendingJsPromise.resolve(activatePreviousItem(state));
800
 
801
                    // We handled this event, so prevent it.
802
                    e.preventDefault();
803
                    return false;
804
                case KEYS.ENTER:
805
                    var suggestionsElement = $(document.getElementById(state.suggestionsId));
806
                    if ((inputElement.attr('aria-expanded') === "true") &&
807
                            (suggestionsElement.children('[aria-selected=true]').length > 0)) {
808
                        // If the suggestion list has an active item, select it.
809
                        pendingJsPromise.resolve(selectCurrentItem(options, state, originalSelect));
810
                    } else if (options.tags) {
811
                        // If tags are enabled, create a tag.
812
                        pendingJsPromise.resolve(createItem(options, state, originalSelect));
813
                    } else {
814
                        pendingJsPromise.resolve();
815
                    }
816
 
817
                    // We handled this event, so prevent it.
818
                    e.preventDefault();
819
                    return false;
820
                case KEYS.ESCAPE:
821
                    if (inputElement.attr('aria-expanded') === "true") {
822
                        // If the suggestion list is open, close it.
823
                        pendingJsPromise.resolve(closeSuggestions(state));
824
                    } else {
825
                        pendingJsPromise.resolve();
826
                    }
827
                    // We handled this event, so prevent it.
828
                    e.preventDefault();
829
                    return false;
830
            }
831
            pendingJsPromise.resolve();
832
            return true;
833
        });
834
        // Support multi lingual COMMA keycode (44).
835
        inputElement.on('keypress', function(e) {
836
 
837
            if (e.keyCode === KEYS.COMMA) {
838
                if (options.tags) {
839
                    // If we are allowing tags, comma should create a tag (or enter).
840
                    addPendingJSPromise('keypress-' + e.keyCode)
841
                    .resolve(createItem(options, state, originalSelect));
842
                }
843
                // We handled this event, so prevent it.
844
                e.preventDefault();
845
                return false;
846
            }
847
            return true;
848
        });
849
        // Support submitting the form without leaving the autocomplete element,
850
        // or submitting too quick before the blur handler action is completed.
851
        inputElement.closest('form').on('submit', function() {
852
            if (options.tags) {
853
                // If tags are enabled, create a tag.
854
                addPendingJSPromise('form-autocomplete-submit')
855
                .resolve(createItem(options, state, originalSelect));
856
            }
857
 
858
            return true;
859
        });
860
        inputElement.on('blur', function() {
861
            var pendingPromise = addPendingJSPromise('form-autocomplete-blur');
862
            window.setTimeout(function() {
863
                // Get the current element with focus.
864
                var focusElement = $(document.activeElement);
865
                var timeoutPromise = $.Deferred();
866
 
867
                // Only close the menu if the input hasn't regained focus and if the element still exists,
868
                // and regain focus if the scrollbar is clicked.
869
                // Due to the half a second delay, it is possible that the input element no longer exist
870
                // by the time this code is being executed.
871
                if (focusElement.is(document.getElementById(state.suggestionsId))) {
872
                    inputElement.focus(); // Probably the scrollbar is clicked. Regain focus.
873
                } else if (!focusElement.is(inputElement) && $(document.getElementById(state.inputId)).length) {
874
                    if (options.tags) {
875
                        timeoutPromise.then(function() {
876
                            return createItem(options, state, originalSelect);
877
                        })
878
                        .catch();
879
                    }
880
                    timeoutPromise.then(function() {
881
                        return closeSuggestions(state);
882
                    })
883
                    .catch();
884
                }
885
 
886
                timeoutPromise.then(function() {
887
                    return pendingPromise.resolve();
888
                })
889
                .catch();
890
                timeoutPromise.resolve();
891
            }, 500);
892
        });
893
        if (options.showSuggestions) {
894
            var arrowElement = $(document.getElementById(state.downArrowId));
895
            arrowElement.on('click', function(e) {
896
                var pendingPromise = addPendingJSPromise('form-autocomplete-show-suggestions');
897
 
898
                // Prevent the close timer, or we will open, then close the suggestions.
899
                inputElement.focus();
900
 
901
                // Handle ajax population of suggestions.
902
                if (!inputElement.val() && options.ajax) {
903
                    require([options.ajax], function(ajaxHandler) {
904
                        pendingPromise.resolve(updateAjax(e, options, state, originalSelect, ajaxHandler));
905
                    });
906
                } else {
907
                    // Else - open the suggestions list.
908
                    pendingPromise.resolve(updateSuggestions(options, state, inputElement.val(), originalSelect));
909
                }
910
            });
911
        }
912
 
913
        var suggestionsElement = $(document.getElementById(state.suggestionsId));
914
        // Remove any click handler first.
915
        suggestionsElement.parent().prop("onclick", null).off("click");
916
        suggestionsElement.parent().on('click', `#${state.suggestionsId} [role=option]`, function(e) {
917
            var pendingPromise = addPendingJSPromise('form-autocomplete-parent');
918
            // Handle clicks on suggestions.
919
            var element = $(e.currentTarget).closest('[role=option]');
920
            var suggestionsElement = $(document.getElementById(state.suggestionsId));
921
            // Find the index of the clicked on suggestion.
922
            var current = suggestionsElement.children(':not([aria-hidden])').index(element);
923
 
924
            // Activate it.
925
            activateItem(current, state)
926
            .then(function() {
927
                // And select it.
928
                return selectCurrentItem(options, state, originalSelect);
929
            })
930
            .then(function() {
931
                return pendingPromise.resolve();
932
            })
933
            .catch();
934
        });
935
        var selectionElement = $(document.getElementById(state.selectionId));
936
 
937
        // Handle clicks on the selected items (will unselect an item).
938
        selectionElement.on('click', '[role=option]', function(e) {
939
            var pendingPromise = addPendingJSPromise('form-autocomplete-clicks');
940
 
941
            // Remove it from the selection.
942
            pendingPromise.resolve(deselectItem(options, state, $(e.currentTarget), originalSelect));
943
        });
944
 
945
        // When listbox is focused, focus on the first option if there is no focused option.
946
        selectionElement.on('focus', function() {
947
            updateActiveSelectionFromState(state);
948
        });
949
 
950
        // Keyboard navigation for the selection list.
951
        selectionElement.on('keydown', function(e) {
952
            var pendingPromise = addPendingJSPromise('form-autocomplete-keydown-' + e.keyCode);
953
            switch (e.keyCode) {
954
                case KEYS.RIGHT:
955
                case KEYS.DOWN:
956
                    // We handled this event, so prevent it.
957
                    e.preventDefault();
958
 
959
                    // Choose the next selection item.
960
                    pendingPromise.resolve(activateNextSelection(state));
961
                    return;
962
                case KEYS.LEFT:
963
                case KEYS.UP:
964
                    // We handled this event, so prevent it.
965
                    e.preventDefault();
966
 
967
                    // Choose the previous selection item.
968
                    pendingPromise.resolve(activatePreviousSelection(state));
969
                    return;
970
                case KEYS.SPACE:
971
                case KEYS.ENTER:
972
                    // Get the item that is currently selected.
973
                    var selectedItem = $(document.getElementById(state.selectionId)).children('[data-active-selection]');
974
                    if (selectedItem) {
975
                        e.preventDefault();
976
 
977
                        // Unselect this item.
978
                        pendingPromise.resolve(deselectItem(options, state, selectedItem, originalSelect));
979
                    }
980
                    return;
981
            }
982
 
983
            // Not handled. Resolve the promise.
984
            pendingPromise.resolve();
985
        });
986
        // Whenever the input field changes, update the suggestion list.
987
        if (options.showSuggestions) {
988
            // Store the value of the field as its last value, when the field gains focus.
989
            inputElement.on('focus', function(e) {
990
                var query = $(e.currentTarget).val();
991
                $(e.currentTarget).data('last-value', query);
992
            });
993
 
994
            // If this field uses ajax, set it up.
995
            if (options.ajax) {
996
                require([options.ajax], function(ajaxHandler) {
997
                    // Creating throttled handlers free of race conditions, and accurate.
998
                    // This code keeps track of a throttleTimeout, which is periodically polled.
999
                    // Once the throttled function is executed, the fact that it is running is noted.
1000
                    // If a subsequent request comes in whilst it is running, this request is re-applied.
1001
                    var throttleTimeout = null;
1002
                    var inProgress = false;
1003
                    var pendingKey = 'autocomplete-throttledhandler';
1004
                    var handler = function(e) {
1005
                        // Empty the current timeout.
1006
                        throttleTimeout = null;
1007
 
1008
                        // Mark this request as in-progress.
1009
                        inProgress = true;
1010
 
1011
                        // Process the request.
1012
                        updateAjax(e, options, state, originalSelect, ajaxHandler)
1013
                        .then(function() {
1014
                            // Check if the throttleTimeout is still empty.
1015
                            // There's a potential condition whereby the JS request takes long enough to complete that
1016
                            // another task has been queued.
1017
                            // In this case another task will be kicked off and we must wait for that before marking htis as
1018
                            // complete.
1019
                            if (null === throttleTimeout) {
1020
                                // Mark this task as complete.
1021
                                M.util.js_complete(pendingKey);
1022
                            }
1023
                            inProgress = false;
1024
 
1025
                            return arguments[0];
1026
                        })
1027
                        .catch(notification.exception);
1028
                    };
1029
 
1030
                    // For input events, we do not want to trigger many, many updates.
1031
                    var throttledHandler = function(e) {
1032
                        window.clearTimeout(throttleTimeout);
1033
                        if (inProgress) {
1034
                            // A request is currently ongoing.
1035
                            // Delay this request another 100ms.
1036
                            throttleTimeout = window.setTimeout(throttledHandler.bind(this, e), 100);
1037
                            return;
1038
                        }
1039
 
1040
                        if (throttleTimeout === null) {
1041
                            // There is currently no existing timeout handler, and it has not been recently cleared, so
1042
                            // this is the start of a throttling check.
1043
                            M.util.js_pending(pendingKey);
1044
                        }
1045
 
1046
                        // There is currently no existing timeout handler, and it has not been recently cleared, so this
1047
                        // is the start of a throttling check.
1048
                        // Queue a call to the handler.
1049
                        throttleTimeout = window.setTimeout(handler.bind(this, e), 300);
1050
                    };
1051
 
1052
                    // Trigger an ajax update after the text field value changes.
1053
                    inputElement.on('input', function(e) {
1054
                        var query = $(e.currentTarget).val();
1055
                        var last = $(e.currentTarget).data('last-value');
1056
                        // IE11 fires many more input events than required - even when the value has not changed.
1057
                        if (last !== query) {
1058
                            throttledHandler(e);
1059
                        }
1060
                        $(e.currentTarget).data('last-value', query);
1061
                    });
1062
                });
1063
            } else {
1064
                inputElement.on('input', function(e) {
1065
                    var query = $(e.currentTarget).val();
1066
                    var last = $(e.currentTarget).data('last-value');
1067
                    // IE11 fires many more input events than required - even when the value has not changed.
1068
                    // We need to only do this for real value changed events or the suggestions will be
1069
                    // unclickable on IE11 (because they will be rebuilt before the click event fires).
1070
                    // Note - because of this we cannot close the list when the query is empty or it will break
1071
                    // on IE11.
1072
                    if (last !== query) {
1073
                        updateSuggestions(options, state, query, originalSelect);
1074
                    }
1075
                    $(e.currentTarget).data('last-value', query);
1076
                });
1077
            }
1078
        }
1079
    };
1080
 
1081
    /**
1082
     * Create and return an unresolved Promise for some pending JS.
1083
     *
1084
     * @param   {String} key The unique identifier for this promise
1085
     * @return  {Promise}
1086
     */
1087
    var addPendingJSPromise = function(key) {
1088
            var pendingKey = 'form-autocomplete:' + key;
1089
 
1090
            M.util.js_pending(pendingKey);
1091
 
1092
            var pendingPromise = $.Deferred();
1093
 
1094
            pendingPromise
1095
            .then(function() {
1096
                M.util.js_complete(pendingKey);
1097
 
1098
                return arguments[0];
1099
            })
1100
            .catch(notification.exception);
1101
 
1102
            return pendingPromise;
1103
    };
1104
 
1105
    /**
1106
     * Turn a boring select box into an auto-complete beast.
1107
     *
1108
     * @method enhanceField
1109
     * @param {string} selector The selector that identifies the select box.
1110
     * @param {boolean} tags Whether to allow support for tags (can define new entries).
1111
     * @param {string} ajax Name of an AMD module to handle ajax requests. If specified, the AMD
1112
     *                      module must expose 2 functions "transport" and "processResults".
1113
     *                      These are modeled on Select2 see: https://select2.github.io/options.html#ajax
1114
     * @param {String|Promise<string>} placeholder - The text to display before a selection is made.
1115
     * @param {Boolean} caseSensitive - If search has to be made case sensitive.
1116
     * @param {Boolean} showSuggestions - If suggestions should be shown
1117
     * @param {String|Promise<string>} noSelectionString - Text to display when there is no selection
1118
     * @param {Boolean} closeSuggestionsOnSelect - Whether to close the suggestions immediately after making a selection.
1119
     * @param {Object} templateOverrides A set of templates to use instead of the standard templates
1120
     * @return {Promise}
1121
     */
1122
     var enhanceField = async function(selector, tags, ajax, placeholder, caseSensitive, showSuggestions, noSelectionString,
1123
                          closeSuggestionsOnSelect, templateOverrides) {
1124
            // Set some default values.
1125
            var options = {
1126
                selector: selector,
1127
                tags: false,
1128
                ajax: false,
1129
                placeholder: await placeholder,
1130
                caseSensitive: false,
1131
                showSuggestions: true,
1132
                noSelectionString: await noSelectionString,
1133
                templates: $.extend({
1134
                        input: 'core/form_autocomplete_input',
1135
                        items: 'core/form_autocomplete_selection_items',
1136
                        layout: 'core/form_autocomplete_layout',
1137
                        selection: 'core/form_autocomplete_selection',
1138
                        suggestions: 'core/form_autocomplete_suggestions',
1139
                    }, templateOverrides),
1140
            };
1141
            var pendingKey = 'autocomplete-setup-' + selector;
1142
            M.util.js_pending(pendingKey);
1143
            if (typeof tags !== "undefined") {
1144
                options.tags = tags;
1145
            }
1146
            if (typeof ajax !== "undefined") {
1147
                options.ajax = ajax;
1148
            }
1149
            if (typeof caseSensitive !== "undefined") {
1150
                options.caseSensitive = caseSensitive;
1151
            }
1152
            if (typeof showSuggestions !== "undefined") {
1153
                options.showSuggestions = showSuggestions;
1154
            }
1155
            if (typeof noSelectionString === "undefined") {
1156
                str.get_string('noselection', 'form').done(function(result) {
1157
                    options.noSelectionString = result;
1158
                }).fail(notification.exception);
1159
            }
1160
 
1161
            // Look for the select element.
1162
            var originalSelect = $(selector);
1163
            if (!originalSelect) {
1164
                log.debug('Selector not found: ' + selector);
1165
                M.util.js_complete(pendingKey);
1166
                return false;
1167
            }
1168
 
1169
            // Ensure we enhance the element only once.
1170
            if (originalSelect.data('enhanced') === 'enhanced') {
1171
                M.util.js_complete(pendingKey);
1172
                return false;
1173
            }
1174
            originalSelect.data('enhanced', 'enhanced');
1175
 
1176
            // Hide the original select.
1177
            Aria.hide(originalSelect.get());
1178
            originalSelect.css('visibility', 'hidden');
1179
 
1180
            // Find or generate some ids.
1181
            var state = {
1182
                selectId: originalSelect.attr('id'),
1183
                inputId: 'form_autocomplete_input-' + uniqueId,
1184
                suggestionsId: 'form_autocomplete_suggestions-' + uniqueId,
1185
                selectionId: 'form_autocomplete_selection-' + uniqueId,
1186
                downArrowId: 'form_autocomplete_downarrow-' + uniqueId,
1187
                items: [],
1188
                required: originalSelect[0]?.ariaRequired === 'true',
1189
            };
1190
 
1191
            // Increment the unique counter so we don't get duplicates ever.
1192
            uniqueId++;
1193
 
1194
            options.multiple = originalSelect.attr('multiple');
1195
            if (!options.multiple) {
1196
                // If this is a single select then there is no way to de-select the current value -
1197
                // unless we add a bogus blank option to be selected when nothing else is.
1198
                // This matches similar code in updateAjax above.
1199
                originalSelect.prepend('<option>');
1200
            }
1201
 
1202
            if (typeof closeSuggestionsOnSelect !== "undefined") {
1203
                options.closeSuggestionsOnSelect = closeSuggestionsOnSelect;
1204
            } else {
1205
                // If not specified, this will close suggestions by default for single-select elements only.
1206
                options.closeSuggestionsOnSelect = !options.multiple;
1207
            }
1208
 
1209
            var originalLabel = $('[for=' + state.selectId + ']');
1210
            // Create the new markup and insert it after the select.
1211
            var suggestions = rebuildOptions(originalSelect.children('option'), true);
1212
 
1213
            // Render all the parts of our UI.
1214
            var context = $.extend({}, options, state);
1215
            context.options = suggestions;
1216
            context.items = [];
1217
 
1218
            // Collect rendered inline JS to be executed once the HTML is shown.
1219
            var collectedjs = '';
1220
 
1221
            var renderLayout = templates.render(options.templates.layout, {})
1222
            .then(function(html) {
1223
                return $(html);
1224
            });
1225
 
1226
            var renderInput = templates.render(options.templates.input, context).then(function(html, js) {
1227
                collectedjs += js;
1228
                return $(html);
1229
            });
1230
 
1231
            var renderDatalist = templates.render(options.templates.suggestions, context).then(function(html, js) {
1232
                collectedjs += js;
1233
                return $(html);
1234
            });
1235
 
1236
            var renderSelection = templates.render(options.templates.selection, context).then(function(html, js) {
1237
                collectedjs += js;
1238
                return $(html);
1239
            });
1240
 
1241
            return Promise.all([renderLayout, renderInput, renderDatalist, renderSelection])
1242
            .then(function([layout, input, suggestions, selection]) {
1243
                originalSelect.hide();
1244
                var container = originalSelect.parent();
1245
 
1246
                // Ensure that the data-fieldtype is set for behat.
1247
                input.find('input').attr('data-fieldtype', 'autocomplete');
1248
 
1249
                container.append(layout);
1250
                container.find('[data-region="form_autocomplete-input"]').replaceWith(input);
1251
                container.find('[data-region="form_autocomplete-suggestions"]').replaceWith(suggestions);
1252
                container.find('[data-region="form_autocomplete-selection"]').replaceWith(selection);
1253
 
1254
                templates.runTemplateJS(collectedjs);
1255
 
1256
                // Update the form label to point to the text input.
1257
                originalLabel.attr('for', state.inputId);
1258
                // Add the event handlers.
1259
                addNavigation(options, state, originalSelect);
1260
 
1261
                var suggestionsElement = $(document.getElementById(state.suggestionsId));
1262
                // Hide the suggestions by default.
1263
                suggestionsElement.hide();
1264
                Aria.hide(suggestionsElement.get());
1265
 
1266
                return;
1267
            })
1268
            .then(function() {
1269
                // Show the current values in the selection list.
1270
                return updateSelectionList(options, state, originalSelect);
1271
            })
1272
            .then(function() {
1273
                return M.util.js_complete(pendingKey);
1274
            })
1275
            .catch(function(error) {
1276
                M.util.js_complete(pendingKey);
1277
                notification.exception(error);
1278
            });
1279
    };
1280
 
1281
    return {
1282
        // Public variables and functions.
1283
        enhanceField: enhanceField,
1284
 
1285
        /**
1286
         * We need to use jQuery here as some calling code uses .done() and .fail() rather than native .then() and .catch()
1287
         *
1288
         * @method enhance
1289
         * @return {Promise} A jQuery promise
1290
         */
1291
        enhance: function() {
1292
            return $.when(enhanceField(...arguments));
1293
        }
1294
    };
1295
});