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