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