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); | |
786e014c | 453 | |
198d7291 | 454 | if (options.closeSuggestionsOnSelect) { |
786e014c DW |
455 | // Clear the input element. |
456 | inputElement.val(''); | |
457 | // Close the list of suggestions. | |
458 | closeSuggestions(state); | |
459 | } else { | |
460 | // Focus on the input element so the suggestions does not auto-close. | |
461 | inputElement.focus(); | |
462 | // Remove the last selected item from the suggestions list. | |
463 | updateSuggestions(options, state, inputElement.val(), originalSelect); | |
464 | } | |
60a1ea56 DW |
465 | }; |
466 | ||
467 | /** | |
468 | * Fetch a new list of options via ajax. | |
469 | * | |
470 | * @method updateAjax | |
471 | * @private | |
472 | * @param {Event} e The event that triggered this update. | |
273b2556 DW |
473 | * @param {Object} options The original options for the autocomplete. |
474 | * @param {Object} state The state variables for the autocomplete. | |
60a1ea56 | 475 | * @param {JQuery} originalSelect The JQuery object matching the hidden select list. |
60a1ea56 DW |
476 | * @param {Object} ajaxHandler This is a module that does the ajax fetch and translates the results. |
477 | */ | |
273b2556 | 478 | var updateAjax = function(e, options, state, originalSelect, ajaxHandler) { |
60a1ea56 DW |
479 | // Get the query to pass to the ajax function. |
480 | var query = $(e.currentTarget).val(); | |
481 | // Call the transport function to do the ajax (name taken from Select2). | |
273b2556 | 482 | ajaxHandler.transport(options.selector, query, function(results) { |
60a1ea56 | 483 | // We got a result - pass it through the translator before using it. |
273b2556 | 484 | var processedResults = ajaxHandler.processResults(options.selector, results); |
60a1ea56 DW |
485 | var existingValues = []; |
486 | ||
487 | // Now destroy all options that are not currently selected. | |
488 | originalSelect.children('option').each(function(optionIndex, option) { | |
489 | option = $(option); | |
490 | if (!option.prop('selected')) { | |
491 | option.remove(); | |
492 | } else { | |
91ab264c | 493 | existingValues.push(String(option.attr('value'))); |
60a1ea56 DW |
494 | } |
495 | }); | |
91ab264c DW |
496 | |
497 | if (!options.multiple && originalSelect.children('option').length === 0) { | |
498 | // If this is a single select - and there are no current options | |
499 | // the first option added will be selected by the browser. This causes a bug! | |
500 | // We need to insert an empty option so that none of the real options are selected. | |
501 | var option = $('<option>'); | |
502 | originalSelect.append(option); | |
503 | } | |
60a1ea56 DW |
504 | // And add all the new ones returned from ajax. |
505 | $.each(processedResults, function(resultIndex, result) { | |
91ab264c | 506 | if (existingValues.indexOf(String(result.value)) === -1) { |
60a1ea56 DW |
507 | var option = $('<option>'); |
508 | option.append(result.label); | |
509 | option.attr('value', result.value); | |
510 | originalSelect.append(option); | |
511 | } | |
512 | }); | |
513 | // Update the list of suggestions now from the new values in the select list. | |
273b2556 | 514 | updateSuggestions(options, state, '', originalSelect); |
60a1ea56 DW |
515 | }, notification.exception); |
516 | }; | |
517 | ||
518 | /** | |
519 | * Add all the event listeners required for keyboard nav, blur clicks etc. | |
520 | * | |
521 | * @method addNavigation | |
522 | * @private | |
273b2556 DW |
523 | * @param {Object} options The options used to create this autocomplete element. |
524 | * @param {Object} state State variables for this autocomplete element. | |
60a1ea56 | 525 | * @param {JQuery} originalSelect The JQuery object matching the hidden select list. |
60a1ea56 | 526 | */ |
273b2556 | 527 | var addNavigation = function(options, state, originalSelect) { |
60a1ea56 | 528 | // Start with the input element. |
273b2556 | 529 | var inputElement = $(document.getElementById(state.inputId)); |
60a1ea56 DW |
530 | // Add keyboard nav with keydown. |
531 | inputElement.on('keydown', function(e) { | |
532 | switch (e.keyCode) { | |
533 | case KEYS.DOWN: | |
534 | // If the suggestion list is open, move to the next item. | |
273b2556 | 535 | if (!options.showSuggestions) { |
97d2ea7f DW |
536 | // Do not consume this event. |
537 | return true; | |
538 | } else if (inputElement.attr('aria-expanded') === "true") { | |
273b2556 | 539 | activateNextItem(state); |
60a1ea56 | 540 | } else { |
d304952b | 541 | // Handle ajax population of suggestions. |
273b2556 DW |
542 | if (!inputElement.val() && options.ajax) { |
543 | require([options.ajax], function(ajaxHandler) { | |
544 | updateAjax(e, options, state, originalSelect, ajaxHandler); | |
d304952b AG |
545 | }); |
546 | } else { | |
47dd5350 | 547 | // Open the suggestions list. |
273b2556 | 548 | updateSuggestions(options, state, inputElement.val(), originalSelect); |
d304952b | 549 | } |
60a1ea56 DW |
550 | } |
551 | // We handled this event, so prevent it. | |
552 | e.preventDefault(); | |
553 | return false; | |
60a1ea56 DW |
554 | case KEYS.UP: |
555 | // Choose the previous active item. | |
273b2556 | 556 | activatePreviousItem(state); |
60a1ea56 DW |
557 | // We handled this event, so prevent it. |
558 | e.preventDefault(); | |
559 | return false; | |
560 | case KEYS.ENTER: | |
273b2556 | 561 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); |
60a1ea56 DW |
562 | if ((inputElement.attr('aria-expanded') === "true") && |
563 | (suggestionsElement.children('[aria-selected=true]').length > 0)) { | |
564 | // If the suggestion list has an active item, select it. | |
273b2556 DW |
565 | selectCurrentItem(options, state, originalSelect); |
566 | } else if (options.tags) { | |
60a1ea56 | 567 | // If tags are enabled, create a tag. |
273b2556 | 568 | createItem(options, state, originalSelect); |
60a1ea56 DW |
569 | } |
570 | // We handled this event, so prevent it. | |
571 | e.preventDefault(); | |
572 | return false; | |
573 | case KEYS.ESCAPE: | |
574 | if (inputElement.attr('aria-expanded') === "true") { | |
575 | // If the suggestion list is open, close it. | |
273b2556 | 576 | closeSuggestions(state); |
60a1ea56 DW |
577 | } |
578 | // We handled this event, so prevent it. | |
579 | e.preventDefault(); | |
580 | return false; | |
e375029e NK |
581 | } |
582 | return true; | |
583 | }); | |
584 | // Support multi lingual COMMA keycode (44). | |
585 | inputElement.on('keypress', function(e) { | |
586 | if (e.keyCode === KEYS.COMMA) { | |
587 | if (options.tags) { | |
588 | // If we are allowing tags, comma should create a tag (or enter). | |
589 | createItem(options, state, originalSelect); | |
590 | } | |
591 | // We handled this event, so prevent it. | |
592 | e.preventDefault(); | |
593 | return false; | |
60a1ea56 DW |
594 | } |
595 | return true; | |
596 | }); | |
597 | // Handler used to force set the value from behat. | |
598 | inputElement.on('behat:set-value', function() { | |
6f326bd2 DW |
599 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); |
600 | if ((inputElement.attr('aria-expanded') === "true") && | |
601 | (suggestionsElement.children('[aria-selected=true]').length > 0)) { | |
602 | // If the suggestion list has an active item, select it. | |
603 | selectCurrentItem(options, state, originalSelect); | |
604 | } else if (options.tags) { | |
605 | // If tags are enabled, create a tag. | |
273b2556 | 606 | createItem(options, state, originalSelect); |
60a1ea56 DW |
607 | } |
608 | }); | |
e1db2b41 RW |
609 | inputElement.on('blur', function() { |
610 | window.setTimeout(function() { | |
611 | // Get the current element with focus. | |
612 | var focusElement = $(document.activeElement); | |
613 | // Only close the menu if the input hasn't regained focus. | |
614 | if (focusElement.attr('id') != inputElement.attr('id')) { | |
273b2556 DW |
615 | if (options.tags) { |
616 | createItem(options, state, originalSelect); | |
527bde6e | 617 | } |
273b2556 | 618 | closeSuggestions(state); |
60a1ea56 | 619 | } |
60a1ea56 DW |
620 | }, 500); |
621 | }); | |
273b2556 DW |
622 | if (options.showSuggestions) { |
623 | var arrowElement = $(document.getElementById(state.downArrowId)); | |
47dd5350 | 624 | arrowElement.on('click', function(e) { |
97d2ea7f DW |
625 | // Prevent the close timer, or we will open, then close the suggestions. |
626 | inputElement.focus(); | |
47dd5350 DW |
627 | // Handle ajax population of suggestions. |
628 | if (!inputElement.val() && options.ajax) { | |
629 | require([options.ajax], function(ajaxHandler) { | |
630 | updateAjax(e, options, state, originalSelect, ajaxHandler); | |
631 | }); | |
632 | } else { | |
633 | // Else - open the suggestions list. | |
634 | updateSuggestions(options, state, inputElement.val(), originalSelect); | |
635 | } | |
97d2ea7f DW |
636 | }); |
637 | } | |
60a1ea56 | 638 | |
273b2556 | 639 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); |
60a1ea56 DW |
640 | suggestionsElement.parent().on('click', '[role=option]', function(e) { |
641 | // Handle clicks on suggestions. | |
642 | var element = $(e.currentTarget).closest('[role=option]'); | |
273b2556 | 643 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); |
60a1ea56 DW |
644 | // Find the index of the clicked on suggestion. |
645 | var current = suggestionsElement.children('[aria-hidden=false]').index(element); | |
646 | // Activate it. | |
273b2556 | 647 | activateItem(current, state); |
60a1ea56 | 648 | // And select it. |
273b2556 | 649 | selectCurrentItem(options, state, originalSelect); |
60a1ea56 | 650 | }); |
273b2556 | 651 | var selectionElement = $(document.getElementById(state.selectionId)); |
60a1ea56 | 652 | // Handle clicks on the selected items (will unselect an item). |
adebc069 | 653 | selectionElement.on('click', '[role=listitem]', function(e) { |
563fe0a5 RW |
654 | // Get the item that was clicked. |
655 | var item = $(e.currentTarget); | |
656 | // Remove it from the selection. | |
273b2556 | 657 | deselectItem(options, state, item, originalSelect); |
60a1ea56 DW |
658 | }); |
659 | // Keyboard navigation for the selection list. | |
adebc069 | 660 | selectionElement.on('keydown', function(e) { |
60a1ea56 DW |
661 | switch (e.keyCode) { |
662 | case KEYS.DOWN: | |
663 | // Choose the next selection item. | |
273b2556 | 664 | activateNextSelection(state); |
60a1ea56 DW |
665 | // We handled this event, so prevent it. |
666 | e.preventDefault(); | |
667 | return false; | |
668 | case KEYS.UP: | |
669 | // Choose the previous selection item. | |
273b2556 | 670 | activatePreviousSelection(state); |
60a1ea56 DW |
671 | // We handled this event, so prevent it. |
672 | e.preventDefault(); | |
673 | return false; | |
674 | case KEYS.SPACE: | |
675 | case KEYS.ENTER: | |
563fe0a5 | 676 | // Get the item that is currently selected. |
273b2556 | 677 | var selectedItem = $(document.getElementById(state.selectionId)).children('[data-active-selection=true]'); |
563fe0a5 RW |
678 | if (selectedItem) { |
679 | // Unselect this item. | |
273b2556 | 680 | deselectItem(options, state, selectedItem, originalSelect); |
563fe0a5 RW |
681 | // We handled this event, so prevent it. |
682 | e.preventDefault(); | |
683 | } | |
60a1ea56 DW |
684 | return false; |
685 | } | |
686 | return true; | |
687 | }); | |
688 | // Whenever the input field changes, update the suggestion list. | |
273b2556 | 689 | if (options.showSuggestions) { |
97d2ea7f DW |
690 | inputElement.on('input', function(e) { |
691 | var query = $(e.currentTarget).val(); | |
692 | var last = $(e.currentTarget).data('last-value'); | |
97d2ea7f DW |
693 | // IE11 fires many more input events than required - even when the value has not changed. |
694 | // We need to only do this for real value changed events or the suggestions will be | |
695 | // unclickable on IE11 (because they will be rebuilt before the click event fires). | |
861c1dea DW |
696 | // Note - because of this we cannot close the list when the query is empty or it will break |
697 | // on IE11. | |
698 | if (last !== query) { | |
273b2556 | 699 | updateSuggestions(options, state, query, originalSelect); |
97d2ea7f | 700 | } |
861c1dea | 701 | $(e.currentTarget).data('last-value', query); |
97d2ea7f DW |
702 | }); |
703 | } | |
60a1ea56 DW |
704 | }; |
705 | ||
706 | return /** @alias module:core/form-autocomplete */ { | |
707 | // Public variables and functions. | |
708 | /** | |
709 | * Turn a boring select box into an auto-complete beast. | |
710 | * | |
711 | * @method enhance | |
c96f55e6 | 712 | * @param {string} selector The selector that identifies the select box. |
60a1ea56 DW |
713 | * @param {boolean} tags Whether to allow support for tags (can define new entries). |
714 | * @param {string} ajax Name of an AMD module to handle ajax requests. If specified, the AMD | |
715 | * module must expose 2 functions "transport" and "processResults". | |
716 | * These are modeled on Select2 see: https://select2.github.io/options.html#ajax | |
717 | * @param {String} placeholder - The text to display before a selection is made. | |
ecbc2a2f | 718 | * @param {Boolean} caseSensitive - If search has to be made case sensitive. |
c96f55e6 | 719 | * @param {Boolean} showSuggestions - If suggestions should be shown |
427e3cbc | 720 | * @param {String} noSelectionString - Text to display when there is no selection |
198d7291 | 721 | * @param {Boolean} closeSuggestionsOnSelect - Whether to close the suggestions immediately after making a selection. |
13426bae | 722 | * @return {Promise} |
60a1ea56 | 723 | */ |
198d7291 JP |
724 | enhance: function(selector, tags, ajax, placeholder, caseSensitive, showSuggestions, noSelectionString, |
725 | closeSuggestionsOnSelect) { | |
60a1ea56 | 726 | // Set some default values. |
273b2556 DW |
727 | var options = { |
728 | selector: selector, | |
729 | tags: false, | |
730 | ajax: false, | |
731 | placeholder: placeholder, | |
732 | caseSensitive: false, | |
427e3cbc EM |
733 | showSuggestions: true, |
734 | noSelectionString: noSelectionString | |
273b2556 DW |
735 | }; |
736 | if (typeof tags !== "undefined") { | |
737 | options.tags = tags; | |
60a1ea56 | 738 | } |
273b2556 DW |
739 | if (typeof ajax !== "undefined") { |
740 | options.ajax = ajax; | |
60a1ea56 | 741 | } |
273b2556 DW |
742 | if (typeof caseSensitive !== "undefined") { |
743 | options.caseSensitive = caseSensitive; | |
ecbc2a2f | 744 | } |
273b2556 DW |
745 | if (typeof showSuggestions !== "undefined") { |
746 | options.showSuggestions = showSuggestions; | |
97d2ea7f | 747 | } |
427e3cbc | 748 | if (typeof noSelectionString === "undefined") { |
35be5826 | 749 | str.get_string('noselection', 'form').done(function(result) { |
1388b618 JP |
750 | options.noSelectionString = result; |
751 | }).fail(notification.exception); | |
427e3cbc | 752 | } |
60a1ea56 DW |
753 | |
754 | // Look for the select element. | |
755 | var originalSelect = $(selector); | |
756 | if (!originalSelect) { | |
757 | log.debug('Selector not found: ' + selector); | |
f3ecea3a | 758 | return false; |
60a1ea56 DW |
759 | } |
760 | ||
761 | // Hide the original select. | |
762 | originalSelect.hide().attr('aria-hidden', true); | |
763 | ||
764 | // Find or generate some ids. | |
273b2556 DW |
765 | var state = { |
766 | selectId: originalSelect.attr('id'), | |
767 | inputId: 'form_autocomplete_input-' + $.now(), | |
768 | suggestionsId: 'form_autocomplete_suggestions-' + $.now(), | |
769 | selectionId: 'form_autocomplete_selection-' + $.now(), | |
770 | downArrowId: 'form_autocomplete_downarrow-' + $.now() | |
771 | }; | |
772 | options.multiple = originalSelect.attr('multiple'); | |
773 | ||
198d7291 JP |
774 | if (typeof closeSuggestionsOnSelect !== "undefined") { |
775 | options.closeSuggestionsOnSelect = closeSuggestionsOnSelect; | |
776 | } else { | |
777 | // If not specified, this will close suggestions by default for single-select elements only. | |
778 | options.closeSuggestionsOnSelect = !options.multiple; | |
779 | } | |
780 | ||
273b2556 | 781 | var originalLabel = $('[for=' + state.selectId + ']'); |
60a1ea56 | 782 | // Create the new markup and insert it after the select. |
273b2556 | 783 | var suggestions = []; |
60a1ea56 | 784 | originalSelect.children('option').each(function(index, option) { |
9f5f3dcc | 785 | suggestions[index] = {label: option.innerHTML, value: $(option).attr('value')}; |
60a1ea56 DW |
786 | }); |
787 | ||
788 | // Render all the parts of our UI. | |
273b2556 DW |
789 | var context = $.extend({}, options, state); |
790 | context.options = suggestions; | |
791 | context.items = []; | |
792 | ||
793 | var renderInput = templates.render('core/form_autocomplete_input', context); | |
794 | var renderDatalist = templates.render('core/form_autocomplete_suggestions', context); | |
795 | var renderSelection = templates.render('core/form_autocomplete_selection', context); | |
60a1ea56 | 796 | |
f3ecea3a | 797 | return $.when(renderInput, renderDatalist, renderSelection).then(function(input, suggestions, selection) { |
60a1ea56 DW |
798 | // Add our new UI elements to the page. |
799 | originalSelect.after(suggestions); | |
800 | originalSelect.after(input); | |
801 | originalSelect.after(selection); | |
802 | // Update the form label to point to the text input. | |
273b2556 | 803 | originalLabel.attr('for', state.inputId); |
60a1ea56 | 804 | // Add the event handlers. |
273b2556 DW |
805 | addNavigation(options, state, originalSelect); |
806 | ||
807 | var inputElement = $(document.getElementById(state.inputId)); | |
808 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); | |
60a1ea56 DW |
809 | // Hide the suggestions by default. |
810 | suggestionsElement.hide().attr('aria-hidden', true); | |
811 | ||
812 | // If this field uses ajax, set it up. | |
273b2556 DW |
813 | if (options.ajax) { |
814 | require([options.ajax], function(ajaxHandler) { | |
235ef57a | 815 | var throttleTimeout = null; |
60a1ea56 | 816 | var handler = function(e) { |
273b2556 | 817 | updateAjax(e, options, state, originalSelect, ajaxHandler); |
60a1ea56 | 818 | }; |
235ef57a DW |
819 | |
820 | // For input events, we do not want to trigger many, many updates. | |
821 | var throttledHandler = function(e) { | |
822 | if (throttleTimeout !== null) { | |
823 | window.clearTimeout(throttleTimeout); | |
824 | throttleTimeout = null; | |
825 | } | |
826 | throttleTimeout = window.setTimeout(handler.bind(this, e), 300); | |
827 | }; | |
60a1ea56 | 828 | // Trigger an ajax update after the text field value changes. |
bb690849 | 829 | inputElement.on("input", throttledHandler); |
60a1ea56 DW |
830 | }); |
831 | } | |
832 | // Show the current values in the selection list. | |
273b2556 | 833 | updateSelectionList(options, state, originalSelect); |
13426bae | 834 | return true; |
60a1ea56 DW |
835 | }); |
836 | } | |
837 | }; | |
838 | }); |