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