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