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 */ | |
df5feea4 AN |
27 | define( |
28 | ['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification', 'core/loadingicon'], | |
29 | function($, log, str, templates, notification, LoadingIcon) { | |
60a1ea56 DW |
30 | |
31 | // Private functions and variables. | |
32 | /** @var {Object} KEYS - List of keycode constants. */ | |
33 | var KEYS = { | |
34 | DOWN: 40, | |
35 | ENTER: 13, | |
36 | SPACE: 32, | |
37 | ESCAPE: 27, | |
e375029e | 38 | COMMA: 44, |
60a1ea56 DW |
39 | UP: 38 |
40 | }; | |
41 | ||
a6c73503 | 42 | var uniqueId = Date.now(); |
b7df2485 | 43 | |
60a1ea56 DW |
44 | /** |
45 | * Make an item in the selection list "active". | |
46 | * | |
47 | * @method activateSelection | |
48 | * @private | |
49 | * @param {Number} index The index in the current (visible) list of selection. | |
273b2556 | 50 | * @param {Object} state State variables for this autocomplete element. |
e994dea0 | 51 | * @return {Promise} |
60a1ea56 | 52 | */ |
273b2556 | 53 | var activateSelection = function(index, state) { |
60a1ea56 | 54 | // Find the elements in the DOM. |
273b2556 | 55 | var selectionElement = $(document.getElementById(state.selectionId)); |
60a1ea56 DW |
56 | |
57 | // Count the visible items. | |
58 | var length = selectionElement.children('[aria-selected=true]').length; | |
59 | // Limit the index to the upper/lower bounds of the list (wrap in both directions). | |
60 | index = index % length; | |
61 | while (index < 0) { | |
62 | index += length; | |
63 | } | |
64 | // Find the specified element. | |
65 | var element = $(selectionElement.children('[aria-selected=true]').get(index)); | |
66 | // Create an id we can assign to this element. | |
273b2556 | 67 | var itemId = state.selectionId + '-' + index; |
60a1ea56 DW |
68 | |
69 | // Deselect all the selections. | |
70 | selectionElement.children().attr('data-active-selection', false).attr('id', ''); | |
71 | // Select only this suggestion and assign it the id. | |
72 | element.attr('data-active-selection', true).attr('id', itemId); | |
73 | // Tell the input field it has a new active descendant so the item is announced. | |
74 | selectionElement.attr('aria-activedescendant', itemId); | |
e994dea0 AN |
75 | |
76 | return $.Deferred().resolve(); | |
60a1ea56 DW |
77 | }; |
78 | ||
f992dcf6 DP |
79 | /** |
80 | * Update the element that shows the currently selected items. | |
81 | * | |
82 | * @method updateSelectionList | |
83 | * @private | |
84 | * @param {Object} options Original options for this autocomplete element. | |
85 | * @param {Object} state State variables for this autocomplete element. | |
86 | * @param {JQuery} originalSelect The JQuery object matching the hidden select list. | |
e994dea0 | 87 | * @return {Promise} |
f992dcf6 DP |
88 | */ |
89 | var updateSelectionList = function(options, state, originalSelect) { | |
e994dea0 AN |
90 | var pendingKey = 'form-autocomplete-updateSelectionList-' + state.inputId; |
91 | M.util.js_pending(pendingKey); | |
92 | ||
f992dcf6 DP |
93 | // Build up a valid context to re-render the template. |
94 | var items = []; | |
95 | var newSelection = $(document.getElementById(state.selectionId)); | |
96 | var activeId = newSelection.attr('aria-activedescendant'); | |
97 | var activeValue = false; | |
98 | ||
99 | if (activeId) { | |
100 | activeValue = $(document.getElementById(activeId)).attr('data-value'); | |
101 | } | |
102 | originalSelect.children('option').each(function(index, ele) { | |
103 | if ($(ele).prop('selected')) { | |
a97c9370 | 104 | var label; |
105 | if ($(ele).data('html')) { | |
106 | label = $(ele).data('html'); | |
107 | } else { | |
108 | label = $(ele).html(); | |
109 | } | |
110 | items.push({label: label, value: $(ele).attr('value')}); | |
f992dcf6 DP |
111 | } |
112 | }); | |
9f5f3dcc | 113 | var context = $.extend({items: items}, options, state); |
f992dcf6 DP |
114 | |
115 | // Render the template. | |
e994dea0 AN |
116 | return templates.render('core/form_autocomplete_selection', context) |
117 | .then(function(html, js) { | |
f992dcf6 | 118 | // Add it to the page. |
e994dea0 | 119 | templates.replaceNodeContents(newSelection, html, js); |
f992dcf6 DP |
120 | |
121 | if (activeValue !== false) { | |
122 | // Reselect any previously selected item. | |
123 | newSelection.children('[aria-selected=true]').each(function(index, ele) { | |
124 | if ($(ele).attr('data-value') === activeValue) { | |
125 | activateSelection(index, state); | |
126 | } | |
127 | }); | |
128 | } | |
e994dea0 AN |
129 | |
130 | return activeValue; | |
131 | }) | |
132 | .then(function() { | |
133 | return M.util.js_complete(pendingKey); | |
134 | }) | |
135 | .catch(notification.exception); | |
1eaba735 FM |
136 | }; |
137 | ||
138 | /** | |
139 | * Notify of a change in the selection. | |
140 | * | |
141 | * @param {jQuery} originalSelect The jQuery object matching the hidden select list. | |
1eaba735 FM |
142 | */ |
143 | var notifyChange = function(originalSelect) { | |
144 | if (typeof M.core_formchangechecker !== 'undefined') { | |
eb97811b MG |
145 | M.core_formchangechecker.set_form_changed(); |
146 | } | |
f992dcf6 DP |
147 | originalSelect.change(); |
148 | }; | |
149 | ||
60a1ea56 | 150 | /** |
563fe0a5 | 151 | * Remove the given item from the list of selected things. |
60a1ea56 | 152 | * |
563fe0a5 | 153 | * @method deselectItem |
60a1ea56 | 154 | * @private |
273b2556 DW |
155 | * @param {Object} options Original options for this autocomplete element. |
156 | * @param {Object} state State variables for this autocomplete element. | |
c96f55e6 | 157 | * @param {Element} item The item to be deselected. |
60a1ea56 | 158 | * @param {Element} originalSelect The original select list. |
e994dea0 | 159 | * @return {Promise} |
60a1ea56 | 160 | */ |
273b2556 | 161 | var deselectItem = function(options, state, item, originalSelect) { |
563fe0a5 RW |
162 | var selectedItemValue = $(item).attr('data-value'); |
163 | ||
164 | // We can only deselect items if this is a multi-select field. | |
273b2556 | 165 | if (options.multiple) { |
563fe0a5 RW |
166 | // Look for a match, and toggle the selected property if there is a match. |
167 | originalSelect.children('option').each(function(index, ele) { | |
168 | if ($(ele).attr('value') == selectedItemValue) { | |
169 | $(ele).prop('selected', false); | |
bdd60287 DW |
170 | // We remove newly created custom tags from the suggestions list when they are deselected. |
171 | if ($(ele).attr('data-iscustom')) { | |
172 | $(ele).remove(); | |
173 | } | |
60a1ea56 | 174 | } |
563fe0a5 RW |
175 | }); |
176 | } | |
60a1ea56 | 177 | // Rerender the selection list. |
e994dea0 AN |
178 | return updateSelectionList(options, state, originalSelect) |
179 | .then(function() { | |
180 | // Notify that the selection changed. | |
181 | notifyChange(originalSelect); | |
182 | ||
183 | return; | |
184 | }); | |
60a1ea56 DW |
185 | }; |
186 | ||
187 | /** | |
188 | * Make an item in the suggestions "active" (about to be selected). | |
189 | * | |
190 | * @method activateItem | |
191 | * @private | |
192 | * @param {Number} index The index in the current (visible) list of suggestions. | |
273b2556 | 193 | * @param {Object} state State variables for this instance of autocomplete. |
e994dea0 | 194 | * @return {Promise} |
60a1ea56 | 195 | */ |
273b2556 | 196 | var activateItem = function(index, state) { |
60a1ea56 | 197 | // Find the elements in the DOM. |
273b2556 DW |
198 | var inputElement = $(document.getElementById(state.inputId)); |
199 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); | |
60a1ea56 DW |
200 | |
201 | // Count the visible items. | |
202 | var length = suggestionsElement.children('[aria-hidden=false]').length; | |
203 | // Limit the index to the upper/lower bounds of the list (wrap in both directions). | |
204 | index = index % length; | |
205 | while (index < 0) { | |
206 | index += length; | |
207 | } | |
208 | // Find the specified element. | |
209 | var element = $(suggestionsElement.children('[aria-hidden=false]').get(index)); | |
210 | // Find the index of this item in the full list of suggestions (including hidden). | |
211 | var globalIndex = $(suggestionsElement.children('[role=option]')).index(element); | |
212 | // Create an id we can assign to this element. | |
273b2556 | 213 | var itemId = state.suggestionsId + '-' + globalIndex; |
60a1ea56 DW |
214 | |
215 | // Deselect all the suggestions. | |
216 | suggestionsElement.children().attr('aria-selected', false).attr('id', ''); | |
217 | // Select only this suggestion and assign it the id. | |
218 | element.attr('aria-selected', true).attr('id', itemId); | |
219 | // Tell the input field it has a new active descendant so the item is announced. | |
220 | inputElement.attr('aria-activedescendant', itemId); | |
32f3de56 DW |
221 | |
222 | // Scroll it into view. | |
223 | var scrollPos = element.offset().top | |
224 | - suggestionsElement.offset().top | |
225 | + suggestionsElement.scrollTop() | |
226 | - (suggestionsElement.height() / 2); | |
e994dea0 | 227 | return suggestionsElement.animate({ |
32f3de56 | 228 | scrollTop: scrollPos |
e994dea0 | 229 | }, 100).promise(); |
60a1ea56 DW |
230 | }; |
231 | ||
232 | /** | |
233 | * Find the index of the current active suggestion, and activate the next one. | |
234 | * | |
235 | * @method activateNextItem | |
236 | * @private | |
273b2556 | 237 | * @param {Object} state State variable for this auto complete element. |
e994dea0 | 238 | * @return {Promise} |
60a1ea56 | 239 | */ |
273b2556 | 240 | var activateNextItem = function(state) { |
60a1ea56 | 241 | // Find the list of suggestions. |
273b2556 | 242 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); |
60a1ea56 DW |
243 | // Find the active one. |
244 | var element = suggestionsElement.children('[aria-selected=true]'); | |
245 | // Find it's index. | |
246 | var current = suggestionsElement.children('[aria-hidden=false]').index(element); | |
247 | // Activate the next one. | |
e994dea0 | 248 | return activateItem(current + 1, state); |
60a1ea56 DW |
249 | }; |
250 | ||
251 | /** | |
252 | * Find the index of the current active selection, and activate the previous one. | |
253 | * | |
254 | * @method activatePreviousSelection | |
255 | * @private | |
273b2556 | 256 | * @param {Object} state State variables for this instance of autocomplete. |
e994dea0 | 257 | * @return {Promise} |
60a1ea56 | 258 | */ |
273b2556 | 259 | var activatePreviousSelection = function(state) { |
60a1ea56 | 260 | // Find the list of selections. |
273b2556 | 261 | var selectionsElement = $(document.getElementById(state.selectionId)); |
60a1ea56 DW |
262 | // Find the active one. |
263 | var element = selectionsElement.children('[data-active-selection=true]'); | |
264 | if (!element) { | |
e994dea0 | 265 | return activateSelection(0, state); |
60a1ea56 DW |
266 | } |
267 | // Find it's index. | |
268 | var current = selectionsElement.children('[aria-selected=true]').index(element); | |
269 | // Activate the next one. | |
e994dea0 | 270 | return activateSelection(current - 1, state); |
60a1ea56 | 271 | }; |
e994dea0 | 272 | |
60a1ea56 DW |
273 | /** |
274 | * Find the index of the current active selection, and activate the next one. | |
275 | * | |
276 | * @method activateNextSelection | |
277 | * @private | |
273b2556 | 278 | * @param {Object} state State variables for this instance of autocomplete. |
e994dea0 | 279 | * @return {Promise} |
60a1ea56 | 280 | */ |
273b2556 | 281 | var activateNextSelection = function(state) { |
60a1ea56 | 282 | // Find the list of selections. |
273b2556 | 283 | var selectionsElement = $(document.getElementById(state.selectionId)); |
e994dea0 | 284 | |
60a1ea56 DW |
285 | // Find the active one. |
286 | var element = selectionsElement.children('[data-active-selection=true]'); | |
e994dea0 AN |
287 | var current = 0; |
288 | ||
289 | if (element) { | |
290 | // The element was found. Determine the index and move to the next one. | |
291 | current = selectionsElement.children('[aria-selected=true]').index(element); | |
292 | current = current + 1; | |
293 | } else { | |
294 | // No selected item found. Move to the first. | |
295 | current = 0; | |
60a1ea56 | 296 | } |
e994dea0 AN |
297 | |
298 | return activateSelection(current, state); | |
60a1ea56 DW |
299 | }; |
300 | ||
301 | /** | |
302 | * Find the index of the current active suggestion, and activate the previous one. | |
303 | * | |
304 | * @method activatePreviousItem | |
305 | * @private | |
273b2556 | 306 | * @param {Object} state State variables for this autocomplete element. |
e994dea0 | 307 | * @return {Promise} |
60a1ea56 | 308 | */ |
273b2556 | 309 | var activatePreviousItem = function(state) { |
60a1ea56 | 310 | // Find the list of suggestions. |
273b2556 | 311 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); |
e994dea0 | 312 | |
60a1ea56 DW |
313 | // Find the active one. |
314 | var element = suggestionsElement.children('[aria-selected=true]'); | |
e994dea0 | 315 | |
60a1ea56 DW |
316 | // Find it's index. |
317 | var current = suggestionsElement.children('[aria-hidden=false]').index(element); | |
e994dea0 AN |
318 | |
319 | // Activate the previous one. | |
320 | return activateItem(current - 1, state); | |
60a1ea56 DW |
321 | }; |
322 | ||
323 | /** | |
324 | * Close the list of suggestions. | |
325 | * | |
326 | * @method closeSuggestions | |
327 | * @private | |
273b2556 | 328 | * @param {Object} state State variables for this autocomplete element. |
e994dea0 | 329 | * @return {Promise} |
60a1ea56 | 330 | */ |
273b2556 | 331 | var closeSuggestions = function(state) { |
60a1ea56 | 332 | // Find the elements in the DOM. |
273b2556 DW |
333 | var inputElement = $(document.getElementById(state.inputId)); |
334 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); | |
60a1ea56 DW |
335 | |
336 | // Announce the list of suggestions was closed, and read the current list of selections. | |
273b2556 | 337 | inputElement.attr('aria-expanded', false).attr('aria-activedescendant', state.selectionId); |
e994dea0 | 338 | |
60a1ea56 DW |
339 | // Hide the suggestions list (from screen readers too). |
340 | suggestionsElement.hide().attr('aria-hidden', true); | |
e994dea0 AN |
341 | |
342 | return $.Deferred().resolve(); | |
60a1ea56 DW |
343 | }; |
344 | ||
345 | /** | |
346 | * Rebuild the list of suggestions based on the current values in the select list, and the query. | |
347 | * | |
348 | * @method updateSuggestions | |
349 | * @private | |
273b2556 DW |
350 | * @param {Object} options The original options for this autocomplete. |
351 | * @param {Object} state The state variables for this autocomplete. | |
352 | * @param {String} query The current text for the search string. | |
60a1ea56 | 353 | * @param {JQuery} originalSelect The JQuery object matching the hidden select list. |
e994dea0 | 354 | * @return {Promise} |
60a1ea56 | 355 | */ |
273b2556 | 356 | var updateSuggestions = function(options, state, query, originalSelect) { |
e994dea0 AN |
357 | var pendingKey = 'form-autocomplete-updateSuggestions-' + state.inputId; |
358 | M.util.js_pending(pendingKey); | |
359 | ||
60a1ea56 | 360 | // Find the elements in the DOM. |
273b2556 DW |
361 | var inputElement = $(document.getElementById(state.inputId)); |
362 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); | |
60a1ea56 DW |
363 | |
364 | // Used to track if we found any visible suggestions. | |
365 | var matchingElements = false; | |
366 | // Options is used by the context when rendering the suggestions from a template. | |
273b2556 | 367 | var suggestions = []; |
60a1ea56 DW |
368 | originalSelect.children('option').each(function(index, option) { |
369 | if ($(option).prop('selected') !== true) { | |
9f5f3dcc | 370 | suggestions[suggestions.length] = {label: option.innerHTML, value: $(option).attr('value')}; |
60a1ea56 DW |
371 | } |
372 | }); | |
373 | ||
374 | // Re-render the list of suggestions. | |
273b2556 | 375 | var searchquery = state.caseSensitive ? query : query.toLocaleLowerCase(); |
9f5f3dcc | 376 | var context = $.extend({options: suggestions}, options, state); |
e994dea0 | 377 | var returnVal = templates.render( |
60a1ea56 | 378 | 'core/form_autocomplete_suggestions', |
273b2556 | 379 | context |
e994dea0 AN |
380 | ) |
381 | .then(function(html, js) { | |
60a1ea56 | 382 | // We have the new template, insert it in the page. |
e994dea0 AN |
383 | templates.replaceNode(suggestionsElement, html, js); |
384 | ||
60a1ea56 | 385 | // Get the element again. |
273b2556 | 386 | suggestionsElement = $(document.getElementById(state.suggestionsId)); |
60a1ea56 DW |
387 | // Show it if it is hidden. |
388 | suggestionsElement.show().attr('aria-hidden', false); | |
389 | // For each option in the list, hide it if it doesn't match the query. | |
390 | suggestionsElement.children().each(function(index, node) { | |
391 | node = $(node); | |
273b2556 DW |
392 | if ((options.caseSensitive && node.text().indexOf(searchquery) > -1) || |
393 | (!options.caseSensitive && node.text().toLocaleLowerCase().indexOf(searchquery) > -1)) { | |
60a1ea56 DW |
394 | node.show().attr('aria-hidden', false); |
395 | matchingElements = true; | |
396 | } else { | |
397 | node.hide().attr('aria-hidden', true); | |
398 | } | |
399 | }); | |
400 | // If we found any matches, show the list. | |
81c471e2 | 401 | inputElement.attr('aria-expanded', true); |
9411beb3 DM |
402 | if (originalSelect.attr('data-notice')) { |
403 | // Display a notice rather than actual suggestions. | |
404 | suggestionsElement.html(originalSelect.attr('data-notice')); | |
405 | } else if (matchingElements) { | |
60a1ea56 DW |
406 | // We only activate the first item in the list if tags is false, |
407 | // because otherwise "Enter" would select the first item, instead of | |
408 | // creating a new tag. | |
273b2556 DW |
409 | if (!options.tags) { |
410 | activateItem(0, state); | |
60a1ea56 DW |
411 | } |
412 | } else { | |
81c471e2 DW |
413 | // Nothing matches. Tell them that. |
414 | str.get_string('nosuggestions', 'form').done(function(nosuggestionsstr) { | |
415 | suggestionsElement.html(nosuggestionsstr); | |
416 | }); | |
60a1ea56 | 417 | } |
60a1ea56 | 418 | |
e994dea0 AN |
419 | return suggestionsElement; |
420 | }) | |
421 | .then(function() { | |
422 | return M.util.js_complete(pendingKey); | |
423 | }) | |
424 | .catch(notification.exception); | |
425 | ||
426 | return returnVal; | |
60a1ea56 DW |
427 | }; |
428 | ||
429 | /** | |
430 | * Create a new item for the list (a tag). | |
431 | * | |
432 | * @method createItem | |
433 | * @private | |
273b2556 DW |
434 | * @param {Object} options The original options for the autocomplete. |
435 | * @param {Object} state State variables for the autocomplete. | |
60a1ea56 | 436 | * @param {JQuery} originalSelect The JQuery object matching the hidden select list. |
e994dea0 | 437 | * @return {Promise} |
60a1ea56 | 438 | */ |
273b2556 | 439 | var createItem = function(options, state, originalSelect) { |
60a1ea56 | 440 | // Find the element in the DOM. |
273b2556 | 441 | var inputElement = $(document.getElementById(state.inputId)); |
60a1ea56 DW |
442 | // Get the current text in the input field. |
443 | var query = inputElement.val(); | |
444 | var tags = query.split(','); | |
445 | var found = false; | |
446 | ||
447 | $.each(tags, function(tagindex, tag) { | |
448 | // If we can only select one at a time, deselect any current value. | |
449 | tag = tag.trim(); | |
450 | if (tag !== '') { | |
273b2556 | 451 | if (!options.multiple) { |
60a1ea56 DW |
452 | originalSelect.children('option').prop('selected', false); |
453 | } | |
454 | // Look for an existing option in the select list that matches this new tag. | |
455 | originalSelect.children('option').each(function(index, ele) { | |
456 | if ($(ele).attr('value') == tag) { | |
457 | found = true; | |
458 | $(ele).prop('selected', true); | |
459 | } | |
460 | }); | |
461 | // Only create the item if it's new. | |
462 | if (!found) { | |
463 | var option = $('<option>'); | |
18e2f403 | 464 | option.append(document.createTextNode(tag)); |
60a1ea56 DW |
465 | option.attr('value', tag); |
466 | originalSelect.append(option); | |
467 | option.prop('selected', true); | |
bdd60287 DW |
468 | // We mark newly created custom options as we handle them differently if they are "deselected". |
469 | option.attr('data-iscustom', true); | |
60a1ea56 DW |
470 | } |
471 | } | |
472 | }); | |
bdd60287 | 473 | |
e994dea0 AN |
474 | return updateSelectionList(options, state, originalSelect) |
475 | .then(function() { | |
476 | // Notify that the selection changed. | |
477 | notifyChange(originalSelect); | |
478 | ||
479 | return; | |
480 | }) | |
481 | .then(function() { | |
482 | // Clear the input field. | |
483 | inputElement.val(''); | |
484 | ||
485 | return; | |
486 | }) | |
487 | .then(function() { | |
488 | // Close the suggestions list. | |
489 | return closeSuggestions(state); | |
490 | }); | |
60a1ea56 DW |
491 | }; |
492 | ||
60a1ea56 DW |
493 | /** |
494 | * Select the currently active item from the suggestions list. | |
495 | * | |
496 | * @method selectCurrentItem | |
497 | * @private | |
273b2556 DW |
498 | * @param {Object} options The original options for the autocomplete. |
499 | * @param {Object} state State variables for the autocomplete. | |
60a1ea56 | 500 | * @param {JQuery} originalSelect The JQuery object matching the hidden select list. |
e994dea0 | 501 | * @return {Promise} |
60a1ea56 | 502 | */ |
273b2556 | 503 | var selectCurrentItem = function(options, state, originalSelect) { |
60a1ea56 | 504 | // Find the elements in the page. |
273b2556 DW |
505 | var inputElement = $(document.getElementById(state.inputId)); |
506 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); | |
60a1ea56 DW |
507 | // Here loop through suggestions and set val to join of all selected items. |
508 | ||
509 | var selectedItemValue = suggestionsElement.children('[aria-selected=true]').attr('data-value'); | |
510 | // The select will either be a single or multi select, so the following will either | |
511 | // select one or more items correctly. | |
512 | // Take care to use 'prop' and not 'attr' for selected properties. | |
513 | // If only one can be selected at a time, start by deselecting everything. | |
273b2556 | 514 | if (!options.multiple) { |
60a1ea56 DW |
515 | originalSelect.children('option').prop('selected', false); |
516 | } | |
517 | // Look for a match, and toggle the selected property if there is a match. | |
518 | originalSelect.children('option').each(function(index, ele) { | |
519 | if ($(ele).attr('value') == selectedItemValue) { | |
520 | $(ele).prop('selected', true); | |
521 | } | |
522 | }); | |
78162bf5 | 523 | |
e994dea0 AN |
524 | return updateSelectionList(options, state, originalSelect) |
525 | .then(function() { | |
526 | // Notify that the selection changed. | |
527 | notifyChange(originalSelect); | |
786e014c | 528 | |
e994dea0 AN |
529 | return; |
530 | }) | |
531 | .then(function() { | |
532 | if (options.closeSuggestionsOnSelect) { | |
533 | // Clear the input element. | |
534 | inputElement.val(''); | |
535 | // Close the list of suggestions. | |
536 | return closeSuggestions(state); | |
537 | } else { | |
538 | // Focus on the input element so the suggestions does not auto-close. | |
539 | inputElement.focus(); | |
540 | // Remove the last selected item from the suggestions list. | |
541 | return updateSuggestions(options, state, inputElement.val(), originalSelect); | |
542 | } | |
543 | }); | |
60a1ea56 DW |
544 | }; |
545 | ||
546 | /** | |
547 | * Fetch a new list of options via ajax. | |
548 | * | |
549 | * @method updateAjax | |
550 | * @private | |
551 | * @param {Event} e The event that triggered this update. | |
273b2556 DW |
552 | * @param {Object} options The original options for the autocomplete. |
553 | * @param {Object} state The state variables for the autocomplete. | |
60a1ea56 | 554 | * @param {JQuery} originalSelect The JQuery object matching the hidden select list. |
60a1ea56 | 555 | * @param {Object} ajaxHandler This is a module that does the ajax fetch and translates the results. |
e994dea0 | 556 | * @return {Promise} |
60a1ea56 | 557 | */ |
273b2556 | 558 | var updateAjax = function(e, options, state, originalSelect, ajaxHandler) { |
e994dea0 | 559 | var pendingPromise = addPendingJSPromise('updateAjax'); |
efef2efd HN |
560 | // We need to show the indicator outside of the hidden select list. |
561 | // So we get the parent id of the hidden select list. | |
562 | var parentElement = $(document.getElementById(state.selectId)).parent(); | |
563 | LoadingIcon.addIconToContainerRemoveOnCompletion(parentElement, pendingPromise); | |
e994dea0 | 564 | |
60a1ea56 DW |
565 | // Get the query to pass to the ajax function. |
566 | var query = $(e.currentTarget).val(); | |
567 | // Call the transport function to do the ajax (name taken from Select2). | |
273b2556 | 568 | ajaxHandler.transport(options.selector, query, function(results) { |
60a1ea56 | 569 | // We got a result - pass it through the translator before using it. |
273b2556 | 570 | var processedResults = ajaxHandler.processResults(options.selector, results); |
60a1ea56 DW |
571 | var existingValues = []; |
572 | ||
573 | // Now destroy all options that are not currently selected. | |
574 | originalSelect.children('option').each(function(optionIndex, option) { | |
575 | option = $(option); | |
576 | if (!option.prop('selected')) { | |
577 | option.remove(); | |
578 | } else { | |
91ab264c | 579 | existingValues.push(String(option.attr('value'))); |
60a1ea56 DW |
580 | } |
581 | }); | |
91ab264c DW |
582 | |
583 | if (!options.multiple && originalSelect.children('option').length === 0) { | |
584 | // If this is a single select - and there are no current options | |
585 | // the first option added will be selected by the browser. This causes a bug! | |
586 | // We need to insert an empty option so that none of the real options are selected. | |
587 | var option = $('<option>'); | |
588 | originalSelect.append(option); | |
589 | } | |
9411beb3 DM |
590 | if ($.isArray(processedResults)) { |
591 | // Add all the new ones returned from ajax. | |
592 | $.each(processedResults, function(resultIndex, result) { | |
593 | if (existingValues.indexOf(String(result.value)) === -1) { | |
594 | var option = $('<option>'); | |
595 | option.append(result.label); | |
596 | option.attr('value', result.value); | |
597 | originalSelect.append(option); | |
598 | } | |
599 | }); | |
600 | originalSelect.attr('data-notice', ''); | |
601 | } else { | |
602 | // The AJAX handler returned a string instead of the array. | |
603 | originalSelect.attr('data-notice', processedResults); | |
604 | } | |
60a1ea56 | 605 | // Update the list of suggestions now from the new values in the select list. |
e994dea0 | 606 | pendingPromise.resolve(updateSuggestions(options, state, '', originalSelect)); |
78162bf5 | 607 | }, function(error) { |
e994dea0 | 608 | pendingPromise.reject(error); |
78162bf5 | 609 | }); |
e994dea0 AN |
610 | |
611 | return pendingPromise; | |
60a1ea56 DW |
612 | }; |
613 | ||
614 | /** | |
615 | * Add all the event listeners required for keyboard nav, blur clicks etc. | |
616 | * | |
617 | * @method addNavigation | |
618 | * @private | |
273b2556 DW |
619 | * @param {Object} options The options used to create this autocomplete element. |
620 | * @param {Object} state State variables for this autocomplete element. | |
60a1ea56 | 621 | * @param {JQuery} originalSelect The JQuery object matching the hidden select list. |
60a1ea56 | 622 | */ |
273b2556 | 623 | var addNavigation = function(options, state, originalSelect) { |
60a1ea56 | 624 | // Start with the input element. |
273b2556 | 625 | var inputElement = $(document.getElementById(state.inputId)); |
60a1ea56 DW |
626 | // Add keyboard nav with keydown. |
627 | inputElement.on('keydown', function(e) { | |
e994dea0 | 628 | var pendingJsPromise = addPendingJSPromise('addNavigation-' + state.inputId + '-' + e.keyCode); |
78162bf5 | 629 | |
60a1ea56 DW |
630 | switch (e.keyCode) { |
631 | case KEYS.DOWN: | |
632 | // If the suggestion list is open, move to the next item. | |
273b2556 | 633 | if (!options.showSuggestions) { |
97d2ea7f | 634 | // Do not consume this event. |
e994dea0 | 635 | pendingJsPromise.resolve(); |
97d2ea7f DW |
636 | return true; |
637 | } else if (inputElement.attr('aria-expanded') === "true") { | |
e994dea0 | 638 | pendingJsPromise.resolve(activateNextItem(state)); |
60a1ea56 | 639 | } else { |
d304952b | 640 | // Handle ajax population of suggestions. |
273b2556 DW |
641 | if (!inputElement.val() && options.ajax) { |
642 | require([options.ajax], function(ajaxHandler) { | |
e994dea0 | 643 | pendingJsPromise.resolve(updateAjax(e, options, state, originalSelect, ajaxHandler)); |
d304952b AG |
644 | }); |
645 | } else { | |
47dd5350 | 646 | // Open the suggestions list. |
e994dea0 | 647 | pendingJsPromise.resolve(updateSuggestions(options, state, inputElement.val(), originalSelect)); |
d304952b | 648 | } |
60a1ea56 DW |
649 | } |
650 | // We handled this event, so prevent it. | |
651 | e.preventDefault(); | |
652 | return false; | |
60a1ea56 DW |
653 | case KEYS.UP: |
654 | // Choose the previous active item. | |
e994dea0 AN |
655 | pendingJsPromise.resolve(activatePreviousItem(state)); |
656 | ||
60a1ea56 DW |
657 | // We handled this event, so prevent it. |
658 | e.preventDefault(); | |
659 | return false; | |
660 | case KEYS.ENTER: | |
273b2556 | 661 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); |
60a1ea56 DW |
662 | if ((inputElement.attr('aria-expanded') === "true") && |
663 | (suggestionsElement.children('[aria-selected=true]').length > 0)) { | |
664 | // If the suggestion list has an active item, select it. | |
e994dea0 | 665 | pendingJsPromise.resolve(selectCurrentItem(options, state, originalSelect)); |
273b2556 | 666 | } else if (options.tags) { |
60a1ea56 | 667 | // If tags are enabled, create a tag. |
e994dea0 AN |
668 | pendingJsPromise.resolve(createItem(options, state, originalSelect)); |
669 | } else { | |
670 | pendingJsPromise.resolve(); | |
60a1ea56 | 671 | } |
e994dea0 | 672 | |
60a1ea56 DW |
673 | // We handled this event, so prevent it. |
674 | e.preventDefault(); | |
675 | return false; | |
676 | case KEYS.ESCAPE: | |
677 | if (inputElement.attr('aria-expanded') === "true") { | |
678 | // If the suggestion list is open, close it. | |
e994dea0 AN |
679 | pendingJsPromise.resolve(closeSuggestions(state)); |
680 | } else { | |
681 | pendingJsPromise.resolve(); | |
60a1ea56 DW |
682 | } |
683 | // We handled this event, so prevent it. | |
684 | e.preventDefault(); | |
685 | return false; | |
e375029e | 686 | } |
e994dea0 | 687 | pendingJsPromise.resolve(); |
e375029e NK |
688 | return true; |
689 | }); | |
690 | // Support multi lingual COMMA keycode (44). | |
691 | inputElement.on('keypress', function(e) { | |
e994dea0 | 692 | |
e375029e NK |
693 | if (e.keyCode === KEYS.COMMA) { |
694 | if (options.tags) { | |
695 | // If we are allowing tags, comma should create a tag (or enter). | |
e994dea0 AN |
696 | addPendingJSPromise('keypress-' + e.keyCode) |
697 | .resolve(createItem(options, state, originalSelect)); | |
e375029e NK |
698 | } |
699 | // We handled this event, so prevent it. | |
700 | e.preventDefault(); | |
701 | return false; | |
60a1ea56 DW |
702 | } |
703 | return true; | |
704 | }); | |
072a033a SR |
705 | // Support submitting the form without leaving the autocomplete element, |
706 | // or submitting too quick before the blur handler action is completed. | |
707 | inputElement.closest('form').on('submit', function() { | |
708 | if (options.tags) { | |
709 | // If tags are enabled, create a tag. | |
710 | addPendingJSPromise('form-autocomplete-submit') | |
711 | .resolve(createItem(options, state, originalSelect)); | |
712 | } | |
713 | ||
714 | return true; | |
715 | }); | |
e1db2b41 | 716 | inputElement.on('blur', function() { |
e994dea0 | 717 | var pendingPromise = addPendingJSPromise('form-autocomplete-blur'); |
e1db2b41 RW |
718 | window.setTimeout(function() { |
719 | // Get the current element with focus. | |
720 | var focusElement = $(document.activeElement); | |
e994dea0 | 721 | |
e75bf415 SR |
722 | // Only close the menu if the input hasn't regained focus and if the element still exists, |
723 | // and regain focus if the scrollbar is clicked. | |
072a033a SR |
724 | // Due to the half a second delay, it is possible that the input element no longer exist |
725 | // by the time this code is being executed. | |
e75bf415 SR |
726 | if (focusElement.is(document.getElementById(state.suggestionsId))) { |
727 | inputElement.focus(); // Probably the scrollbar is clicked. Regain focus. | |
728 | } else if (!focusElement.is(inputElement) && $(document.getElementById(state.inputId)).length) { | |
273b2556 | 729 | if (options.tags) { |
e994dea0 AN |
730 | pendingPromise.then(function() { |
731 | return createItem(options, state, originalSelect); | |
732 | }) | |
733 | .catch(); | |
527bde6e | 734 | } |
e994dea0 AN |
735 | pendingPromise.then(function() { |
736 | return closeSuggestions(state); | |
737 | }) | |
738 | .catch(); | |
60a1ea56 | 739 | } |
e994dea0 AN |
740 | |
741 | pendingPromise.resolve(); | |
60a1ea56 DW |
742 | }, 500); |
743 | }); | |
273b2556 DW |
744 | if (options.showSuggestions) { |
745 | var arrowElement = $(document.getElementById(state.downArrowId)); | |
47dd5350 | 746 | arrowElement.on('click', function(e) { |
e994dea0 AN |
747 | var pendingPromise = addPendingJSPromise('form-autocomplete-show-suggestions'); |
748 | ||
97d2ea7f DW |
749 | // Prevent the close timer, or we will open, then close the suggestions. |
750 | inputElement.focus(); | |
e994dea0 | 751 | |
47dd5350 DW |
752 | // Handle ajax population of suggestions. |
753 | if (!inputElement.val() && options.ajax) { | |
754 | require([options.ajax], function(ajaxHandler) { | |
e994dea0 | 755 | pendingPromise.resolve(updateAjax(e, options, state, originalSelect, ajaxHandler)); |
47dd5350 DW |
756 | }); |
757 | } else { | |
758 | // Else - open the suggestions list. | |
e994dea0 | 759 | pendingPromise.resolve(updateSuggestions(options, state, inputElement.val(), originalSelect)); |
47dd5350 | 760 | } |
97d2ea7f DW |
761 | }); |
762 | } | |
60a1ea56 | 763 | |
273b2556 | 764 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); |
5cfd7a72 MO |
765 | // Remove any click handler first. |
766 | suggestionsElement.parent().prop("onclick", null).off("click"); | |
60a1ea56 | 767 | suggestionsElement.parent().on('click', '[role=option]', function(e) { |
e994dea0 | 768 | var pendingPromise = addPendingJSPromise('form-autocomplete-parent'); |
60a1ea56 DW |
769 | // Handle clicks on suggestions. |
770 | var element = $(e.currentTarget).closest('[role=option]'); | |
273b2556 | 771 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); |
60a1ea56 DW |
772 | // Find the index of the clicked on suggestion. |
773 | var current = suggestionsElement.children('[aria-hidden=false]').index(element); | |
e994dea0 | 774 | |
60a1ea56 | 775 | // Activate it. |
e994dea0 AN |
776 | activateItem(current, state) |
777 | .then(function() { | |
778 | // And select it. | |
779 | return selectCurrentItem(options, state, originalSelect); | |
780 | }) | |
781 | .then(function() { | |
782 | return pendingPromise.resolve(); | |
783 | }) | |
784 | .catch(); | |
60a1ea56 | 785 | }); |
273b2556 | 786 | var selectionElement = $(document.getElementById(state.selectionId)); |
60a1ea56 | 787 | // Handle clicks on the selected items (will unselect an item). |
adebc069 | 788 | selectionElement.on('click', '[role=listitem]', function(e) { |
e994dea0 AN |
789 | var pendingPromise = addPendingJSPromise('form-autocomplete-clicks'); |
790 | ||
563fe0a5 | 791 | // Remove it from the selection. |
e994dea0 | 792 | pendingPromise.resolve(deselectItem(options, state, $(e.currentTarget), originalSelect)); |
60a1ea56 DW |
793 | }); |
794 | // Keyboard navigation for the selection list. | |
adebc069 | 795 | selectionElement.on('keydown', function(e) { |
e994dea0 | 796 | var pendingPromise = addPendingJSPromise('form-autocomplete-keydown-' + e.keyCode); |
60a1ea56 DW |
797 | switch (e.keyCode) { |
798 | case KEYS.DOWN: | |
60a1ea56 DW |
799 | // We handled this event, so prevent it. |
800 | e.preventDefault(); | |
e994dea0 AN |
801 | |
802 | // Choose the next selection item. | |
803 | pendingPromise.resolve(activateNextSelection(state)); | |
60a1ea56 DW |
804 | return false; |
805 | case KEYS.UP: | |
60a1ea56 DW |
806 | // We handled this event, so prevent it. |
807 | e.preventDefault(); | |
e994dea0 AN |
808 | |
809 | // Choose the previous selection item. | |
810 | pendingPromise.resolve(activatePreviousSelection(state)); | |
60a1ea56 DW |
811 | return false; |
812 | case KEYS.SPACE: | |
813 | case KEYS.ENTER: | |
563fe0a5 | 814 | // Get the item that is currently selected. |
273b2556 | 815 | var selectedItem = $(document.getElementById(state.selectionId)).children('[data-active-selection=true]'); |
563fe0a5 | 816 | if (selectedItem) { |
563fe0a5 | 817 | e.preventDefault(); |
e994dea0 AN |
818 | |
819 | // Unselect this item. | |
820 | pendingPromise.resolve(deselectItem(options, state, selectedItem, originalSelect)); | |
563fe0a5 | 821 | } |
60a1ea56 DW |
822 | return false; |
823 | } | |
e994dea0 AN |
824 | |
825 | // Not handled. Resolve the promise. | |
826 | pendingPromise.resolve(); | |
60a1ea56 DW |
827 | return true; |
828 | }); | |
829 | // Whenever the input field changes, update the suggestion list. | |
273b2556 | 830 | if (options.showSuggestions) { |
68a0485c JP |
831 | // If this field uses ajax, set it up. |
832 | if (options.ajax) { | |
833 | require([options.ajax], function(ajaxHandler) { | |
e994dea0 AN |
834 | // Creating throttled handlers free of race conditions, and accurate. |
835 | // This code keeps track of a throttleTimeout, which is periodically polled. | |
836 | // Once the throttled function is executed, the fact that it is running is noted. | |
837 | // If a subsequent request comes in whilst it is running, this request is re-applied. | |
68a0485c | 838 | var throttleTimeout = null; |
e994dea0 | 839 | var inProgress = false; |
78162bf5 | 840 | var pendingKey = 'autocomplete-throttledhandler'; |
68a0485c | 841 | var handler = function(e) { |
e994dea0 AN |
842 | // Empty the current timeout. |
843 | throttleTimeout = null; | |
844 | ||
845 | // Mark this request as in-progress. | |
846 | inProgress = true; | |
847 | ||
848 | // Process the request. | |
849 | updateAjax(e, options, state, originalSelect, ajaxHandler) | |
850 | .then(function() { | |
851 | // Check if the throttleTimeout is still empty. | |
852 | // There's a potential condition whereby the JS request takes long enough to complete that | |
853 | // another task has been queued. | |
854 | // In this case another task will be kicked off and we must wait for that before marking htis as | |
855 | // complete. | |
856 | if (null === throttleTimeout) { | |
857 | // Mark this task as complete. | |
858 | M.util.js_complete(pendingKey); | |
859 | } | |
860 | inProgress = false; | |
861 | ||
862 | return arguments[0]; | |
863 | }) | |
864 | .catch(notification.exception); | |
68a0485c JP |
865 | }; |
866 | ||
867 | // For input events, we do not want to trigger many, many updates. | |
868 | var throttledHandler = function(e) { | |
e994dea0 AN |
869 | window.clearTimeout(throttleTimeout); |
870 | if (inProgress) { | |
871 | // A request is currently ongoing. | |
872 | // Delay this request another 100ms. | |
873 | throttleTimeout = window.setTimeout(throttledHandler.bind(this, e), 100); | |
874 | return; | |
875 | } | |
876 | ||
877 | if (throttleTimeout === null) { | |
878 | // There is currently no existing timeout handler, and it has not been recently cleared, so | |
879 | // this is the start of a throttling check. | |
78162bf5 | 880 | M.util.js_pending(pendingKey); |
68a0485c | 881 | } |
e994dea0 AN |
882 | |
883 | // There is currently no existing timeout handler, and it has not been recently cleared, so this | |
884 | // is the start of a throttling check. | |
885 | // Queue a call to the handler. | |
68a0485c JP |
886 | throttleTimeout = window.setTimeout(handler.bind(this, e), 300); |
887 | }; | |
e994dea0 | 888 | |
68a0485c JP |
889 | // Trigger an ajax update after the text field value changes. |
890 | inputElement.on("input", throttledHandler); | |
891 | }); | |
892 | } else { | |
893 | inputElement.on('input', function(e) { | |
894 | var query = $(e.currentTarget).val(); | |
895 | var last = $(e.currentTarget).data('last-value'); | |
896 | // IE11 fires many more input events than required - even when the value has not changed. | |
897 | // We need to only do this for real value changed events or the suggestions will be | |
898 | // unclickable on IE11 (because they will be rebuilt before the click event fires). | |
899 | // Note - because of this we cannot close the list when the query is empty or it will break | |
900 | // on IE11. | |
901 | if (last !== query) { | |
902 | updateSuggestions(options, state, query, originalSelect); | |
903 | } | |
904 | $(e.currentTarget).data('last-value', query); | |
905 | }); | |
906 | } | |
97d2ea7f | 907 | } |
60a1ea56 DW |
908 | }; |
909 | ||
e994dea0 AN |
910 | /** |
911 | * Create and return an unresolved Promise for some pending JS. | |
912 | * | |
913 | * @param {String} key The unique identifier for this promise | |
914 | * @return {Promise} | |
915 | */ | |
916 | var addPendingJSPromise = function(key) { | |
917 | var pendingKey = 'form-autocomplete:' + key; | |
918 | ||
919 | M.util.js_pending(pendingKey); | |
920 | ||
921 | var pendingPromise = $.Deferred(); | |
922 | ||
923 | pendingPromise | |
924 | .then(function() { | |
925 | M.util.js_complete(pendingKey); | |
926 | ||
927 | return arguments[0]; | |
928 | }) | |
929 | .catch(notification.exception); | |
930 | ||
931 | return pendingPromise; | |
932 | }; | |
933 | ||
60a1ea56 DW |
934 | return /** @alias module:core/form-autocomplete */ { |
935 | // Public variables and functions. | |
936 | /** | |
937 | * Turn a boring select box into an auto-complete beast. | |
938 | * | |
939 | * @method enhance | |
c96f55e6 | 940 | * @param {string} selector The selector that identifies the select box. |
60a1ea56 DW |
941 | * @param {boolean} tags Whether to allow support for tags (can define new entries). |
942 | * @param {string} ajax Name of an AMD module to handle ajax requests. If specified, the AMD | |
943 | * module must expose 2 functions "transport" and "processResults". | |
944 | * These are modeled on Select2 see: https://select2.github.io/options.html#ajax | |
945 | * @param {String} placeholder - The text to display before a selection is made. | |
ecbc2a2f | 946 | * @param {Boolean} caseSensitive - If search has to be made case sensitive. |
c96f55e6 | 947 | * @param {Boolean} showSuggestions - If suggestions should be shown |
427e3cbc | 948 | * @param {String} noSelectionString - Text to display when there is no selection |
198d7291 | 949 | * @param {Boolean} closeSuggestionsOnSelect - Whether to close the suggestions immediately after making a selection. |
13426bae | 950 | * @return {Promise} |
60a1ea56 | 951 | */ |
198d7291 JP |
952 | enhance: function(selector, tags, ajax, placeholder, caseSensitive, showSuggestions, noSelectionString, |
953 | closeSuggestionsOnSelect) { | |
60a1ea56 | 954 | // Set some default values. |
273b2556 DW |
955 | var options = { |
956 | selector: selector, | |
957 | tags: false, | |
958 | ajax: false, | |
959 | placeholder: placeholder, | |
960 | caseSensitive: false, | |
427e3cbc EM |
961 | showSuggestions: true, |
962 | noSelectionString: noSelectionString | |
273b2556 | 963 | }; |
78162bf5 AN |
964 | var pendingKey = 'autocomplete-setup-' + selector; |
965 | M.util.js_pending(pendingKey); | |
273b2556 DW |
966 | if (typeof tags !== "undefined") { |
967 | options.tags = tags; | |
60a1ea56 | 968 | } |
273b2556 DW |
969 | if (typeof ajax !== "undefined") { |
970 | options.ajax = ajax; | |
60a1ea56 | 971 | } |
273b2556 DW |
972 | if (typeof caseSensitive !== "undefined") { |
973 | options.caseSensitive = caseSensitive; | |
ecbc2a2f | 974 | } |
273b2556 DW |
975 | if (typeof showSuggestions !== "undefined") { |
976 | options.showSuggestions = showSuggestions; | |
97d2ea7f | 977 | } |
427e3cbc | 978 | if (typeof noSelectionString === "undefined") { |
35be5826 | 979 | str.get_string('noselection', 'form').done(function(result) { |
1388b618 JP |
980 | options.noSelectionString = result; |
981 | }).fail(notification.exception); | |
427e3cbc | 982 | } |
60a1ea56 DW |
983 | |
984 | // Look for the select element. | |
985 | var originalSelect = $(selector); | |
986 | if (!originalSelect) { | |
987 | log.debug('Selector not found: ' + selector); | |
78162bf5 | 988 | M.util.js_complete(pendingKey); |
f3ecea3a | 989 | return false; |
60a1ea56 DW |
990 | } |
991 | ||
d8e57f02 DW |
992 | originalSelect.css('visibility', 'hidden').attr('aria-hidden', true); |
993 | ||
60a1ea56 | 994 | // Hide the original select. |
60a1ea56 DW |
995 | |
996 | // Find or generate some ids. | |
273b2556 DW |
997 | var state = { |
998 | selectId: originalSelect.attr('id'), | |
b7df2485 DW |
999 | inputId: 'form_autocomplete_input-' + uniqueId, |
1000 | suggestionsId: 'form_autocomplete_suggestions-' + uniqueId, | |
1001 | selectionId: 'form_autocomplete_selection-' + uniqueId, | |
1002 | downArrowId: 'form_autocomplete_downarrow-' + uniqueId | |
273b2556 | 1003 | }; |
b7df2485 DW |
1004 | |
1005 | // Increment the unique counter so we don't get duplicates ever. | |
1006 | uniqueId++; | |
1007 | ||
273b2556 DW |
1008 | options.multiple = originalSelect.attr('multiple'); |
1009 | ||
198d7291 JP |
1010 | if (typeof closeSuggestionsOnSelect !== "undefined") { |
1011 | options.closeSuggestionsOnSelect = closeSuggestionsOnSelect; | |
1012 | } else { | |
1013 | // If not specified, this will close suggestions by default for single-select elements only. | |
1014 | options.closeSuggestionsOnSelect = !options.multiple; | |
1015 | } | |
1016 | ||
273b2556 | 1017 | var originalLabel = $('[for=' + state.selectId + ']'); |
60a1ea56 | 1018 | // Create the new markup and insert it after the select. |
273b2556 | 1019 | var suggestions = []; |
60a1ea56 | 1020 | originalSelect.children('option').each(function(index, option) { |
9f5f3dcc | 1021 | suggestions[index] = {label: option.innerHTML, value: $(option).attr('value')}; |
60a1ea56 DW |
1022 | }); |
1023 | ||
1024 | // Render all the parts of our UI. | |
273b2556 DW |
1025 | var context = $.extend({}, options, state); |
1026 | context.options = suggestions; | |
1027 | context.items = []; | |
1028 | ||
5cdf8d49 DM |
1029 | // Collect rendered inline JS to be executed once the HTML is shown. |
1030 | var collectedjs = ''; | |
1031 | ||
1032 | var renderInput = templates.render('core/form_autocomplete_input', context).then(function(html, js) { | |
1033 | collectedjs += js; | |
1034 | return html; | |
1035 | }); | |
1036 | ||
1037 | var renderDatalist = templates.render('core/form_autocomplete_suggestions', context).then(function(html, js) { | |
1038 | collectedjs += js; | |
1039 | return html; | |
1040 | }); | |
1041 | ||
1042 | var renderSelection = templates.render('core/form_autocomplete_selection', context).then(function(html, js) { | |
1043 | collectedjs += js; | |
1044 | return html; | |
1045 | }); | |
60a1ea56 | 1046 | |
e994dea0 AN |
1047 | return $.when(renderInput, renderDatalist, renderSelection) |
1048 | .then(function(input, suggestions, selection) { | |
d8e57f02 | 1049 | originalSelect.hide(); |
60a1ea56 DW |
1050 | originalSelect.after(suggestions); |
1051 | originalSelect.after(input); | |
1052 | originalSelect.after(selection); | |
d8e57f02 | 1053 | |
5cdf8d49 DM |
1054 | templates.runTemplateJS(collectedjs); |
1055 | ||
60a1ea56 | 1056 | // Update the form label to point to the text input. |
273b2556 | 1057 | originalLabel.attr('for', state.inputId); |
60a1ea56 | 1058 | // Add the event handlers. |
273b2556 DW |
1059 | addNavigation(options, state, originalSelect); |
1060 | ||
273b2556 | 1061 | var suggestionsElement = $(document.getElementById(state.suggestionsId)); |
60a1ea56 DW |
1062 | // Hide the suggestions by default. |
1063 | suggestionsElement.hide().attr('aria-hidden', true); | |
1064 | ||
e994dea0 AN |
1065 | return; |
1066 | }) | |
1067 | .then(function() { | |
60a1ea56 | 1068 | // Show the current values in the selection list. |
e994dea0 AN |
1069 | return updateSelectionList(options, state, originalSelect); |
1070 | }) | |
1071 | .then(function() { | |
1072 | return M.util.js_complete(pendingKey); | |
1073 | }) | |
1074 | .catch(function(error) { | |
78162bf5 AN |
1075 | M.util.js_complete(pendingKey); |
1076 | notification.exception(error); | |
60a1ea56 DW |
1077 | }); |
1078 | } | |
1079 | }; | |
1080 | }); |