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