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