1 // This file is part of Moodle - http://moodle.org/
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.
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.
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/>.
17 * A type of dialogue used as for choosing options.
19 * @module core_course/local/chooser/dialogue
21 * @copyright 2019 Mihail Geshoski <mihail@moodle.com>
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
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';
34 const getPlugin = pluginName => import(pluginName);
37 * Given an event from the main module 'page' navigate to it's help section via a carousel.
39 * @method showModuleHelp
40 * @param {jQuery} carousel Our initialized carousel to manipulate
41 * @param {Object} moduleData Data of the module to carousel to
42 * @param {jQuery} modal We need to figure out if the current modal has a footer.
44 const showModuleHelp = (carousel, moduleData, modal = null) => {
45 // If we have a real footer then we need to change temporarily.
46 if (modal !== null && moduleData.showFooter === true) {
47 modal.setFooter(Templates.render('core_course/local/activitychooser/footer_partial', moduleData));
49 const help = carousel.find(selectors.regions.help)[0];
51 help.classList.add('m-auto');
54 const spinnerPromise = addIconToContainer(help);
57 let transitionPromiseResolver = null;
58 const transitionPromise = new Promise(resolve => {
59 transitionPromiseResolver = resolve;
62 // Build up the html & js ready to place into the help section.
63 const contentPromise = Templates.renderForPromise('core_course/local/activitychooser/help', moduleData);
65 // Wait for the content to be ready, and for the transition to be complet.
66 Promise.all([contentPromise, spinnerPromise, transitionPromise])
67 .then(([{html, js}]) => Templates.replaceNodeContents(help, html, js))
69 help.querySelector(selectors.regions.chooserSummary.header).focus();
72 .catch(Notification.exception);
74 // Move to the next slide, and resolve the transition promise when it's done.
75 carousel.one('slid.bs.carousel', () => {
76 transitionPromiseResolver();
78 // Trigger the transition between 'pages'.
79 carousel.carousel('next');
83 * Given a user wants to change the favourite state of a module we either add or remove the status.
84 * We also propergate this change across our map of modals.
86 * @method manageFavouriteState
87 * @param {HTMLElement} modalBody The DOM node of the modal to manipulate
88 * @param {HTMLElement} caller
89 * @param {Function} partialFavourite Partially applied function we need to manage favourite status
91 const manageFavouriteState = async(modalBody, caller, partialFavourite) => {
92 const isFavourite = caller.dataset.favourited;
93 const id = caller.dataset.id;
94 const name = caller.dataset.name;
95 const internal = caller.dataset.internal;
96 // Switch on fave or not.
97 if (isFavourite === 'true') {
98 await Repository.unfavouriteModule(name, id);
100 partialFavourite(internal, false, modalBody);
102 await Repository.favouriteModule(name, id);
104 partialFavourite(internal, true, modalBody);
110 * Register chooser related event listeners.
112 * @method registerListenerEvents
113 * @param {Promise} modal Our modal that we are working with
114 * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
115 * @param {Function} partialFavourite Partially applied function we need to manage favourite status
116 * @param {Object} footerData Our base footer object.
118 const registerListenerEvents = (modal, mappedModules, partialFavourite, footerData) => {
119 const bodyClickListener = async(e) => {
120 if (e.target.closest(selectors.actions.optionActions.showSummary)) {
121 const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));
123 const module = e.target.closest(selectors.regions.chooserOption.container);
124 const moduleName = module.dataset.modname;
125 const moduleData = mappedModules.get(moduleName);
126 // We need to know if the overall modal has a footer so we know when to show a real / vs fake footer.
127 moduleData.showFooter = modal.hasFooterContent();
128 showModuleHelp(carousel, moduleData, modal);
131 if (e.target.closest(selectors.actions.optionActions.manageFavourite)) {
132 const caller = e.target.closest(selectors.actions.optionActions.manageFavourite);
133 await manageFavouriteState(modal.getBody()[0], caller, partialFavourite);
134 const activeSectionId = modal.getBody()[0].querySelector(selectors.elements.activetab).getAttribute("href");
135 const sectionChooserOptions = modal.getBody()[0]
136 .querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));
137 const firstChooserOption = sectionChooserOptions
138 .querySelector(selectors.regions.chooserOption.container);
139 toggleFocusableChooserOption(firstChooserOption, true);
140 initChooserOptionsKeyboardNavigation(modal.getBody()[0], mappedModules, sectionChooserOptions, modal);
143 // From the help screen go back to the module overview.
144 if (e.target.matches(selectors.actions.closeOption)) {
145 const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));
147 // Trigger the transition between 'pages'.
148 carousel.carousel('prev');
149 carousel.on('slid.bs.carousel', () => {
150 const allModules = modal.getBody()[0].querySelector(selectors.regions.modules);
151 const caller = allModules.querySelector(selectors.regions.getModuleSelector(e.target.dataset.modname));
156 // The "clear search" button is triggered.
157 if (e.target.closest(selectors.actions.clearSearch)) {
158 // Clear the entered search query in the search bar and hide the search results container.
159 const searchInput = modal.getBody()[0].querySelector(selectors.actions.search);
160 searchInput.value = "";
162 toggleSearchResultsView(modal, mappedModules, searchInput.value);
166 // We essentially have two types of footer.
167 // A fake one that is handled within the template for chooser_help and then all of the stuff for
168 // modal.footer. We need to ensure we know exactly what type of footer we are using so we know what we
169 // need to manage. The below code handles a real footer going to a mnet carousel item.
170 const footerClickListener = async(e) => {
171 if (footerData.footer === true) {
172 const footerjs = await getPlugin(footerData.customfooterjs);
173 await footerjs.footerClickListener(e, footerData, modal);
177 modal.getBodyPromise()
179 // The return value of getBodyPromise is a jquery object containing the body NodeElement.
180 .then(body => body[0])
182 // Set up the carousel.
184 $(body.querySelector(selectors.regions.carousel))
194 // Add the listener for clicks on the body.
196 body.addEventListener('click', bodyClickListener);
200 // Add a listener for an input change in the activity chooser's search bar.
202 const searchInput = body.querySelector(selectors.actions.search);
203 // The search input is triggered.
204 searchInput.addEventListener('input', debounce(() => {
205 // Display the search results.
206 toggleSearchResultsView(modal, mappedModules, searchInput.value);
211 // Register event listeners related to the keyboard navigation controls.
213 // Get the active chooser options section.
214 const activeSectionId = body.querySelector(selectors.elements.activetab).getAttribute("href");
215 const sectionChooserOptions = body.querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));
216 const firstChooserOption = sectionChooserOptions.querySelector(selectors.regions.chooserOption.container);
218 toggleFocusableChooserOption(firstChooserOption, true);
219 initTabsKeyboardNavigation(body);
220 initChooserOptionsKeyboardNavigation(body, mappedModules, sectionChooserOptions, modal);
226 modal.getFooterPromise()
228 // The return value of getBodyPromise is a jquery object containing the body NodeElement.
229 .then(footer => footer[0])
230 // Add the listener for clicks on the footer.
232 footer.addEventListener('click', footerClickListener);
239 * Initialise the keyboard navigation controls for the tab list items.
241 * @method initTabsKeyboardNavigation
242 * @param {HTMLElement} body Our modal that we are working with
244 const initTabsKeyboardNavigation = (body) => {
245 // Set up the tab handlers.
246 const favTabNav = body.querySelector(selectors.regions.favouriteTabNav);
247 const recommendedTabNav = body.querySelector(selectors.regions.recommendedTabNav);
248 const defaultTabNav = body.querySelector(selectors.regions.defaultTabNav);
249 const activityTabNav = body.querySelector(selectors.regions.activityTabNav);
250 const resourceTabNav = body.querySelector(selectors.regions.resourceTabNav);
251 const tabNavArray = [favTabNav, recommendedTabNav, defaultTabNav, activityTabNav, resourceTabNav];
252 tabNavArray.forEach((element) => {
253 return element.addEventListener('keydown', (e) => {
254 // The first visible navigation tab link.
255 const firstLink = e.target.parentElement.querySelector(selectors.elements.visibletabs);
256 // The last navigation tab link. It would always be the default activities tab link.
257 const lastLink = e.target.parentElement.lastElementChild;
259 if (e.keyCode === arrowRight) {
260 const nextLink = e.target.nextElementSibling;
261 if (nextLink === null) {
262 e.target.tabIndex = -1;
263 firstLink.tabIndex = 0;
265 } else if (nextLink.classList.contains('d-none')) {
266 e.target.tabIndex = -1;
267 lastLink.tabIndex = 0;
270 e.target.tabIndex = -1;
271 nextLink.tabIndex = 0;
275 if (e.keyCode === arrowLeft) {
276 const previousLink = e.target.previousElementSibling;
277 if (previousLink === null) {
278 e.target.tabIndex = -1;
279 lastLink.tabIndex = 0;
281 } else if (previousLink.classList.contains('d-none')) {
282 e.target.tabIndex = -1;
283 firstLink.tabIndex = 0;
286 e.target.tabIndex = -1;
287 previousLink.tabIndex = 0;
288 previousLink.focus();
291 if (e.keyCode === home) {
292 e.target.tabIndex = -1;
293 firstLink.tabIndex = 0;
296 if (e.keyCode === end) {
297 e.target.tabIndex = -1;
298 lastLink.tabIndex = 0;
301 if (e.keyCode === space) {
310 * Initialise the keyboard navigation controls for the chooser options.
312 * @method initChooserOptionsKeyboardNavigation
313 * @param {HTMLElement} body Our modal that we are working with
314 * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
315 * @param {HTMLElement} chooserOptionsContainer The section that contains the chooser items
316 * @param {Object} modal Our created modal for the section
318 const initChooserOptionsKeyboardNavigation = (body, mappedModules, chooserOptionsContainer, modal = null) => {
319 const chooserOptions = chooserOptionsContainer.querySelectorAll(selectors.regions.chooserOption.container);
321 Array.from(chooserOptions).forEach((element) => {
322 return element.addEventListener('keydown', (e) => {
324 // Check for enter/ space triggers for showing the help.
325 if (e.keyCode === enter || e.keyCode === space) {
326 if (e.target.matches(selectors.actions.optionActions.showSummary)) {
328 const module = e.target.closest(selectors.regions.chooserOption.container);
329 const moduleName = module.dataset.modname;
330 const moduleData = mappedModules.get(moduleName);
331 const carousel = $(body.querySelector(selectors.regions.carousel));
338 // We need to know if the overall modal has a footer so we know when to show a real / vs fake footer.
339 moduleData.showFooter = modal.hasFooterContent();
340 showModuleHelp(carousel, moduleData, modal);
345 if (e.keyCode === arrowRight) {
347 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
348 const nextOption = currentOption.nextElementSibling;
349 const firstOption = chooserOptionsContainer.firstElementChild;
350 const toFocusOption = clickErrorHandler(nextOption, firstOption);
351 focusChooserOption(toFocusOption, currentOption);
355 if (e.keyCode === arrowLeft) {
357 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
358 const previousOption = currentOption.previousElementSibling;
359 const lastOption = chooserOptionsContainer.lastElementChild;
360 const toFocusOption = clickErrorHandler(previousOption, lastOption);
361 focusChooserOption(toFocusOption, currentOption);
364 if (e.keyCode === home) {
366 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
367 const firstOption = chooserOptionsContainer.firstElementChild;
368 focusChooserOption(firstOption, currentOption);
371 if (e.keyCode === end) {
373 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
374 const lastOption = chooserOptionsContainer.lastElementChild;
375 focusChooserOption(lastOption, currentOption);
382 * Focus on a chooser option element and remove the previous chooser element from the focus order
384 * @method focusChooserOption
385 * @param {HTMLElement} currentChooserOption The current chooser option element that we want to focus
386 * @param {HTMLElement|null} previousChooserOption The previous focused option element
388 const focusChooserOption = (currentChooserOption, previousChooserOption = null) => {
389 if (previousChooserOption !== null) {
390 toggleFocusableChooserOption(previousChooserOption, false);
393 toggleFocusableChooserOption(currentChooserOption, true);
394 currentChooserOption.focus();
398 * Add or remove a chooser option from the focus order.
400 * @method toggleFocusableChooserOption
401 * @param {HTMLElement} chooserOption The chooser option element which should be added or removed from the focus order
402 * @param {Boolean} isFocusable Whether the chooser element is focusable or not
404 const toggleFocusableChooserOption = (chooserOption, isFocusable) => {
405 const chooserOptionLink = chooserOption.querySelector(selectors.actions.addChooser);
406 const chooserOptionHelp = chooserOption.querySelector(selectors.actions.optionActions.showSummary);
407 const chooserOptionFavourite = chooserOption.querySelector(selectors.actions.optionActions.manageFavourite);
410 // Set tabindex to 0 to add current chooser option element to the focus order.
411 chooserOption.tabIndex = 0;
412 chooserOptionLink.tabIndex = 0;
413 chooserOptionHelp.tabIndex = 0;
414 chooserOptionFavourite.tabIndex = 0;
416 // Set tabindex to -1 to remove the previous chooser option element from the focus order.
417 chooserOption.tabIndex = -1;
418 chooserOptionLink.tabIndex = -1;
419 chooserOptionHelp.tabIndex = -1;
420 chooserOptionFavourite.tabIndex = -1;
425 * Small error handling function to make sure the navigated to object exists
427 * @method clickErrorHandler
428 * @param {HTMLElement} item What we want to check exists
429 * @param {HTMLElement} fallback If we dont match anything fallback the focus
430 * @return {HTMLElement}
432 const clickErrorHandler = (item, fallback) => {
441 * Render the search results in a defined container
443 * @method renderSearchResults
444 * @param {HTMLElement} searchResultsContainer The container where the data should be rendered
445 * @param {Object} searchResultsData Data containing the module items that satisfy the search criteria
447 const renderSearchResults = async(searchResultsContainer, searchResultsData) => {
448 const templateData = {
449 'searchresultsnumber': searchResultsData.length,
450 'searchresults': searchResultsData
452 // Build up the html & js ready to place into the help section.
453 const {html, js} = await Templates.renderForPromise('core_course/local/activitychooser/search_results', templateData);
454 await Templates.replaceNodeContents(searchResultsContainer, html, js);
458 * Toggle (display/hide) the search results depending on the value of the search query
460 * @method toggleSearchResultsView
461 * @param {Object} modal Our created modal for the section
462 * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
463 * @param {String} searchQuery The search query
465 const toggleSearchResultsView = async(modal, mappedModules, searchQuery) => {
466 const modalBody = modal.getBody()[0];
467 const searchResultsContainer = modalBody.querySelector(selectors.regions.searchResults);
468 const chooserContainer = modalBody.querySelector(selectors.regions.chooser);
469 const clearSearchButton = modalBody.querySelector(selectors.actions.clearSearch);
471 if (searchQuery.length > 0) { // Search query is present.
472 const searchResultsData = searchModules(mappedModules, searchQuery);
473 await renderSearchResults(searchResultsContainer, searchResultsData);
474 const searchResultItemsContainer = searchResultsContainer.querySelector(selectors.regions.searchResultItems);
475 const firstSearchResultItem = searchResultItemsContainer.querySelector(selectors.regions.chooserOption.container);
476 if (firstSearchResultItem) {
477 // Set the first result item to be focusable.
478 toggleFocusableChooserOption(firstSearchResultItem, true);
479 // Register keyboard events on the created search result items.
480 initChooserOptionsKeyboardNavigation(modalBody, mappedModules, searchResultItemsContainer, modal);
482 // Display the "clear" search button in the activity chooser search bar.
483 clearSearchButton.classList.remove('d-none');
484 // Hide the default chooser options container.
485 chooserContainer.setAttribute('hidden', 'hidden');
486 // Display the search results container.
487 searchResultsContainer.removeAttribute('hidden');
488 } else { // Search query is not present.
489 // Hide the "clear" search button in the activity chooser search bar.
490 clearSearchButton.classList.add('d-none');
491 // Hide the search results container.
492 searchResultsContainer.setAttribute('hidden', 'hidden');
493 // Display the default chooser options container.
494 chooserContainer.removeAttribute('hidden');
499 * Return the list of modules which have a name or description that matches the given search term.
501 * @method searchModules
502 * @param {Array} modules List of available modules
503 * @param {String} searchTerm The search term to match
506 const searchModules = (modules, searchTerm) => {
507 if (searchTerm === '') {
510 searchTerm = searchTerm.toLowerCase();
511 const searchResults = [];
512 modules.forEach((activity) => {
513 const activityName = activity.title.toLowerCase();
514 const activityDesc = activity.help.toLowerCase();
515 if (activityName.includes(searchTerm) || activityDesc.includes(searchTerm)) {
516 searchResults.push(activity);
520 return searchResults;
524 * Set up our tabindex information across the chooser.
526 * @method setupKeyboardAccessibility
527 * @param {Promise} modal Our created modal for the section
528 * @param {Map} mappedModules A map of all of the built module information
530 const setupKeyboardAccessibility = (modal, mappedModules) => {
531 modal.getModal()[0].tabIndex = -1;
533 modal.getBodyPromise().then(body => {
534 $(selectors.elements.tab).on('shown.bs.tab', (e) => {
535 const activeSectionId = e.target.getAttribute("href");
536 const activeSectionChooserOptions = body[0]
537 .querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));
538 const firstChooserOption = activeSectionChooserOptions
539 .querySelector(selectors.regions.chooserOption.container);
540 const prevActiveSectionId = e.relatedTarget.getAttribute("href");
541 const prevActiveSectionChooserOptions = body[0]
542 .querySelector(selectors.regions.getSectionChooserOptions(prevActiveSectionId));
544 // Disable the focus of every chooser option in the previous active section.
545 disableFocusAllChooserOptions(prevActiveSectionChooserOptions);
546 // Enable the focus of the first chooser option in the current active section.
547 toggleFocusableChooserOption(firstChooserOption, true);
548 initChooserOptionsKeyboardNavigation(body[0], mappedModules, activeSectionChooserOptions, modal);
551 }).catch(Notification.exception);
555 * Disable the focus of all chooser options in a specific container (section).
557 * @method disableFocusAllChooserOptions
558 * @param {HTMLElement} sectionChooserOptions The section that contains the chooser items
560 const disableFocusAllChooserOptions = (sectionChooserOptions) => {
561 const allChooserOptions = sectionChooserOptions.querySelectorAll(selectors.regions.chooserOption.container);
562 allChooserOptions.forEach((chooserOption) => {
563 toggleFocusableChooserOption(chooserOption, false);
568 * Display the module chooser.
570 * @method displayChooser
571 * @param {Promise} modalPromise Our created modal for the section
572 * @param {Array} sectionModules An array of all of the built module information
573 * @param {Function} partialFavourite Partially applied function we need to manage favourite status
574 * @param {Object} footerData Our base footer object.
576 export const displayChooser = (modalPromise, sectionModules, partialFavourite, footerData) => {
577 // Make a map so we can quickly fetch a specific module's object for either rendering or searching.
578 const mappedModules = new Map();
579 sectionModules.forEach((module) => {
580 mappedModules.set(module.componentname + '_' + module.link, module);
583 // Register event listeners.
584 modalPromise.then(modal => {
585 registerListenerEvents(modal, mappedModules, partialFavourite, footerData);
587 // We want to focus on the first chooser option element as soon as the modal is opened.
588 setupKeyboardAccessibility(modal, mappedModules);
590 // We want to focus on the action select when the dialog is closed.
591 modal.getRoot().on(ModalEvents.hidden, () => {