3cbc5e865836c20a922d6d966158245fd8d9f5f5
[moodle.git] / course / amd / src / local / activitychooser / dialogue.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/>.
16 /**
17  * A type of dialogue used as for choosing options.
18  *
19  * @module     core_course/local/chooser/dialogue
20  * @package    core
21  * @copyright  2019 Mihail Geshoski <mihail@moodle.com>
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 import $ from 'jquery';
26 import * as ModalEvents from 'core/modal_events';
27 import selectors from 'core_course/local/activitychooser/selectors';
28 import * as Templates from 'core/templates';
29 import {end, arrowLeft, arrowRight, home, enter, space} from 'core/key_codes';
30 import {addIconToContainer} from 'core/loadingicon';
31 import * as Repository from 'core_course/local/activitychooser/repository';
32 import Notification from 'core/notification';
33 import {debounce} from 'core/utils';
35 /**
36  * Given an event from the main module 'page' navigate to it's help section via a carousel.
37  *
38  * @method showModuleHelp
39  * @param {jQuery} carousel Our initialized carousel to manipulate
40  * @param {Object} moduleData Data of the module to carousel to
41  */
42 const showModuleHelp = (carousel, moduleData) => {
43     const help = carousel.find(selectors.regions.help)[0];
44     help.innerHTML = '';
45     help.classList.add('m-auto');
47     // Add a spinner.
48     const spinnerPromise = addIconToContainer(help);
50     // Used later...
51     let transitionPromiseResolver = null;
52     const transitionPromise = new Promise(resolve => {
53         transitionPromiseResolver = resolve;
54     });
56     // Build up the html & js ready to place into the help section.
57     const contentPromise = Templates.renderForPromise('core_course/local/activitychooser/help', moduleData);
59     // Wait for the content to be ready, and for the transition to be complet.
60     Promise.all([contentPromise, spinnerPromise, transitionPromise])
61         .then(([{html, js}]) => Templates.replaceNodeContents(help, html, js))
62         .then(() => {
63             help.querySelector(selectors.regions.chooserSummary.header).focus();
64             return help;
65         })
66         .catch(Notification.exception);
68     // Move to the next slide, and resolve the transition promise when it's done.
69     carousel.one('slid.bs.carousel', () => {
70         transitionPromiseResolver();
71     });
72     // Trigger the transition between 'pages'.
73     carousel.carousel('next');
74 };
76 /**
77  * Given a user wants to change the favourite state of a module we either add or remove the status.
78  * We also propergate this change across our map of modals.
79  *
80  * @method manageFavouriteState
81  * @param {HTMLElement} modalBody The DOM node of the modal to manipulate
82  * @param {HTMLElement} caller
83  * @param {Function} partialFavourite Partially applied function we need to manage favourite status
84  */
85 const manageFavouriteState = async(modalBody, caller, partialFavourite) => {
86     const isFavourite = caller.dataset.favourited;
87     const id = caller.dataset.id;
88     const name = caller.dataset.name;
89     const internal = caller.dataset.internal;
90     // Switch on fave or not.
91     if (isFavourite === 'true') {
92         await Repository.unfavouriteModule(name, id);
94         partialFavourite(internal, false, modalBody);
95     } else {
96         await Repository.favouriteModule(name, id);
98         partialFavourite(internal, true, modalBody);
99     }
101 };
103 /**
104  * Register chooser related event listeners.
105  *
106  * @method registerListenerEvents
107  * @param {Promise} modal Our modal that we are working with
108  * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
109  * @param {Function} partialFavourite Partially applied function we need to manage favourite status
110  */
111 const registerListenerEvents = (modal, mappedModules, partialFavourite) => {
112     const bodyClickListener = async(e) => {
113         if (e.target.closest(selectors.actions.optionActions.showSummary)) {
114             const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));
116             const module = e.target.closest(selectors.regions.chooserOption.container);
117             const moduleName = module.dataset.modname;
118             const moduleData = mappedModules.get(moduleName);
119             showModuleHelp(carousel, moduleData);
120         }
122         if (e.target.closest(selectors.actions.optionActions.manageFavourite)) {
123             const caller = e.target.closest(selectors.actions.optionActions.manageFavourite);
124             await manageFavouriteState(modal.getBody()[0], caller, partialFavourite);
125             const activeSectionId = modal.getBody()[0].querySelector(selectors.elements.activetab).getAttribute("href");
126             const sectionChooserOptions = modal.getBody()[0]
127                 .querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));
128             const firstChooserOption = sectionChooserOptions
129                 .querySelector(selectors.regions.chooserOption.container);
130             toggleFocusableChooserOption(firstChooserOption, true);
131             initChooserOptionsKeyboardNavigation(modal.getBody()[0], mappedModules, sectionChooserOptions);
132         }
134         // From the help screen go back to the module overview.
135         if (e.target.matches(selectors.actions.closeOption)) {
136             const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));
138             // Trigger the transition between 'pages'.
139             carousel.carousel('prev');
140             carousel.on('slid.bs.carousel', () => {
141                 const allModules = modal.getBody()[0].querySelector(selectors.regions.modules);
142                 const caller = allModules.querySelector(selectors.regions.getModuleSelector(e.target.dataset.modname));
143                 caller.focus();
144             });
145         }
147         // The "clear search" button is triggered.
148         if (e.target.closest(selectors.actions.clearSearch)) {
149             // Clear the entered search query in the search bar and hide the search results container.
150             const searchInput = modal.getBody()[0].querySelector(selectors.actions.search);
151             searchInput.value = "";
152             searchInput.focus();
153             toggleSearchResultsView(modal.getBody()[0], mappedModules, searchInput.value);
154         }
155     };
157     modal.getBodyPromise()
159     // The return value of getBodyPromise is a jquery object containing the body NodeElement.
160     .then(body => body[0])
162     // Set up the carousel.
163     .then(body => {
164         $(body.querySelector(selectors.regions.carousel))
165             .carousel({
166                 interval: false,
167                 pause: true,
168                 keyboard: false
169             });
171         return body;
172     })
174     // Add the listener for clicks on the body.
175     .then(body => {
176         body.addEventListener('click', bodyClickListener);
177         return body;
178     })
180     // Add a listener for an input change in the activity chooser's search bar.
181     .then(body => {
182         const searchInput = body.querySelector(selectors.actions.search);
183         // The search input is triggered.
184         searchInput.addEventListener('input', debounce(() => {
185             // Display the search results.
186             toggleSearchResultsView(body, mappedModules, searchInput.value);
187         }, 300));
188         return body;
189     })
191     // Register event listeners related to the keyboard navigation controls.
192     .then(body => {
193         // Get the active chooser options section.
194         const activeSectionId = body.querySelector(selectors.elements.activetab).getAttribute("href");
195         const sectionChooserOptions = body.querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));
196         const firstChooserOption = sectionChooserOptions.querySelector(selectors.regions.chooserOption.container);
198         toggleFocusableChooserOption(firstChooserOption, true);
199         initTabsKeyboardNavigation(body);
200         initChooserOptionsKeyboardNavigation(body, mappedModules, sectionChooserOptions);
202         return body;
203     })
204     .catch();
206 };
208 /**
209  * Initialise the keyboard navigation controls for the tab list items.
210  *
211  * @method initTabsKeyboardNavigation
212  * @param {HTMLElement} body Our modal that we are working with
213  */
214 const initTabsKeyboardNavigation = (body) => {
215     // Set up the tab handlers.
216     const favTabNav = body.querySelector(selectors.regions.favouriteTabNav);
217     const recommendedTabNav = body.querySelector(selectors.regions.recommendedTabNav);
218     const defaultTabNav = body.querySelector(selectors.regions.defaultTabNav);
219     const activityTabNav = body.querySelector(selectors.regions.activityTabNav);
220     const resourceTabNav = body.querySelector(selectors.regions.resourceTabNav);
221     const tabNavArray = [favTabNav, recommendedTabNav, defaultTabNav, activityTabNav, resourceTabNav];
222     tabNavArray.forEach((element) => {
223         return element.addEventListener('keydown', (e) => {
224             // The first visible navigation tab link.
225             const firstLink = e.target.parentElement.querySelector(selectors.elements.visibletabs);
226             // The last navigation tab link. It would always be the default activities tab link.
227             const lastLink = e.target.parentElement.lastElementChild;
229             if (e.keyCode === arrowRight) {
230                 const nextLink = e.target.nextElementSibling;
231                 if (nextLink === null) {
232                     e.target.tabIndex = -1;
233                     firstLink.tabIndex = 0;
234                     firstLink.focus();
235                 } else if (nextLink.classList.contains('d-none')) {
236                     e.target.tabIndex = -1;
237                     lastLink.tabIndex = 0;
238                     lastLink.focus();
239                 } else {
240                     e.target.tabIndex = -1;
241                     nextLink.tabIndex = 0;
242                     nextLink.focus();
243                 }
244             }
245             if (e.keyCode === arrowLeft) {
246                 const previousLink = e.target.previousElementSibling;
247                 if (previousLink === null) {
248                     e.target.tabIndex = -1;
249                     lastLink.tabIndex = 0;
250                     lastLink.focus();
251                 } else if (previousLink.classList.contains('d-none')) {
252                     e.target.tabIndex = -1;
253                     firstLink.tabIndex = 0;
254                     firstLink.focus();
255                 } else {
256                     e.target.tabIndex = -1;
257                     previousLink.tabIndex = 0;
258                     previousLink.focus();
259                 }
260             }
261             if (e.keyCode === home) {
262                 e.target.tabIndex = -1;
263                 firstLink.tabIndex = 0;
264                 firstLink.focus();
265             }
266             if (e.keyCode === end) {
267                 e.target.tabIndex = -1;
268                 lastLink.tabIndex = 0;
269                 lastLink.focus();
270             }
271             if (e.keyCode === space) {
272                 e.preventDefault();
273                 e.target.click();
274             }
275         });
276     });
277 };
279 /**
280  * Initialise the keyboard navigation controls for the chooser options.
281  *
282  * @method initChooserOptionsKeyboardNavigation
283  * @param {HTMLElement} body Our modal that we are working with
284  * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
285  * @param {HTMLElement} chooserOptionsContainer The section that contains the chooser items
286  */
287 const initChooserOptionsKeyboardNavigation = (body, mappedModules, chooserOptionsContainer) => {
288     const chooserOptions = chooserOptionsContainer.querySelectorAll(selectors.regions.chooserOption.container);
290     Array.from(chooserOptions).forEach((element) => {
291         return element.addEventListener('keydown', (e) => {
293             // Check for enter/ space triggers for showing the help.
294             if (e.keyCode === enter || e.keyCode === space) {
295                 if (e.target.matches(selectors.actions.optionActions.showSummary)) {
296                     e.preventDefault();
297                     const module = e.target.closest(selectors.regions.chooserOption.container);
298                     const moduleName = module.dataset.modname;
299                     const moduleData = mappedModules.get(moduleName);
300                     const carousel = $(body.querySelector(selectors.regions.carousel));
301                     carousel.carousel({
302                         interval: false,
303                         pause: true,
304                         keyboard: false
305                     });
306                     showModuleHelp(carousel, moduleData);
307                 }
308             }
310             // Next.
311             if (e.keyCode === arrowRight) {
312                 e.preventDefault();
313                 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
314                 const nextOption = currentOption.nextElementSibling;
315                 const firstOption = chooserOptionsContainer.firstElementChild;
316                 const toFocusOption = clickErrorHandler(nextOption, firstOption);
317                 focusChooserOption(toFocusOption, currentOption);
318             }
320             // Previous.
321             if (e.keyCode === arrowLeft) {
322                 e.preventDefault();
323                 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
324                 const previousOption = currentOption.previousElementSibling;
325                 const lastOption = chooserOptionsContainer.lastElementChild;
326                 const toFocusOption = clickErrorHandler(previousOption, lastOption);
327                 focusChooserOption(toFocusOption, currentOption);
328             }
330             if (e.keyCode === home) {
331                 e.preventDefault();
332                 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
333                 const firstOption = chooserOptionsContainer.firstElementChild;
334                 focusChooserOption(firstOption, currentOption);
335             }
337             if (e.keyCode === end) {
338                 e.preventDefault();
339                 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
340                 const lastOption = chooserOptionsContainer.lastElementChild;
341                 focusChooserOption(lastOption, currentOption);
342             }
343         });
344     });
345 };
347 /**
348  * Focus on a chooser option element and remove the previous chooser element from the focus order
349  *
350  * @method focusChooserOption
351  * @param {HTMLElement} currentChooserOption The current chooser option element that we want to focus
352  * @param {HTMLElement|null} previousChooserOption The previous focused option element
353  */
354 const focusChooserOption = (currentChooserOption, previousChooserOption = null) => {
355     if (previousChooserOption !== null) {
356         toggleFocusableChooserOption(previousChooserOption, false);
357     }
359     toggleFocusableChooserOption(currentChooserOption, true);
360     currentChooserOption.focus();
361 };
363 /**
364  * Add or remove a chooser option from the focus order.
365  *
366  * @method toggleFocusableChooserOption
367  * @param {HTMLElement} chooserOption The chooser option element which should be added or removed from the focus order
368  * @param {Boolean} isFocusable Whether the chooser element is focusable or not
369  */
370 const toggleFocusableChooserOption = (chooserOption, isFocusable) => {
371     const chooserOptionLink = chooserOption.querySelector(selectors.actions.addChooser);
372     const chooserOptionHelp = chooserOption.querySelector(selectors.actions.optionActions.showSummary);
373     const chooserOptionFavourite = chooserOption.querySelector(selectors.actions.optionActions.manageFavourite);
375     if (isFocusable) {
376         // Set tabindex to 0 to add current chooser option element to the focus order.
377         chooserOption.tabIndex = 0;
378         chooserOptionLink.tabIndex = 0;
379         chooserOptionHelp.tabIndex = 0;
380         chooserOptionFavourite.tabIndex = 0;
381     } else {
382         // Set tabindex to -1 to remove the previous chooser option element from the focus order.
383         chooserOption.tabIndex = -1;
384         chooserOptionLink.tabIndex = -1;
385         chooserOptionHelp.tabIndex = -1;
386         chooserOptionFavourite.tabIndex = -1;
387     }
388 };
390 /**
391  * Small error handling function to make sure the navigated to object exists
392  *
393  * @method clickErrorHandler
394  * @param {HTMLElement} item What we want to check exists
395  * @param {HTMLElement} fallback If we dont match anything fallback the focus
396  * @return {HTMLElement}
397  */
398 const clickErrorHandler = (item, fallback) => {
399     if (item !== null) {
400         return item;
401     } else {
402         return fallback;
403     }
404 };
406 /**
407  * Render the search results in a defined container
408  *
409  * @method renderSearchResults
410  * @param {HTMLElement} searchResultsContainer The container where the data should be rendered
411  * @param {Object} searchResultsData Data containing the module items that satisfy the search criteria
412  */
413 const renderSearchResults = async(searchResultsContainer, searchResultsData) => {
414     const templateData = {
415         'searchresultsnumber': searchResultsData.length,
416         'searchresults': searchResultsData
417     };
418     // Build up the html & js ready to place into the help section.
419     const {html, js} = await Templates.renderForPromise('core_course/local/activitychooser/search_results', templateData);
420     await Templates.replaceNodeContents(searchResultsContainer, html, js);
421 };
423 /**
424  * Toggle (display/hide) the search results depending on the value of the search query
425  *
426  * @method toggleSearchResultsView
427  * @param {HTMLElement} modalBody The body of the created modal for the section
428  * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
429  * @param {String} searchQuery The search query
430  */
431 const toggleSearchResultsView = async(modalBody, mappedModules, searchQuery) => {
432     const searchResultsContainer = modalBody.querySelector(selectors.regions.searchResults);
433     const chooserContainer = modalBody.querySelector(selectors.regions.chooser);
434     const clearSearchButton = modalBody.querySelector(selectors.elements.clearsearch);
435     const searchIcon = modalBody.querySelector(selectors.elements.searchicon);
437     if (searchQuery.length > 0) { // Search query is present.
438         const searchResultsData = searchModules(mappedModules, searchQuery);
439         await renderSearchResults(searchResultsContainer, searchResultsData);
440         const searchResultItemsContainer = searchResultsContainer.querySelector(selectors.regions.searchResultItems);
441         const firstSearchResultItem = searchResultItemsContainer.querySelector(selectors.regions.chooserOption.container);
442         if (firstSearchResultItem) {
443             // Set the first result item to be focusable.
444             toggleFocusableChooserOption(firstSearchResultItem, true);
445             // Register keyboard events on the created search result items.
446             initChooserOptionsKeyboardNavigation(modalBody, mappedModules, searchResultItemsContainer);
447         }
448         // Display the "clear" search button in the activity chooser search bar.
449         searchIcon.classList.add('d-none');
450         clearSearchButton.classList.remove('d-none');
451         // Hide the default chooser options container.
452         chooserContainer.setAttribute('hidden', 'hidden');
453         // Display the search results container.
454         searchResultsContainer.removeAttribute('hidden');
455     } else { // Search query is not present.
456         // Hide the "clear" search button in the activity chooser search bar.
457         clearSearchButton.classList.add('d-none');
458         searchIcon.classList.remove('d-none');
459         // Hide the search results container.
460         searchResultsContainer.setAttribute('hidden', 'hidden');
461         // Display the default chooser options container.
462         chooserContainer.removeAttribute('hidden');
463     }
464 };
466 /**
467  * Return the list of modules which have a name or description that matches the given search term.
468  *
469  * @method searchModules
470  * @param {Array} modules List of available modules
471  * @param {String} searchTerm The search term to match
472  * @return {Array}
473  */
474 const searchModules = (modules, searchTerm) => {
475     if (searchTerm === '') {
476         return modules;
477     }
478     searchTerm = searchTerm.toLowerCase();
479     const searchResults = [];
480     modules.forEach((activity) => {
481         const activityName = activity.title.toLowerCase();
482         const activityDesc = activity.help.toLowerCase();
483         if (activityName.includes(searchTerm) || activityDesc.includes(searchTerm)) {
484             searchResults.push(activity);
485         }
486     });
488     return searchResults;
489 };
491 /**
492  * Set up our tabindex information across the chooser.
493  *
494  * @method setupKeyboardAccessibility
495  * @param {Promise} modal Our created modal for the section
496  * @param {Map} mappedModules A map of all of the built module information
497  */
498 const setupKeyboardAccessibility = (modal, mappedModules) => {
499     modal.getModal()[0].tabIndex = -1;
501     modal.getBodyPromise().then(body => {
502         $(selectors.elements.tab).on('shown.bs.tab', (e) => {
503             const activeSectionId = e.target.getAttribute("href");
504             const activeSectionChooserOptions = body[0]
505                 .querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));
506             const firstChooserOption = activeSectionChooserOptions
507                 .querySelector(selectors.regions.chooserOption.container);
508             const prevActiveSectionId = e.relatedTarget.getAttribute("href");
509             const prevActiveSectionChooserOptions = body[0]
510                 .querySelector(selectors.regions.getSectionChooserOptions(prevActiveSectionId));
512             // Disable the focus of every chooser option in the previous active section.
513             disableFocusAllChooserOptions(prevActiveSectionChooserOptions);
514             // Enable the focus of the first chooser option in the current active section.
515             toggleFocusableChooserOption(firstChooserOption, true);
516             initChooserOptionsKeyboardNavigation(body[0], mappedModules, activeSectionChooserOptions);
517         });
518         return;
519     }).catch(Notification.exception);
520 };
522 /**
523  * Disable the focus of all chooser options in a specific container (section).
524  *
525  * @method disableFocusAllChooserOptions
526  * @param {HTMLElement} sectionChooserOptions The section that contains the chooser items
527  */
528 const disableFocusAllChooserOptions = (sectionChooserOptions) => {
529     const allChooserOptions = sectionChooserOptions.querySelectorAll(selectors.regions.chooserOption.container);
530     allChooserOptions.forEach((chooserOption) => {
531         toggleFocusableChooserOption(chooserOption, false);
532     });
533 };
535 /**
536  * Display the module chooser.
537  *
538  * @method displayChooser
539  * @param {Promise} modalPromise Our created modal for the section
540  * @param {Array} sectionModules An array of all of the built module information
541  * @param {Function} partialFavourite Partially applied function we need to manage favourite status
542  */
543 export const displayChooser = (modalPromise, sectionModules, partialFavourite) => {
544     // Make a map so we can quickly fetch a specific module's object for either rendering or searching.
545     const mappedModules = new Map();
546     sectionModules.forEach((module) => {
547         mappedModules.set(module.componentname + '_' + module.link, module);
548     });
550     // Register event listeners.
551     modalPromise.then(modal => {
552         registerListenerEvents(modal, mappedModules, partialFavourite);
554         // We want to focus on the first chooser option element as soon as the modal is opened.
555         setupKeyboardAccessibility(modal, mappedModules);
557         // We want to focus on the action select when the dialog is closed.
558         modal.getRoot().on(ModalEvents.hidden, () => {
559             modal.destroy();
560         });
562         return modal;
563     }).catch();
564 };