Commit | Line | Data |
---|---|---|
60a1ea56 DW |
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 | * @class autocomplete | |
21 | * @package core | |
22 | * @copyright 2015 Damyon Wiese <damyon@moodle.com> | |
23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later | |
24 | * @since 3.0 | |
25 | */ | |
26 | /* globals require: false */ | |
27 | define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification'], function($, log, str, templates, notification) { | |
28 | ||
29 | // Private functions and variables. | |
30 | /** @var {Object} KEYS - List of keycode constants. */ | |
31 | var KEYS = { | |
32 | DOWN: 40, | |
33 | ENTER: 13, | |
34 | SPACE: 32, | |
35 | ESCAPE: 27, | |
e375029e | 36 | COMMA: 44, |
60a1ea56 DW |
37 | UP: 38 |
38 | }; | |
39 | ||
60a1ea56 DW |
40 | /** |
41 | * Make an item in the selection list "active". | |
42 | * | |
43 | * @method activateSelection | |
44 | * @private | |
45 | * @param {Number} index The index in the current (visible) list of selection. | |
273b2556 | 46 | * @param {Object} state State variables for this autocomplete element. |
60a1ea56 | 47 | */ |
273b2556 | 48 | var activateSelection = function(index, state) { |
60a1ea56 | 49 | // Find the elements in the DOM. |
273b2556 | 50 | var selectionElement = $(document.getElementById(state.selectionId)); |
60a1ea56 DW |
51 | |
52 | // Count the visible items. | |
53 | var length = selectionElement.children('[aria-selected=true]').length; | |
54 | // Limit the index to the upper/lower bounds of the list (wrap in both directions). | |
55 | index = index % length; | |
56 | while (index < 0) { | |
57 | index += length; | |
58 | } | |
59 | // Find the specified element. | |
60 | var element = $(selectionElement.children('[aria-selected=true]').get(index)); | |
61 | // Create an id we can assign to this element. | |
273b2556 | 62 | var itemId = state.selectionId + '-' + index; |
60a1ea56 DW |
63 | |
64 | // Deselect all the selections. | |
65 | selectionElement.children().attr('data-active-selection', false).attr('id', ''); | |
66 | // Select only this suggestion and assign it the id. | |
67 | element.attr('data-active-selection', true).attr('id', itemId); | |
68 | // Tell the input field it has a new active descendant so the item is announced. | |
69 | selectionElement.attr('aria-activedescendant', itemId); | |
70 | }; | |
71 | ||
f992dcf6 DP |
72 | /** |
73 | * Update the element that shows the currently selected items. | |
74 | * | |
75 | * @method updateSelectionList | |
76 | * @private | |
77 | * @param {Object} options Original options for this autocomplete element. | |
78 | * @param {Object} state State variables for this autocomplete element. | |
79 | * @param {JQuery} originalSelect The JQuery object matching the hidden select list. | |
80 | */ | |
81 | var updateSelectionList = function(options, state, originalSelect) { | |
82 | // Build up a valid context to re-render the template. | |
83 | var items = []; | |
84 | var newSelection = $(document.getElementById(state.selectionId)); | |
85 | var activeId = newSelection.attr('aria-activedescendant'); | |
86 | var activeValue = false; | |
87 | ||
88 | if (activeId) { | |
89 | activeValue = $(document.getElementById(activeId)).attr('data-value'); | |
90 | } | |
91 | originalSelect.children('option').each(function(index, ele) { | |
92 | if ($(ele).prop('selected')) { | |
9f5f3dcc | 93 | items.push({label: $(ele).html(), value: $(ele).attr('value')}); |
f992dcf6 DP |
94 | } |
95 | }); | |
9f5f3dcc | 96 | var context = $.extend({items: items}, options, state); |
f992dcf6 DP |
97 | |
98 | // Render the template. | |
99 | templates.render('core/form_autocomplete_selection', context).done(function(newHTML) { | |
100 | // Add it to the page. | |
101 | newSelection.empty().append($(newHTML).html()); | |
102 | ||
103 | if (activeValue !== false) { | |
104 | // Reselect any previously selected item. | |
105 | newSelection.children('[aria-selected=true]').each(function(index, ele) { | |
106 | if ($(ele).attr('data-value') === activeValue) { | |
107 | activateSelection(index, state); | |
108 | } | |
109 | }); | |
110 | } | |
111 | }).fail(notification.exception); | |
1eaba735 FM |
112 | }; |
113 | ||
114 | /** | |
115 | * Notify of a change in the selection. | |
116 | * | |
117 | * @param {jQuery} originalSelect The jQuery object matching the hidden select list. | |
1eaba735 FM |
118 | */ |
119 | var notifyChange = function(originalSelect) { | |
120 | if (typeof M.core_formchangechecker !== 'undefined') { | |
eb97811b MG |
121 | M.core_formchangechecker.set_form_changed(); |
122 | } | |
f992dcf6 DP |
123 | originalSelect.change(); |
124 | }; | |
125 | ||
60a1ea56 | 126 | /** |
563fe0a5 | 127 | * Remove the given item from the list of selected things. |
60a1ea56 | 128 | * |
563fe0a5 | 129 | * @method deselectItem |
60a1ea56 | 130 | * @private |
273b2556 DW |
131 | * @param {Object} options Original options for this autocomplete element. |
132 | * @param {Object} state State variables for this autocomplete element. | |
c96f55e6 | 133 | * @param {Element} item The item to be deselected. |
60a1ea56 | 134 | * @param {Element} originalSelect The original select list. |
60a1ea56 | 135 | */ |
273b2556 | 136 | var deselectItem = function(options, state, item, originalSelect) { |
563fe0a5 RW |
137 | var selectedItemValue = $(item).attr('data-value'); |
138 | ||
139 | // We can only deselect items if this is a multi-select field. | |
273b2556 | 140 | if (options.multiple) { |
563fe0a5 RW |
141 | // Look for a match, and toggle the selected property if there is a match. |
142 | originalSelect.children('option').each(function(index, ele) { | |
143 | if ($(ele).attr('value') == selectedItemValue) { | |
144 | $(ele).prop('selected', false); | |
bdd60287 DW |
145 | // We remove newly created custom tags from the suggestions list when they are deselected. |
146 | if ($(ele).attr('data-iscustom')) { | |
147 | $(ele).remove(); | |
148 | } | |
60a1ea56 | 149 | } |
563fe0a5 RW |
150 | }); |
151 | } | |
60a1ea56 | 152 | // Rerender the selection list. |
273b2556 | 153 | updateSelectionList(options, state, originalSelect); |
1eaba735 FM |
154 | // Notifiy that the selection changed. |
155 | notifyChange(originalSelect); | |
60a1ea56 DW |
156 | }; |
157 | ||
158 | /** | |
159 | * Make an item in the suggestions "active" (about to be selected). | |
160 | * | |
161 | * @method activateItem | |
162 | * @private | |
163 | * @param {Number} index The index in the current (visible) list of suggestions. | |
273b2556 | 164 | * @param {Object} state State variables for this instance of autocomplete. |
60a1ea56 | 165 | */ |
273b2556 | 166 | var activateItem = function(index, state) { |
60a1ea56 | 167 | // Find the elements in the DOM. |
273b2556 DW |
168 | var inputElement = $(document.getElementById(state.inputId)); |
169 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); | |
60a1ea56 DW |
170 | |
171 | // Count the visible items. | |
172 | var length = suggestionsElement.children('[aria-hidden=false]').length; | |
173 | // Limit the index to the upper/lower bounds of the list (wrap in both directions). | |
174 | index = index % length; | |
175 | while (index < 0) { | |
176 | index += length; | |
177 | } | |
178 | // Find the specified element. | |
179 | var element = $(suggestionsElement.children('[aria-hidden=false]').get(index)); | |
180 | // Find the index of this item in the full list of suggestions (including hidden). | |
181 | var globalIndex = $(suggestionsElement.children('[role=option]')).index(element); | |
182 | // Create an id we can assign to this element. | |
273b2556 | 183 | var itemId = state.suggestionsId + '-' + globalIndex; |
60a1ea56 DW |
184 | |
185 | // Deselect all the suggestions. | |
186 | suggestionsElement.children().attr('aria-selected', false).attr('id', ''); | |
187 | // Select only this suggestion and assign it the id. | |
188 | element.attr('aria-selected', true).attr('id', itemId); | |
189 | // Tell the input field it has a new active descendant so the item is announced. | |
190 | inputElement.attr('aria-activedescendant', itemId); | |
32f3de56 DW |
191 | |
192 | // Scroll it into view. | |
193 | var scrollPos = element.offset().top | |
194 | - suggestionsElement.offset().top | |
195 | + suggestionsElement.scrollTop() | |
196 | - (suggestionsElement.height() / 2); | |
197 | suggestionsElement.animate({ | |
198 | scrollTop: scrollPos | |
199 | }, 100); | |
60a1ea56 DW |
200 | }; |
201 | ||
202 | /** | |
203 | * Find the index of the current active suggestion, and activate the next one. | |
204 | * | |
205 | * @method activateNextItem | |
206 | * @private | |
273b2556 | 207 | * @param {Object} state State variable for this auto complete element. |
60a1ea56 | 208 | */ |
273b2556 | 209 | var activateNextItem = function(state) { |
60a1ea56 | 210 | // Find the list of suggestions. |
273b2556 | 211 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); |
60a1ea56 DW |
212 | // Find the active one. |
213 | var element = suggestionsElement.children('[aria-selected=true]'); | |
214 | // Find it's index. | |
215 | var current = suggestionsElement.children('[aria-hidden=false]').index(element); | |
216 | // Activate the next one. | |
35be5826 | 217 | activateItem(current + 1, state); |
60a1ea56 DW |
218 | }; |
219 | ||
220 | /** | |
221 | * Find the index of the current active selection, and activate the previous one. | |
222 | * | |
223 | * @method activatePreviousSelection | |
224 | * @private | |
273b2556 | 225 | * @param {Object} state State variables for this instance of autocomplete. |
60a1ea56 | 226 | */ |
273b2556 | 227 | var activatePreviousSelection = function(state) { |
60a1ea56 | 228 | // Find the list of selections. |
273b2556 | 229 | var selectionsElement = $(document.getElementById(state.selectionId)); |
60a1ea56 DW |
230 | // Find the active one. |
231 | var element = selectionsElement.children('[data-active-selection=true]'); | |
232 | if (!element) { | |
273b2556 | 233 | activateSelection(0, state); |
60a1ea56 DW |
234 | return; |
235 | } | |
236 | // Find it's index. | |
237 | var current = selectionsElement.children('[aria-selected=true]').index(element); | |
238 | // Activate the next one. | |
35be5826 | 239 | activateSelection(current - 1, state); |
60a1ea56 DW |
240 | }; |
241 | /** | |
242 | * Find the index of the current active selection, and activate the next one. | |
243 | * | |
244 | * @method activateNextSelection | |
245 | * @private | |
273b2556 | 246 | * @param {Object} state State variables for this instance of autocomplete. |
60a1ea56 | 247 | */ |
273b2556 | 248 | var activateNextSelection = function(state) { |
60a1ea56 | 249 | // Find the list of selections. |
273b2556 | 250 | var selectionsElement = $(document.getElementById(state.selectionId)); |
60a1ea56 DW |
251 | // Find the active one. |
252 | var element = selectionsElement.children('[data-active-selection=true]'); | |
253 | if (!element) { | |
273b2556 | 254 | activateSelection(0, state); |
60a1ea56 DW |
255 | return; |
256 | } | |
257 | // Find it's index. | |
258 | var current = selectionsElement.children('[aria-selected=true]').index(element); | |
259 | // Activate the next one. | |
35be5826 | 260 | activateSelection(current + 1, state); |
60a1ea56 DW |
261 | }; |
262 | ||
263 | /** | |
264 | * Find the index of the current active suggestion, and activate the previous one. | |
265 | * | |
266 | * @method activatePreviousItem | |
267 | * @private | |
273b2556 | 268 | * @param {Object} state State variables for this autocomplete element. |
60a1ea56 | 269 | */ |
273b2556 | 270 | var activatePreviousItem = function(state) { |
60a1ea56 | 271 | // Find the list of suggestions. |
273b2556 | 272 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); |
60a1ea56 DW |
273 | // Find the active one. |
274 | var element = suggestionsElement.children('[aria-selected=true]'); | |
275 | // Find it's index. | |
276 | var current = suggestionsElement.children('[aria-hidden=false]').index(element); | |
277 | // Activate the next one. | |
35be5826 | 278 | activateItem(current - 1, state); |
60a1ea56 DW |
279 | }; |
280 | ||
281 | /** | |
282 | * Close the list of suggestions. | |
283 | * | |
284 | * @method closeSuggestions | |
285 | * @private | |
273b2556 | 286 | * @param {Object} state State variables for this autocomplete element. |
60a1ea56 | 287 | */ |
273b2556 | 288 | var closeSuggestions = function(state) { |
60a1ea56 | 289 | // Find the elements in the DOM. |
273b2556 DW |
290 | var inputElement = $(document.getElementById(state.inputId)); |
291 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); | |
60a1ea56 DW |
292 | |
293 | // Announce the list of suggestions was closed, and read the current list of selections. | |
273b2556 | 294 | inputElement.attr('aria-expanded', false).attr('aria-activedescendant', state.selectionId); |
60a1ea56 DW |
295 | // Hide the suggestions list (from screen readers too). |
296 | suggestionsElement.hide().attr('aria-hidden', true); | |
297 | }; | |
298 | ||
299 | /** | |
300 | * Rebuild the list of suggestions based on the current values in the select list, and the query. | |
301 | * | |
302 | * @method updateSuggestions | |
303 | * @private | |
273b2556 DW |
304 | * @param {Object} options The original options for this autocomplete. |
305 | * @param {Object} state The state variables for this autocomplete. | |
306 | * @param {String} query The current text for the search string. | |
60a1ea56 | 307 | * @param {JQuery} originalSelect The JQuery object matching the hidden select list. |
60a1ea56 | 308 | */ |
273b2556 | 309 | var updateSuggestions = function(options, state, query, originalSelect) { |
60a1ea56 | 310 | // Find the elements in the DOM. |
273b2556 DW |
311 | var inputElement = $(document.getElementById(state.inputId)); |
312 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); | |
60a1ea56 DW |
313 | |
314 | // Used to track if we found any visible suggestions. | |
315 | var matchingElements = false; | |
316 | // Options is used by the context when rendering the suggestions from a template. | |
273b2556 | 317 | var suggestions = []; |
60a1ea56 DW |
318 | originalSelect.children('option').each(function(index, option) { |
319 | if ($(option).prop('selected') !== true) { | |
9f5f3dcc | 320 | suggestions[suggestions.length] = {label: option.innerHTML, value: $(option).attr('value')}; |
60a1ea56 DW |
321 | } |
322 | }); | |
323 | ||
324 | // Re-render the list of suggestions. | |
273b2556 | 325 | var searchquery = state.caseSensitive ? query : query.toLocaleLowerCase(); |
9f5f3dcc | 326 | var context = $.extend({options: suggestions}, options, state); |
60a1ea56 DW |
327 | templates.render( |
328 | 'core/form_autocomplete_suggestions', | |
273b2556 | 329 | context |
60a1ea56 DW |
330 | ).done(function(newHTML) { |
331 | // We have the new template, insert it in the page. | |
332 | suggestionsElement.replaceWith(newHTML); | |
333 | // Get the element again. | |
273b2556 | 334 | suggestionsElement = $(document.getElementById(state.suggestionsId)); |
60a1ea56 DW |
335 | // Show it if it is hidden. |
336 | suggestionsElement.show().attr('aria-hidden', false); | |
337 | // For each option in the list, hide it if it doesn't match the query. | |
338 | suggestionsElement.children().each(function(index, node) { | |
339 | node = $(node); | |
273b2556 DW |
340 | if ((options.caseSensitive && node.text().indexOf(searchquery) > -1) || |
341 | (!options.caseSensitive && node.text().toLocaleLowerCase().indexOf(searchquery) > -1)) { | |
60a1ea56 DW |
342 | node.show().attr('aria-hidden', false); |
343 | matchingElements = true; | |
344 | } else { | |
345 | node.hide().attr('aria-hidden', true); | |
346 | } | |
347 | }); | |
348 | // If we found any matches, show the list. | |
81c471e2 | 349 | inputElement.attr('aria-expanded', true); |
60a1ea56 | 350 | if (matchingElements) { |
60a1ea56 DW |
351 | // We only activate the first item in the list if tags is false, |
352 | // because otherwise "Enter" would select the first item, instead of | |
353 | // creating a new tag. | |
273b2556 DW |
354 | if (!options.tags) { |
355 | activateItem(0, state); | |
60a1ea56 DW |
356 | } |
357 | } else { | |
81c471e2 DW |
358 | // Nothing matches. Tell them that. |
359 | str.get_string('nosuggestions', 'form').done(function(nosuggestionsstr) { | |
360 | suggestionsElement.html(nosuggestionsstr); | |
361 | }); | |
60a1ea56 DW |
362 | } |
363 | }).fail(notification.exception); | |
364 | ||
365 | }; | |
366 | ||
367 | /** | |
368 | * Create a new item for the list (a tag). | |
369 | * | |
370 | * @method createItem | |
371 | * @private | |
273b2556 DW |
372 | * @param {Object} options The original options for the autocomplete. |
373 | * @param {Object} state State variables for the autocomplete. | |
60a1ea56 DW |
374 | * @param {JQuery} originalSelect The JQuery object matching the hidden select list. |
375 | */ | |
273b2556 | 376 | var createItem = function(options, state, originalSelect) { |
60a1ea56 | 377 | // Find the element in the DOM. |
273b2556 | 378 | var inputElement = $(document.getElementById(state.inputId)); |
60a1ea56 DW |
379 | // Get the current text in the input field. |
380 | var query = inputElement.val(); | |
381 | var tags = query.split(','); | |
382 | var found = false; | |
383 | ||
384 | $.each(tags, function(tagindex, tag) { | |
385 | // If we can only select one at a time, deselect any current value. | |
386 | tag = tag.trim(); | |
387 | if (tag !== '') { | |
273b2556 | 388 | if (!options.multiple) { |
60a1ea56 DW |
389 | originalSelect.children('option').prop('selected', false); |
390 | } | |
391 | // Look for an existing option in the select list that matches this new tag. | |
392 | originalSelect.children('option').each(function(index, ele) { | |
393 | if ($(ele).attr('value') == tag) { | |
394 | found = true; | |
395 | $(ele).prop('selected', true); | |
396 | } | |
397 | }); | |
398 | // Only create the item if it's new. | |
399 | if (!found) { | |
400 | var option = $('<option>'); | |
401 | option.append(tag); | |
402 | option.attr('value', tag); | |
403 | originalSelect.append(option); | |
404 | option.prop('selected', true); | |
bdd60287 DW |
405 | // We mark newly created custom options as we handle them differently if they are "deselected". |
406 | option.attr('data-iscustom', true); | |
60a1ea56 DW |
407 | } |
408 | } | |
409 | }); | |
bdd60287 | 410 | |
273b2556 | 411 | updateSelectionList(options, state, originalSelect); |
1eaba735 FM |
412 | // Notifiy that the selection changed. |
413 | notifyChange(originalSelect); | |
60a1ea56 DW |
414 | // Clear the input field. |
415 | inputElement.val(''); | |
416 | // Close the suggestions list. | |
273b2556 | 417 | closeSuggestions(state); |
60a1ea56 DW |
418 | }; |
419 | ||
60a1ea56 DW |
420 | /** |
421 | * Select the currently active item from the suggestions list. | |
422 | * | |
423 | * @method selectCurrentItem | |
424 | * @private | |
273b2556 DW |
425 | * @param {Object} options The original options for the autocomplete. |
426 | * @param {Object} state State variables for the autocomplete. | |
60a1ea56 DW |
427 | * @param {JQuery} originalSelect The JQuery object matching the hidden select list. |
428 | */ | |
273b2556 | 429 | var selectCurrentItem = function(options, state, originalSelect) { |
60a1ea56 | 430 | // Find the elements in the page. |
273b2556 DW |
431 | var inputElement = $(document.getElementById(state.inputId)); |
432 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); | |
60a1ea56 DW |
433 | // Here loop through suggestions and set val to join of all selected items. |
434 | ||
435 | var selectedItemValue = suggestionsElement.children('[aria-selected=true]').attr('data-value'); | |
436 | // The select will either be a single or multi select, so the following will either | |
437 | // select one or more items correctly. | |
438 | // Take care to use 'prop' and not 'attr' for selected properties. | |
439 | // If only one can be selected at a time, start by deselecting everything. | |
273b2556 | 440 | if (!options.multiple) { |
60a1ea56 DW |
441 | originalSelect.children('option').prop('selected', false); |
442 | } | |
443 | // Look for a match, and toggle the selected property if there is a match. | |
444 | originalSelect.children('option').each(function(index, ele) { | |
445 | if ($(ele).attr('value') == selectedItemValue) { | |
446 | $(ele).prop('selected', true); | |
447 | } | |
448 | }); | |
449 | // Rerender the selection list. | |
273b2556 | 450 | updateSelectionList(options, state, originalSelect); |
1eaba735 FM |
451 | // Notifiy that the selection changed. |
452 | notifyChange(originalSelect); | |
60a1ea56 DW |
453 | // Clear the input element. |
454 | inputElement.val(''); | |
455 | // Close the list of suggestions. | |
273b2556 | 456 | closeSuggestions(state); |
60a1ea56 DW |
457 | }; |
458 | ||
459 | /** | |
460 | * Fetch a new list of options via ajax. | |
461 | * | |
462 | * @method updateAjax | |
463 | * @private | |
464 | * @param {Event} e The event that triggered this update. | |
273b2556 DW |
465 | * @param {Object} options The original options for the autocomplete. |
466 | * @param {Object} state The state variables for the autocomplete. | |
60a1ea56 | 467 | * @param {JQuery} originalSelect The JQuery object matching the hidden select list. |
60a1ea56 DW |
468 | * @param {Object} ajaxHandler This is a module that does the ajax fetch and translates the results. |
469 | */ | |
273b2556 | 470 | var updateAjax = function(e, options, state, originalSelect, ajaxHandler) { |
60a1ea56 DW |
471 | // Get the query to pass to the ajax function. |
472 | var query = $(e.currentTarget).val(); | |
473 | // Call the transport function to do the ajax (name taken from Select2). | |
273b2556 | 474 | ajaxHandler.transport(options.selector, query, function(results) { |
60a1ea56 | 475 | // We got a result - pass it through the translator before using it. |
273b2556 | 476 | var processedResults = ajaxHandler.processResults(options.selector, results); |
60a1ea56 DW |
477 | var existingValues = []; |
478 | ||
479 | // Now destroy all options that are not currently selected. | |
480 | originalSelect.children('option').each(function(optionIndex, option) { | |
481 | option = $(option); | |
482 | if (!option.prop('selected')) { | |
483 | option.remove(); | |
484 | } else { | |
91ab264c | 485 | existingValues.push(String(option.attr('value'))); |
60a1ea56 DW |
486 | } |
487 | }); | |
91ab264c DW |
488 | |
489 | if (!options.multiple && originalSelect.children('option').length === 0) { | |
490 | // If this is a single select - and there are no current options | |
491 | // the first option added will be selected by the browser. This causes a bug! | |
492 | // We need to insert an empty option so that none of the real options are selected. | |
493 | var option = $('<option>'); | |
494 | originalSelect.append(option); | |
495 | } | |
60a1ea56 DW |
496 | // And add all the new ones returned from ajax. |
497 | $.each(processedResults, function(resultIndex, result) { | |
91ab264c | 498 | if (existingValues.indexOf(String(result.value)) === -1) { |
60a1ea56 DW |
499 | var option = $('<option>'); |
500 | option.append(result.label); | |
501 | option.attr('value', result.value); | |
502 | originalSelect.append(option); | |
503 | } | |
504 | }); | |
505 | // Update the list of suggestions now from the new values in the select list. | |
273b2556 | 506 | updateSuggestions(options, state, '', originalSelect); |
60a1ea56 DW |
507 | }, notification.exception); |
508 | }; | |
509 | ||
510 | /** | |
511 | * Add all the event listeners required for keyboard nav, blur clicks etc. | |
512 | * | |
513 | * @method addNavigation | |
514 | * @private | |
273b2556 DW |
515 | * @param {Object} options The options used to create this autocomplete element. |
516 | * @param {Object} state State variables for this autocomplete element. | |
60a1ea56 | 517 | * @param {JQuery} originalSelect The JQuery object matching the hidden select list. |
60a1ea56 | 518 | */ |
273b2556 | 519 | var addNavigation = function(options, state, originalSelect) { |
60a1ea56 | 520 | // Start with the input element. |
273b2556 | 521 | var inputElement = $(document.getElementById(state.inputId)); |
60a1ea56 DW |
522 | // Add keyboard nav with keydown. |
523 | inputElement.on('keydown', function(e) { | |
524 | switch (e.keyCode) { | |
525 | case KEYS.DOWN: | |
526 | // If the suggestion list is open, move to the next item. | |
273b2556 | 527 | if (!options.showSuggestions) { |
97d2ea7f DW |
528 | // Do not consume this event. |
529 | return true; | |
530 | } else if (inputElement.attr('aria-expanded') === "true") { | |
273b2556 | 531 | activateNextItem(state); |
60a1ea56 | 532 | } else { |
d304952b | 533 | // Handle ajax population of suggestions. |
273b2556 DW |
534 | if (!inputElement.val() && options.ajax) { |
535 | require([options.ajax], function(ajaxHandler) { | |
536 | updateAjax(e, options, state, originalSelect, ajaxHandler); | |
d304952b AG |
537 | }); |
538 | } else { | |
539 | // Else - open the suggestions list. | |
273b2556 | 540 | updateSuggestions(options, state, inputElement.val(), originalSelect); |
d304952b | 541 | } |
60a1ea56 DW |
542 | } |
543 | // We handled this event, so prevent it. | |
544 | e.preventDefault(); | |
545 | return false; | |
60a1ea56 DW |
546 | case KEYS.UP: |
547 | // Choose the previous active item. | |
273b2556 | 548 | activatePreviousItem(state); |
60a1ea56 DW |
549 | // We handled this event, so prevent it. |
550 | e.preventDefault(); | |
551 | return false; | |
552 | case KEYS.ENTER: | |
273b2556 | 553 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); |
60a1ea56 DW |
554 | if ((inputElement.attr('aria-expanded') === "true") && |
555 | (suggestionsElement.children('[aria-selected=true]').length > 0)) { | |
556 | // If the suggestion list has an active item, select it. | |
273b2556 DW |
557 | selectCurrentItem(options, state, originalSelect); |
558 | } else if (options.tags) { | |
60a1ea56 | 559 | // If tags are enabled, create a tag. |
273b2556 | 560 | createItem(options, state, originalSelect); |
60a1ea56 DW |
561 | } |
562 | // We handled this event, so prevent it. | |
563 | e.preventDefault(); | |
564 | return false; | |
565 | case KEYS.ESCAPE: | |
566 | if (inputElement.attr('aria-expanded') === "true") { | |
567 | // If the suggestion list is open, close it. | |
273b2556 | 568 | closeSuggestions(state); |
60a1ea56 DW |
569 | } |
570 | // We handled this event, so prevent it. | |
571 | e.preventDefault(); | |
572 | return false; | |
e375029e NK |
573 | } |
574 | return true; | |
575 | }); | |
576 | // Support multi lingual COMMA keycode (44). | |
577 | inputElement.on('keypress', function(e) { | |
578 | if (e.keyCode === KEYS.COMMA) { | |
579 | if (options.tags) { | |
580 | // If we are allowing tags, comma should create a tag (or enter). | |
581 | createItem(options, state, originalSelect); | |
582 | } | |
583 | // We handled this event, so prevent it. | |
584 | e.preventDefault(); | |
585 | return false; | |
60a1ea56 DW |
586 | } |
587 | return true; | |
588 | }); | |
589 | // Handler used to force set the value from behat. | |
590 | inputElement.on('behat:set-value', function() { | |
6f326bd2 DW |
591 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); |
592 | if ((inputElement.attr('aria-expanded') === "true") && | |
593 | (suggestionsElement.children('[aria-selected=true]').length > 0)) { | |
594 | // If the suggestion list has an active item, select it. | |
595 | selectCurrentItem(options, state, originalSelect); | |
596 | } else if (options.tags) { | |
597 | // If tags are enabled, create a tag. | |
273b2556 | 598 | createItem(options, state, originalSelect); |
60a1ea56 DW |
599 | } |
600 | }); | |
e1db2b41 RW |
601 | inputElement.on('blur', function() { |
602 | window.setTimeout(function() { | |
603 | // Get the current element with focus. | |
604 | var focusElement = $(document.activeElement); | |
605 | // Only close the menu if the input hasn't regained focus. | |
606 | if (focusElement.attr('id') != inputElement.attr('id')) { | |
273b2556 DW |
607 | if (options.tags) { |
608 | createItem(options, state, originalSelect); | |
527bde6e | 609 | } |
273b2556 | 610 | closeSuggestions(state); |
60a1ea56 | 611 | } |
60a1ea56 DW |
612 | }, 500); |
613 | }); | |
273b2556 DW |
614 | if (options.showSuggestions) { |
615 | var arrowElement = $(document.getElementById(state.downArrowId)); | |
97d2ea7f DW |
616 | arrowElement.on('click', function() { |
617 | // Prevent the close timer, or we will open, then close the suggestions. | |
618 | inputElement.focus(); | |
97d2ea7f | 619 | // Show the suggestions list. |
273b2556 | 620 | updateSuggestions(options, state, inputElement.val(), originalSelect); |
97d2ea7f DW |
621 | }); |
622 | } | |
60a1ea56 | 623 | |
273b2556 | 624 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); |
60a1ea56 DW |
625 | suggestionsElement.parent().on('click', '[role=option]', function(e) { |
626 | // Handle clicks on suggestions. | |
627 | var element = $(e.currentTarget).closest('[role=option]'); | |
273b2556 | 628 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); |
60a1ea56 DW |
629 | // Find the index of the clicked on suggestion. |
630 | var current = suggestionsElement.children('[aria-hidden=false]').index(element); | |
631 | // Activate it. | |
273b2556 | 632 | activateItem(current, state); |
60a1ea56 | 633 | // And select it. |
273b2556 | 634 | selectCurrentItem(options, state, originalSelect); |
60a1ea56 | 635 | }); |
273b2556 | 636 | var selectionElement = $(document.getElementById(state.selectionId)); |
60a1ea56 | 637 | // Handle clicks on the selected items (will unselect an item). |
adebc069 | 638 | selectionElement.on('click', '[role=listitem]', function(e) { |
563fe0a5 RW |
639 | // Get the item that was clicked. |
640 | var item = $(e.currentTarget); | |
641 | // Remove it from the selection. | |
273b2556 | 642 | deselectItem(options, state, item, originalSelect); |
60a1ea56 DW |
643 | }); |
644 | // Keyboard navigation for the selection list. | |
adebc069 | 645 | selectionElement.on('keydown', function(e) { |
60a1ea56 DW |
646 | switch (e.keyCode) { |
647 | case KEYS.DOWN: | |
648 | // Choose the next selection item. | |
273b2556 | 649 | activateNextSelection(state); |
60a1ea56 DW |
650 | // We handled this event, so prevent it. |
651 | e.preventDefault(); | |
652 | return false; | |
653 | case KEYS.UP: | |
654 | // Choose the previous selection item. | |
273b2556 | 655 | activatePreviousSelection(state); |
60a1ea56 DW |
656 | // We handled this event, so prevent it. |
657 | e.preventDefault(); | |
658 | return false; | |
659 | case KEYS.SPACE: | |
660 | case KEYS.ENTER: | |
563fe0a5 | 661 | // Get the item that is currently selected. |
273b2556 | 662 | var selectedItem = $(document.getElementById(state.selectionId)).children('[data-active-selection=true]'); |
563fe0a5 RW |
663 | if (selectedItem) { |
664 | // Unselect this item. | |
273b2556 | 665 | deselectItem(options, state, selectedItem, originalSelect); |
563fe0a5 RW |
666 | // We handled this event, so prevent it. |
667 | e.preventDefault(); | |
668 | } | |
60a1ea56 DW |
669 | return false; |
670 | } | |
671 | return true; | |
672 | }); | |
673 | // Whenever the input field changes, update the suggestion list. | |
273b2556 | 674 | if (options.showSuggestions) { |
97d2ea7f DW |
675 | inputElement.on('input', function(e) { |
676 | var query = $(e.currentTarget).val(); | |
677 | var last = $(e.currentTarget).data('last-value'); | |
97d2ea7f DW |
678 | // IE11 fires many more input events than required - even when the value has not changed. |
679 | // We need to only do this for real value changed events or the suggestions will be | |
680 | // unclickable on IE11 (because they will be rebuilt before the click event fires). | |
861c1dea DW |
681 | // Note - because of this we cannot close the list when the query is empty or it will break |
682 | // on IE11. | |
683 | if (last !== query) { | |
273b2556 | 684 | updateSuggestions(options, state, query, originalSelect); |
97d2ea7f | 685 | } |
861c1dea | 686 | $(e.currentTarget).data('last-value', query); |
97d2ea7f DW |
687 | }); |
688 | } | |
60a1ea56 DW |
689 | }; |
690 | ||
691 | return /** @alias module:core/form-autocomplete */ { | |
692 | // Public variables and functions. | |
693 | /** | |
694 | * Turn a boring select box into an auto-complete beast. | |
695 | * | |
696 | * @method enhance | |
c96f55e6 | 697 | * @param {string} selector The selector that identifies the select box. |
60a1ea56 DW |
698 | * @param {boolean} tags Whether to allow support for tags (can define new entries). |
699 | * @param {string} ajax Name of an AMD module to handle ajax requests. If specified, the AMD | |
700 | * module must expose 2 functions "transport" and "processResults". | |
701 | * These are modeled on Select2 see: https://select2.github.io/options.html#ajax | |
702 | * @param {String} placeholder - The text to display before a selection is made. | |
ecbc2a2f | 703 | * @param {Boolean} caseSensitive - If search has to be made case sensitive. |
c96f55e6 | 704 | * @param {Boolean} showSuggestions - If suggestions should be shown |
427e3cbc | 705 | * @param {String} noSelectionString - Text to display when there is no selection |
60a1ea56 | 706 | */ |
427e3cbc | 707 | enhance: function(selector, tags, ajax, placeholder, caseSensitive, showSuggestions, noSelectionString) { |
60a1ea56 | 708 | // Set some default values. |
273b2556 DW |
709 | var options = { |
710 | selector: selector, | |
711 | tags: false, | |
712 | ajax: false, | |
713 | placeholder: placeholder, | |
714 | caseSensitive: false, | |
427e3cbc EM |
715 | showSuggestions: true, |
716 | noSelectionString: noSelectionString | |
273b2556 DW |
717 | }; |
718 | if (typeof tags !== "undefined") { | |
719 | options.tags = tags; | |
60a1ea56 | 720 | } |
273b2556 DW |
721 | if (typeof ajax !== "undefined") { |
722 | options.ajax = ajax; | |
60a1ea56 | 723 | } |
273b2556 DW |
724 | if (typeof caseSensitive !== "undefined") { |
725 | options.caseSensitive = caseSensitive; | |
ecbc2a2f | 726 | } |
273b2556 DW |
727 | if (typeof showSuggestions !== "undefined") { |
728 | options.showSuggestions = showSuggestions; | |
97d2ea7f | 729 | } |
427e3cbc | 730 | if (typeof noSelectionString === "undefined") { |
35be5826 | 731 | str.get_string('noselection', 'form').done(function(result) { |
1388b618 JP |
732 | options.noSelectionString = result; |
733 | }).fail(notification.exception); | |
427e3cbc | 734 | } |
60a1ea56 DW |
735 | |
736 | // Look for the select element. | |
737 | var originalSelect = $(selector); | |
738 | if (!originalSelect) { | |
739 | log.debug('Selector not found: ' + selector); | |
c96f55e6 | 740 | return; |
60a1ea56 DW |
741 | } |
742 | ||
743 | // Hide the original select. | |
744 | originalSelect.hide().attr('aria-hidden', true); | |
745 | ||
746 | // Find or generate some ids. | |
273b2556 DW |
747 | var state = { |
748 | selectId: originalSelect.attr('id'), | |
749 | inputId: 'form_autocomplete_input-' + $.now(), | |
750 | suggestionsId: 'form_autocomplete_suggestions-' + $.now(), | |
751 | selectionId: 'form_autocomplete_selection-' + $.now(), | |
752 | downArrowId: 'form_autocomplete_downarrow-' + $.now() | |
753 | }; | |
754 | options.multiple = originalSelect.attr('multiple'); | |
755 | ||
756 | var originalLabel = $('[for=' + state.selectId + ']'); | |
60a1ea56 | 757 | // Create the new markup and insert it after the select. |
273b2556 | 758 | var suggestions = []; |
60a1ea56 | 759 | originalSelect.children('option').each(function(index, option) { |
9f5f3dcc | 760 | suggestions[index] = {label: option.innerHTML, value: $(option).attr('value')}; |
60a1ea56 DW |
761 | }); |
762 | ||
763 | // Render all the parts of our UI. | |
273b2556 DW |
764 | var context = $.extend({}, options, state); |
765 | context.options = suggestions; | |
766 | context.items = []; | |
767 | ||
768 | var renderInput = templates.render('core/form_autocomplete_input', context); | |
769 | var renderDatalist = templates.render('core/form_autocomplete_suggestions', context); | |
770 | var renderSelection = templates.render('core/form_autocomplete_selection', context); | |
60a1ea56 DW |
771 | |
772 | $.when(renderInput, renderDatalist, renderSelection).done(function(input, suggestions, selection) { | |
773 | // Add our new UI elements to the page. | |
774 | originalSelect.after(suggestions); | |
775 | originalSelect.after(input); | |
776 | originalSelect.after(selection); | |
777 | // Update the form label to point to the text input. | |
273b2556 | 778 | originalLabel.attr('for', state.inputId); |
60a1ea56 | 779 | // Add the event handlers. |
273b2556 DW |
780 | addNavigation(options, state, originalSelect); |
781 | ||
782 | var inputElement = $(document.getElementById(state.inputId)); | |
783 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); | |
60a1ea56 DW |
784 | // Hide the suggestions by default. |
785 | suggestionsElement.hide().attr('aria-hidden', true); | |
786 | ||
787 | // If this field uses ajax, set it up. | |
273b2556 DW |
788 | if (options.ajax) { |
789 | require([options.ajax], function(ajaxHandler) { | |
235ef57a | 790 | var throttleTimeout = null; |
60a1ea56 | 791 | var handler = function(e) { |
273b2556 | 792 | updateAjax(e, options, state, originalSelect, ajaxHandler); |
60a1ea56 | 793 | }; |
235ef57a DW |
794 | |
795 | // For input events, we do not want to trigger many, many updates. | |
796 | var throttledHandler = function(e) { | |
797 | if (throttleTimeout !== null) { | |
798 | window.clearTimeout(throttleTimeout); | |
799 | throttleTimeout = null; | |
800 | } | |
801 | throttleTimeout = window.setTimeout(handler.bind(this, e), 300); | |
802 | }; | |
60a1ea56 | 803 | // Trigger an ajax update after the text field value changes. |
bb690849 | 804 | inputElement.on("input", throttledHandler); |
273b2556 | 805 | var arrowElement = $(document.getElementById(state.downArrowId)); |
60a1ea56 DW |
806 | arrowElement.on("click", handler); |
807 | }); | |
808 | } | |
809 | // Show the current values in the selection list. | |
273b2556 | 810 | updateSelectionList(options, state, originalSelect); |
60a1ea56 DW |
811 | }); |
812 | } | |
813 | }; | |
814 | }); |