MDL-69301 course: Remove custom keyboard navigation code
[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';
16d77f18 34const getPlugin = pluginName => import(pluginName);
05b27f21
MM
35
36/**
37 * Given an event from the main module 'page' navigate to it's help section via a carousel.
38 *
39 * @method showModuleHelp
40 * @param {jQuery} carousel Our initialized carousel to manipulate
41 * @param {Object} moduleData Data of the module to carousel to
16d77f18 42 * @param {jQuery} modal We need to figure out if the current modal has a footer.
05b27f21 43 */
16d77f18
MM
44const 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));
48 }
05b27f21
MM
49 const help = carousel.find(selectors.regions.help)[0];
50 help.innerHTML = '';
f2d033a2 51 help.classList.add('m-auto');
05b27f21
MM
52
53 // Add a spinner.
54 const spinnerPromise = addIconToContainer(help);
55
56 // Used later...
57 let transitionPromiseResolver = null;
58 const transitionPromise = new Promise(resolve => {
59 transitionPromiseResolver = resolve;
60 });
61
62 // Build up the html & js ready to place into the help section.
d2695ab2 63 const contentPromise = Templates.renderForPromise('core_course/local/activitychooser/help', moduleData);
05b27f21
MM
64
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))
68 .then(() => {
486abbcb 69 help.querySelector(selectors.regions.chooserSummary.header).focus();
05b27f21
MM
70 return help;
71 })
72 .catch(Notification.exception);
73
74 // Move to the next slide, and resolve the transition promise when it's done.
75 carousel.one('slid.bs.carousel', () => {
76 transitionPromiseResolver();
77 });
78 // Trigger the transition between 'pages'.
79 carousel.carousel('next');
80};
81
6e1a4477
MM
82/**
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.
85 *
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
90 */
91const 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);
99
100 partialFavourite(internal, false, modalBody);
101 } else {
102 await Repository.favouriteModule(name, id);
103
104 partialFavourite(internal, true, modalBody);
105 }
106
107};
108
05b27f21
MM
109/**
110 * Register chooser related event listeners.
111 *
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}
6e1a4477 115 * @param {Function} partialFavourite Partially applied function we need to manage favourite status
16d77f18 116 * @param {Object} footerData Our base footer object.
05b27f21 117 */
16d77f18 118const registerListenerEvents = (modal, mappedModules, partialFavourite, footerData) => {
9f1bfca2 119 const bodyClickListener = async(e) => {
05b27f21
MM
120 if (e.target.closest(selectors.actions.optionActions.showSummary)) {
121 const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));
122
123 const module = e.target.closest(selectors.regions.chooserOption.container);
124 const moduleName = module.dataset.modname;
125 const moduleData = mappedModules.get(moduleName);
16d77f18
MM
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);
05b27f21
MM
129 }
130
6e1a4477
MM
131 if (e.target.closest(selectors.actions.optionActions.manageFavourite)) {
132 const caller = e.target.closest(selectors.actions.optionActions.manageFavourite);
9f1bfca2
MG
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);
16d77f18 140 initChooserOptionsKeyboardNavigation(modal.getBody()[0], mappedModules, sectionChooserOptions, modal);
6e1a4477
MM
141 }
142
05b27f21
MM
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));
146
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));
152 caller.focus();
153 });
154 }
f152b71d
MG
155
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 = "";
161 searchInput.focus();
16d77f18
MM
162 toggleSearchResultsView(modal, mappedModules, searchInput.value);
163 }
164 };
165
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);
f152b71d 174 }
05b27f21
MM
175 };
176
177 modal.getBodyPromise()
178
179 // The return value of getBodyPromise is a jquery object containing the body NodeElement.
180 .then(body => body[0])
181
182 // Set up the carousel.
183 .then(body => {
184 $(body.querySelector(selectors.regions.carousel))
185 .carousel({
186 interval: false,
187 pause: true,
188 keyboard: false
189 });
190
191 return body;
192 })
193
194 // Add the listener for clicks on the body.
195 .then(body => {
196 body.addEventListener('click', bodyClickListener);
197 return body;
198 })
199
f152b71d
MG
200 // Add a listener for an input change in the activity chooser's search bar.
201 .then(body => {
202 const searchInput = body.querySelector(selectors.actions.search);
203 // The search input is triggered.
204 searchInput.addEventListener('input', debounce(() => {
205 // Display the search results.
16d77f18 206 toggleSearchResultsView(modal, mappedModules, searchInput.value);
f152b71d
MG
207 }, 300));
208 return body;
209 })
210
05b27f21
MM
211 // Register event listeners related to the keyboard navigation controls.
212 .then(body => {
f152b71d
MG
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);
e146a2ca 217
f152b71d 218 toggleFocusableChooserOption(firstChooserOption, true);
16d77f18 219 initChooserOptionsKeyboardNavigation(body, mappedModules, sectionChooserOptions, modal);
e146a2ca 220
05b27f21
MM
221 return body;
222 })
223 .catch();
224
16d77f18
MM
225 modal.getFooterPromise()
226
227 // The return value of getBodyPromise is a jquery object containing the body NodeElement.
228 .then(footer => footer[0])
229 // Add the listener for clicks on the footer.
230 .then(footer => {
231 footer.addEventListener('click', footerClickListener);
232 return footer;
233 })
234 .catch();
05b27f21
MM
235};
236
f152b71d
MG
237/**
238 * Initialise the keyboard navigation controls for the chooser options.
239 *
240 * @method initChooserOptionsKeyboardNavigation
241 * @param {HTMLElement} body Our modal that we are working with
242 * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
243 * @param {HTMLElement} chooserOptionsContainer The section that contains the chooser items
16d77f18 244 * @param {Object} modal Our created modal for the section
f152b71d 245 */
16d77f18 246const initChooserOptionsKeyboardNavigation = (body, mappedModules, chooserOptionsContainer, modal = null) => {
f152b71d 247 const chooserOptions = chooserOptionsContainer.querySelectorAll(selectors.regions.chooserOption.container);
05b27f21
MM
248
249 Array.from(chooserOptions).forEach((element) => {
9f1bfca2 250 return element.addEventListener('keydown', (e) => {
05b27f21
MM
251
252 // Check for enter/ space triggers for showing the help.
253 if (e.keyCode === enter || e.keyCode === space) {
254 if (e.target.matches(selectors.actions.optionActions.showSummary)) {
255 e.preventDefault();
256 const module = e.target.closest(selectors.regions.chooserOption.container);
257 const moduleName = module.dataset.modname;
258 const moduleData = mappedModules.get(moduleName);
259 const carousel = $(body.querySelector(selectors.regions.carousel));
260 carousel.carousel({
261 interval: false,
262 pause: true,
263 keyboard: false
264 });
16d77f18
MM
265
266 // We need to know if the overall modal has a footer so we know when to show a real / vs fake footer.
267 moduleData.showFooter = modal.hasFooterContent();
268 showModuleHelp(carousel, moduleData, modal);
05b27f21
MM
269 }
270 }
271
272 // Next.
273 if (e.keyCode === arrowRight) {
274 e.preventDefault();
275 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
276 const nextOption = currentOption.nextElementSibling;
f152b71d 277 const firstOption = chooserOptionsContainer.firstElementChild;
05b27f21
MM
278 const toFocusOption = clickErrorHandler(nextOption, firstOption);
279 focusChooserOption(toFocusOption, currentOption);
280 }
281
282 // Previous.
283 if (e.keyCode === arrowLeft) {
284 e.preventDefault();
285 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
286 const previousOption = currentOption.previousElementSibling;
f152b71d 287 const lastOption = chooserOptionsContainer.lastElementChild;
05b27f21
MM
288 const toFocusOption = clickErrorHandler(previousOption, lastOption);
289 focusChooserOption(toFocusOption, currentOption);
290 }
291
292 if (e.keyCode === home) {
293 e.preventDefault();
294 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
f152b71d 295 const firstOption = chooserOptionsContainer.firstElementChild;
05b27f21
MM
296 focusChooserOption(firstOption, currentOption);
297 }
298
299 if (e.keyCode === end) {
300 e.preventDefault();
301 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
f152b71d 302 const lastOption = chooserOptionsContainer.lastElementChild;
05b27f21
MM
303 focusChooserOption(lastOption, currentOption);
304 }
305 });
306 });
307};
308
309/**
310 * Focus on a chooser option element and remove the previous chooser element from the focus order
311 *
312 * @method focusChooserOption
313 * @param {HTMLElement} currentChooserOption The current chooser option element that we want to focus
f152b71d 314 * @param {HTMLElement|null} previousChooserOption The previous focused option element
05b27f21 315 */
f152b71d
MG
316const focusChooserOption = (currentChooserOption, previousChooserOption = null) => {
317 if (previousChooserOption !== null) {
318 toggleFocusableChooserOption(previousChooserOption, false);
05b27f21
MM
319 }
320
f152b71d 321 toggleFocusableChooserOption(currentChooserOption, true);
05b27f21
MM
322 currentChooserOption.focus();
323};
324
f152b71d
MG
325/**
326 * Add or remove a chooser option from the focus order.
327 *
328 * @method toggleFocusableChooserOption
329 * @param {HTMLElement} chooserOption The chooser option element which should be added or removed from the focus order
330 * @param {Boolean} isFocusable Whether the chooser element is focusable or not
331 */
332const toggleFocusableChooserOption = (chooserOption, isFocusable) => {
333 const chooserOptionLink = chooserOption.querySelector(selectors.actions.addChooser);
334 const chooserOptionHelp = chooserOption.querySelector(selectors.actions.optionActions.showSummary);
335 const chooserOptionFavourite = chooserOption.querySelector(selectors.actions.optionActions.manageFavourite);
336
337 if (isFocusable) {
338 // Set tabindex to 0 to add current chooser option element to the focus order.
339 chooserOption.tabIndex = 0;
340 chooserOptionLink.tabIndex = 0;
341 chooserOptionHelp.tabIndex = 0;
342 chooserOptionFavourite.tabIndex = 0;
343 } else {
344 // Set tabindex to -1 to remove the previous chooser option element from the focus order.
345 chooserOption.tabIndex = -1;
346 chooserOptionLink.tabIndex = -1;
347 chooserOptionHelp.tabIndex = -1;
348 chooserOptionFavourite.tabIndex = -1;
349 }
350};
351
05b27f21
MM
352/**
353 * Small error handling function to make sure the navigated to object exists
354 *
355 * @method clickErrorHandler
356 * @param {HTMLElement} item What we want to check exists
357 * @param {HTMLElement} fallback If we dont match anything fallback the focus
f152b71d 358 * @return {HTMLElement}
05b27f21
MM
359 */
360const clickErrorHandler = (item, fallback) => {
361 if (item !== null) {
362 return item;
363 } else {
364 return fallback;
365 }
366};
367
f152b71d
MG
368/**
369 * Render the search results in a defined container
370 *
371 * @method renderSearchResults
372 * @param {HTMLElement} searchResultsContainer The container where the data should be rendered
373 * @param {Object} searchResultsData Data containing the module items that satisfy the search criteria
374 */
375const renderSearchResults = async(searchResultsContainer, searchResultsData) => {
376 const templateData = {
377 'searchresultsnumber': searchResultsData.length,
378 'searchresults': searchResultsData
379 };
380 // Build up the html & js ready to place into the help section.
d2695ab2 381 const {html, js} = await Templates.renderForPromise('core_course/local/activitychooser/search_results', templateData);
f152b71d
MG
382 await Templates.replaceNodeContents(searchResultsContainer, html, js);
383};
384
385/**
386 * Toggle (display/hide) the search results depending on the value of the search query
387 *
388 * @method toggleSearchResultsView
16d77f18 389 * @param {Object} modal Our created modal for the section
f152b71d
MG
390 * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
391 * @param {String} searchQuery The search query
392 */
16d77f18
MM
393const toggleSearchResultsView = async(modal, mappedModules, searchQuery) => {
394 const modalBody = modal.getBody()[0];
f152b71d
MG
395 const searchResultsContainer = modalBody.querySelector(selectors.regions.searchResults);
396 const chooserContainer = modalBody.querySelector(selectors.regions.chooser);
9d82cdb6 397 const clearSearchButton = modalBody.querySelector(selectors.actions.clearSearch);
f152b71d
MG
398
399 if (searchQuery.length > 0) { // Search query is present.
400 const searchResultsData = searchModules(mappedModules, searchQuery);
401 await renderSearchResults(searchResultsContainer, searchResultsData);
402 const searchResultItemsContainer = searchResultsContainer.querySelector(selectors.regions.searchResultItems);
403 const firstSearchResultItem = searchResultItemsContainer.querySelector(selectors.regions.chooserOption.container);
404 if (firstSearchResultItem) {
405 // Set the first result item to be focusable.
406 toggleFocusableChooserOption(firstSearchResultItem, true);
407 // Register keyboard events on the created search result items.
16d77f18 408 initChooserOptionsKeyboardNavigation(modalBody, mappedModules, searchResultItemsContainer, modal);
f152b71d
MG
409 }
410 // Display the "clear" search button in the activity chooser search bar.
f152b71d
MG
411 clearSearchButton.classList.remove('d-none');
412 // Hide the default chooser options container.
413 chooserContainer.setAttribute('hidden', 'hidden');
414 // Display the search results container.
415 searchResultsContainer.removeAttribute('hidden');
416 } else { // Search query is not present.
417 // Hide the "clear" search button in the activity chooser search bar.
418 clearSearchButton.classList.add('d-none');
f152b71d
MG
419 // Hide the search results container.
420 searchResultsContainer.setAttribute('hidden', 'hidden');
421 // Display the default chooser options container.
422 chooserContainer.removeAttribute('hidden');
423 }
424};
425
426/**
427 * Return the list of modules which have a name or description that matches the given search term.
428 *
429 * @method searchModules
430 * @param {Array} modules List of available modules
431 * @param {String} searchTerm The search term to match
432 * @return {Array}
433 */
434const searchModules = (modules, searchTerm) => {
435 if (searchTerm === '') {
436 return modules;
437 }
438 searchTerm = searchTerm.toLowerCase();
439 const searchResults = [];
440 modules.forEach((activity) => {
441 const activityName = activity.title.toLowerCase();
442 const activityDesc = activity.help.toLowerCase();
443 if (activityName.includes(searchTerm) || activityDesc.includes(searchTerm)) {
444 searchResults.push(activity);
445 }
446 });
447
448 return searchResults;
449};
450
f2d033a2
MM
451/**
452 * Set up our tabindex information across the chooser.
453 *
454 * @method setupKeyboardAccessibility
455 * @param {Promise} modal Our created modal for the section
456 * @param {Map} mappedModules A map of all of the built module information
457 */
458const setupKeyboardAccessibility = (modal, mappedModules) => {
459 modal.getModal()[0].tabIndex = -1;
460
461 modal.getBodyPromise().then(body => {
462 $(selectors.elements.tab).on('shown.bs.tab', (e) => {
463 const activeSectionId = e.target.getAttribute("href");
464 const activeSectionChooserOptions = body[0]
465 .querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));
466 const firstChooserOption = activeSectionChooserOptions
467 .querySelector(selectors.regions.chooserOption.container);
468 const prevActiveSectionId = e.relatedTarget.getAttribute("href");
469 const prevActiveSectionChooserOptions = body[0]
470 .querySelector(selectors.regions.getSectionChooserOptions(prevActiveSectionId));
471
472 // Disable the focus of every chooser option in the previous active section.
473 disableFocusAllChooserOptions(prevActiveSectionChooserOptions);
474 // Enable the focus of the first chooser option in the current active section.
475 toggleFocusableChooserOption(firstChooserOption, true);
16d77f18 476 initChooserOptionsKeyboardNavigation(body[0], mappedModules, activeSectionChooserOptions, modal);
f2d033a2
MM
477 });
478 return;
479 }).catch(Notification.exception);
480};
481
f152b71d
MG
482/**
483 * Disable the focus of all chooser options in a specific container (section).
484 *
485 * @method disableFocusAllChooserOptions
486 * @param {HTMLElement} sectionChooserOptions The section that contains the chooser items
487 */
488const disableFocusAllChooserOptions = (sectionChooserOptions) => {
489 const allChooserOptions = sectionChooserOptions.querySelectorAll(selectors.regions.chooserOption.container);
490 allChooserOptions.forEach((chooserOption) => {
491 toggleFocusableChooserOption(chooserOption, false);
492 });
493};
494
05b27f21
MM
495/**
496 * Display the module chooser.
497 *
498 * @method displayChooser
f2d033a2 499 * @param {Promise} modalPromise Our created modal for the section
05b27f21 500 * @param {Array} sectionModules An array of all of the built module information
6e1a4477 501 * @param {Function} partialFavourite Partially applied function we need to manage favourite status
16d77f18 502 * @param {Object} footerData Our base footer object.
05b27f21 503 */
16d77f18 504export const displayChooser = (modalPromise, sectionModules, partialFavourite, footerData) => {
05b27f21
MM
505 // Make a map so we can quickly fetch a specific module's object for either rendering or searching.
506 const mappedModules = new Map();
507 sectionModules.forEach((module) => {
806e736a 508 mappedModules.set(module.componentname + '_' + module.link, module);
05b27f21
MM
509 });
510
511 // Register event listeners.
f2d033a2 512 modalPromise.then(modal => {
16d77f18 513 registerListenerEvents(modal, mappedModules, partialFavourite, footerData);
05b27f21 514
f2d033a2
MM
515 // We want to focus on the first chooser option element as soon as the modal is opened.
516 setupKeyboardAccessibility(modal, mappedModules);
05b27f21 517
f2d033a2
MM
518 // We want to focus on the action select when the dialog is closed.
519 modal.getRoot().on(ModalEvents.hidden, () => {
520 modal.destroy();
521 });
05b27f21 522
f2d033a2 523 return modal;
e146a2ca 524 }).catch();
05b27f21 525};