weekly 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 = '';
f2d033a2 45 help.classList.add('m-auto');
05b27f21
MM
46
47 // Add a spinner.
48 const spinnerPromise = addIconToContainer(help);
49
50 // Used later...
51 let transitionPromiseResolver = null;
52 const transitionPromise = new Promise(resolve => {
53 transitionPromiseResolver = resolve;
54 });
55
56 // Build up the html & js ready to place into the help section.
d2695ab2 57 const contentPromise = Templates.renderForPromise('core_course/local/activitychooser/help', moduleData);
05b27f21
MM
58
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(() => {
486abbcb 63 help.querySelector(selectors.regions.chooserSummary.header).focus();
05b27f21
MM
64 return help;
65 })
66 .catch(Notification.exception);
67
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};
75
6e1a4477
MM
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 */
85const 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);
93
94 partialFavourite(internal, false, modalBody);
95 } else {
96 await Repository.favouriteModule(name, id);
97
98 partialFavourite(internal, true, modalBody);
99 }
100
101};
102
05b27f21
MM
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}
6e1a4477 109 * @param {Function} partialFavourite Partially applied function we need to manage favourite status
05b27f21 110 */
6e1a4477 111const registerListenerEvents = (modal, mappedModules, partialFavourite) => {
9f1bfca2 112 const bodyClickListener = async(e) => {
05b27f21
MM
113 if (e.target.closest(selectors.actions.optionActions.showSummary)) {
114 const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));
115
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 }
121
6e1a4477
MM
122 if (e.target.closest(selectors.actions.optionActions.manageFavourite)) {
123 const caller = e.target.closest(selectors.actions.optionActions.manageFavourite);
9f1bfca2
MG
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);
6e1a4477
MM
132 }
133
05b27f21
MM
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));
137
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 }
f152b71d
MG
146
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 }
05b27f21
MM
155 };
156
157 modal.getBodyPromise()
158
159 // The return value of getBodyPromise is a jquery object containing the body NodeElement.
160 .then(body => body[0])
161
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 });
170
171 return body;
172 })
173
174 // Add the listener for clicks on the body.
175 .then(body => {
176 body.addEventListener('click', bodyClickListener);
177 return body;
178 })
179
f152b71d
MG
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 })
190
05b27f21
MM
191 // Register event listeners related to the keyboard navigation controls.
192 .then(body => {
f152b71d
MG
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);
e146a2ca 197
f152b71d
MG
198 toggleFocusableChooserOption(firstChooserOption, true);
199 initTabsKeyboardNavigation(body);
200 initChooserOptionsKeyboardNavigation(body, mappedModules, sectionChooserOptions);
e146a2ca 201
05b27f21
MM
202 return body;
203 })
204 .catch();
205
206};
207
208/**
f152b71d 209 * Initialise the keyboard navigation controls for the tab list items.
05b27f21 210 *
f152b71d 211 * @method initTabsKeyboardNavigation
c58c23d6 212 * @param {HTMLElement} body Our modal that we are working with
05b27f21 213 */
f152b71d 214const initTabsKeyboardNavigation = (body) => {
c58c23d6
MM
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);
e146a2ca
MM
219 const activityTabNav = body.querySelector(selectors.regions.activityTabNav);
220 const resourceTabNav = body.querySelector(selectors.regions.resourceTabNav);
221 const tabNavArray = [favTabNav, recommendedTabNav, defaultTabNav, activityTabNav, resourceTabNav];
c58c23d6 222 tabNavArray.forEach((element) => {
9f1bfca2
MG
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;
c58c23d6
MM
228
229 if (e.keyCode === arrowRight) {
9f1bfca2 230 const nextLink = e.target.nextElementSibling;
c58c23d6 231 if (nextLink === null) {
9f1bfca2 232 e.target.tabIndex = -1;
c58c23d6
MM
233 firstLink.tabIndex = 0;
234 firstLink.focus();
9f1bfca2
MG
235 } else if (nextLink.classList.contains('d-none')) {
236 e.target.tabIndex = -1;
c58c23d6
MM
237 lastLink.tabIndex = 0;
238 lastLink.focus();
239 } else {
9f1bfca2
MG
240 e.target.tabIndex = -1;
241 nextLink.tabIndex = 0;
242 nextLink.focus();
c58c23d6
MM
243 }
244 }
245 if (e.keyCode === arrowLeft) {
9f1bfca2 246 const previousLink = e.target.previousElementSibling;
c58c23d6 247 if (previousLink === null) {
9f1bfca2 248 e.target.tabIndex = -1;
c58c23d6
MM
249 lastLink.tabIndex = 0;
250 lastLink.focus();
9f1bfca2
MG
251 } else if (previousLink.classList.contains('d-none')) {
252 e.target.tabIndex = -1;
c58c23d6
MM
253 firstLink.tabIndex = 0;
254 firstLink.focus();
255 } else {
9f1bfca2
MG
256 e.target.tabIndex = -1;
257 previousLink.tabIndex = 0;
258 previousLink.focus();
c58c23d6
MM
259 }
260 }
261 if (e.keyCode === home) {
9f1bfca2 262 e.target.tabIndex = -1;
c58c23d6
MM
263 firstLink.tabIndex = 0;
264 firstLink.focus();
265 }
266 if (e.keyCode === end) {
9f1bfca2 267 e.target.tabIndex = -1;
c58c23d6
MM
268 lastLink.tabIndex = 0;
269 lastLink.focus();
270 }
271 if (e.keyCode === space) {
272 e.preventDefault();
273 e.target.click();
274 }
275 });
276 });
f152b71d 277};
c58c23d6 278
f152b71d
MG
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 */
287const initChooserOptionsKeyboardNavigation = (body, mappedModules, chooserOptionsContainer) => {
288 const chooserOptions = chooserOptionsContainer.querySelectorAll(selectors.regions.chooserOption.container);
05b27f21
MM
289
290 Array.from(chooserOptions).forEach((element) => {
9f1bfca2 291 return element.addEventListener('keydown', (e) => {
05b27f21
MM
292
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 }
309
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;
f152b71d 315 const firstOption = chooserOptionsContainer.firstElementChild;
05b27f21
MM
316 const toFocusOption = clickErrorHandler(nextOption, firstOption);
317 focusChooserOption(toFocusOption, currentOption);
318 }
319
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;
f152b71d 325 const lastOption = chooserOptionsContainer.lastElementChild;
05b27f21
MM
326 const toFocusOption = clickErrorHandler(previousOption, lastOption);
327 focusChooserOption(toFocusOption, currentOption);
328 }
329
330 if (e.keyCode === home) {
331 e.preventDefault();
332 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
f152b71d 333 const firstOption = chooserOptionsContainer.firstElementChild;
05b27f21
MM
334 focusChooserOption(firstOption, currentOption);
335 }
336
337 if (e.keyCode === end) {
338 e.preventDefault();
339 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
f152b71d 340 const lastOption = chooserOptionsContainer.lastElementChild;
05b27f21
MM
341 focusChooserOption(lastOption, currentOption);
342 }
343 });
344 });
345};
346
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
f152b71d 352 * @param {HTMLElement|null} previousChooserOption The previous focused option element
05b27f21 353 */
f152b71d
MG
354const focusChooserOption = (currentChooserOption, previousChooserOption = null) => {
355 if (previousChooserOption !== null) {
356 toggleFocusableChooserOption(previousChooserOption, false);
05b27f21
MM
357 }
358
f152b71d 359 toggleFocusableChooserOption(currentChooserOption, true);
05b27f21
MM
360 currentChooserOption.focus();
361};
362
f152b71d
MG
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 */
370const 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);
374
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};
389
05b27f21
MM
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
f152b71d 396 * @return {HTMLElement}
05b27f21
MM
397 */
398const clickErrorHandler = (item, fallback) => {
399 if (item !== null) {
400 return item;
401 } else {
402 return fallback;
403 }
404};
405
f152b71d
MG
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 */
413const 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.
d2695ab2 419 const {html, js} = await Templates.renderForPromise('core_course/local/activitychooser/search_results', templateData);
f152b71d
MG
420 await Templates.replaceNodeContents(searchResultsContainer, html, js);
421};
422
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 */
431const 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);
436
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};
465
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 */
474const 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 });
487
488 return searchResults;
489};
490
f2d033a2
MM
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 */
498const setupKeyboardAccessibility = (modal, mappedModules) => {
499 modal.getModal()[0].tabIndex = -1;
500
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));
511
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};
521
f152b71d
MG
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 */
528const disableFocusAllChooserOptions = (sectionChooserOptions) => {
529 const allChooserOptions = sectionChooserOptions.querySelectorAll(selectors.regions.chooserOption.container);
530 allChooserOptions.forEach((chooserOption) => {
531 toggleFocusableChooserOption(chooserOption, false);
532 });
533};
534
05b27f21
MM
535/**
536 * Display the module chooser.
537 *
538 * @method displayChooser
f2d033a2 539 * @param {Promise} modalPromise Our created modal for the section
05b27f21 540 * @param {Array} sectionModules An array of all of the built module information
6e1a4477 541 * @param {Function} partialFavourite Partially applied function we need to manage favourite status
05b27f21 542 */
f2d033a2 543export const displayChooser = (modalPromise, sectionModules, partialFavourite) => {
05b27f21
MM
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) => {
806e736a 547 mappedModules.set(module.componentname + '_' + module.link, module);
05b27f21
MM
548 });
549
550 // Register event listeners.
f2d033a2
MM
551 modalPromise.then(modal => {
552 registerListenerEvents(modal, mappedModules, partialFavourite);
05b27f21 553
f2d033a2
MM
554 // We want to focus on the first chooser option element as soon as the modal is opened.
555 setupKeyboardAccessibility(modal, mappedModules);
05b27f21 556
f2d033a2
MM
557 // We want to focus on the action select when the dialog is closed.
558 modal.getRoot().on(ModalEvents.hidden, () => {
559 modal.destroy();
560 });
05b27f21 561
f2d033a2 562 return modal;
e146a2ca 563 }).catch();
05b27f21 564};