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