lib/amd/src/form-autocomplete.js

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