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';
05b27f21
MM
33
34/**
35 * Given an event from the main module 'page' navigate to it's help section via a carousel.
36 *
37 * @method showModuleHelp
38 * @param {jQuery} carousel Our initialized carousel to manipulate
39 * @param {Object} moduleData Data of the module to carousel to
40 */
41const showModuleHelp = (carousel, moduleData) => {
42 const help = carousel.find(selectors.regions.help)[0];
43 help.innerHTML = '';
44
45 // Add a spinner.
46 const spinnerPromise = addIconToContainer(help);
47
48 // Used later...
49 let transitionPromiseResolver = null;
50 const transitionPromise = new Promise(resolve => {
51 transitionPromiseResolver = resolve;
52 });
53
54 // Build up the html & js ready to place into the help section.
55 const contentPromise = Templates.renderForPromise('core_course/chooser_help', moduleData);
56
57 // Wait for the content to be ready, and for the transition to be complet.
58 Promise.all([contentPromise, spinnerPromise, transitionPromise])
59 .then(([{html, js}]) => Templates.replaceNodeContents(help, html, js))
60 .then(() => {
61 help.querySelector(selectors.regions.chooserSummary.description).focus();
62 return help;
63 })
64 .catch(Notification.exception);
65
66 // Move to the next slide, and resolve the transition promise when it's done.
67 carousel.one('slid.bs.carousel', () => {
68 transitionPromiseResolver();
69 });
70 // Trigger the transition between 'pages'.
71 carousel.carousel('next');
72};
73
6e1a4477
MM
74/**
75 * Given a user wants to change the favourite state of a module we either add or remove the status.
76 * We also propergate this change across our map of modals.
77 *
78 * @method manageFavouriteState
79 * @param {HTMLElement} modalBody The DOM node of the modal to manipulate
80 * @param {HTMLElement} caller
81 * @param {Function} partialFavourite Partially applied function we need to manage favourite status
82 */
83const manageFavouriteState = async(modalBody, caller, partialFavourite) => {
84 const isFavourite = caller.dataset.favourited;
85 const id = caller.dataset.id;
86 const name = caller.dataset.name;
87 const internal = caller.dataset.internal;
88 // Switch on fave or not.
89 if (isFavourite === 'true') {
90 await Repository.unfavouriteModule(name, id);
91
92 partialFavourite(internal, false, modalBody);
93 } else {
94 await Repository.favouriteModule(name, id);
95
96 partialFavourite(internal, true, modalBody);
97 }
98
99};
100
05b27f21
MM
101/**
102 * Register chooser related event listeners.
103 *
104 * @method registerListenerEvents
105 * @param {Promise} modal Our modal that we are working with
106 * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
6e1a4477 107 * @param {Function} partialFavourite Partially applied function we need to manage favourite status
05b27f21 108 */
6e1a4477 109const registerListenerEvents = (modal, mappedModules, partialFavourite) => {
05b27f21
MM
110 const bodyClickListener = e => {
111 if (e.target.closest(selectors.actions.optionActions.showSummary)) {
112 const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));
113
114 const module = e.target.closest(selectors.regions.chooserOption.container);
115 const moduleName = module.dataset.modname;
116 const moduleData = mappedModules.get(moduleName);
117 showModuleHelp(carousel, moduleData);
118 }
119
6e1a4477
MM
120 if (e.target.closest(selectors.actions.optionActions.manageFavourite)) {
121 const caller = e.target.closest(selectors.actions.optionActions.manageFavourite);
122 manageFavouriteState(modal.getBody()[0], caller, partialFavourite);
123 }
124
05b27f21
MM
125 // From the help screen go back to the module overview.
126 if (e.target.matches(selectors.actions.closeOption)) {
127 const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));
128
129 // Trigger the transition between 'pages'.
130 carousel.carousel('prev');
131 carousel.on('slid.bs.carousel', () => {
132 const allModules = modal.getBody()[0].querySelector(selectors.regions.modules);
133 const caller = allModules.querySelector(selectors.regions.getModuleSelector(e.target.dataset.modname));
134 caller.focus();
135 });
136 }
137 };
138
139 modal.getBodyPromise()
140
141 // The return value of getBodyPromise is a jquery object containing the body NodeElement.
142 .then(body => body[0])
143
144 // Set up the carousel.
145 .then(body => {
146 $(body.querySelector(selectors.regions.carousel))
147 .carousel({
148 interval: false,
149 pause: true,
150 keyboard: false
151 });
152
153 return body;
154 })
155
156 // Add the listener for clicks on the body.
157 .then(body => {
158 body.addEventListener('click', bodyClickListener);
159 return body;
160 })
161
162 // Register event listeners related to the keyboard navigation controls.
163 .then(body => {
164 initKeyboardNavigation(body, mappedModules);
165 return body;
166 })
167 .catch();
168
169};
170
171/**
172 * Initialise the keyboard navigation controls for the chooser.
173 *
174 * @method initKeyboardNavigation
c58c23d6 175 * @param {HTMLElement} body Our modal that we are working with
05b27f21
MM
176 * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
177 */
178const initKeyboardNavigation = (body, mappedModules) => {
179
c58c23d6
MM
180 // Set up the tab handlers.
181 const favTabNav = body.querySelector(selectors.regions.favouriteTabNav);
182 const recommendedTabNav = body.querySelector(selectors.regions.recommendedTabNav);
183 const defaultTabNav = body.querySelector(selectors.regions.defaultTabNav);
184 const tabNavArray = [favTabNav, recommendedTabNav, defaultTabNav];
185 tabNavArray.forEach((element) => {
186 return element.addEventListener('keyup', (e) => {
187 const firstLink = e.target.parentElement.parentElement.firstElementChild.firstElementChild;
188 const lastLink = e.target.parentElement.parentElement.lastElementChild.firstElementChild;
189
190 if (e.keyCode === arrowRight) {
191 const nextLink = e.target.parentElement.nextElementSibling;
192 if (nextLink === null) {
193 e.srcElement.tabIndex = -1;
194 firstLink.tabIndex = 0;
195 firstLink.focus();
196 } else if (nextLink.firstElementChild.classList.contains('d-none')) {
197 e.srcElement.tabIndex = -1;
198 lastLink.tabIndex = 0;
199 lastLink.focus();
200 } else {
201 e.srcElement.tabIndex = -1;
202 nextLink.firstElementChild.tabIndex = 0;
203 nextLink.firstElementChild.focus();
204 }
205 }
206 if (e.keyCode === arrowLeft) {
207 const previousLink = e.target.parentElement.previousElementSibling;
208 if (previousLink === null) {
209 e.srcElement.tabIndex = -1;
210 lastLink.tabIndex = 0;
211 lastLink.focus();
212 } else if (previousLink.firstElementChild.classList.contains('d-none')) {
213 e.srcElement.tabIndex = -1;
214 firstLink.tabIndex = 0;
215 firstLink.focus();
216 } else {
217 e.srcElement.tabIndex = -1;
218 previousLink.firstElementChild.tabIndex = 0;
219 previousLink.firstElementChild.focus();
220 }
221 }
222 if (e.keyCode === home) {
223 e.srcElement.tabIndex = -1;
224 firstLink.tabIndex = 0;
225 firstLink.focus();
226 }
227 if (e.keyCode === end) {
228 e.srcElement.tabIndex = -1;
229 lastLink.tabIndex = 0;
230 lastLink.focus();
231 }
232 if (e.keyCode === space) {
233 e.preventDefault();
234 e.target.click();
235 }
236 });
237 });
238
239 // Set up the handlers for the modules.
05b27f21
MM
240 const chooserOptions = body.querySelectorAll(selectors.regions.chooserOption.container);
241
242 Array.from(chooserOptions).forEach((element) => {
243 return element.addEventListener('keyup', (e) => {
244 const chooserOptions = document.querySelector(selectors.regions.chooserOptions);
245
246 // Check for enter/ space triggers for showing the help.
247 if (e.keyCode === enter || e.keyCode === space) {
248 if (e.target.matches(selectors.actions.optionActions.showSummary)) {
249 e.preventDefault();
250 const module = e.target.closest(selectors.regions.chooserOption.container);
251 const moduleName = module.dataset.modname;
252 const moduleData = mappedModules.get(moduleName);
253 const carousel = $(body.querySelector(selectors.regions.carousel));
254 carousel.carousel({
255 interval: false,
256 pause: true,
257 keyboard: false
258 });
259 showModuleHelp(carousel, moduleData);
260 }
261 }
262
263 // Next.
264 if (e.keyCode === arrowRight) {
265 e.preventDefault();
266 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
267 const nextOption = currentOption.nextElementSibling;
268 const firstOption = chooserOptions.firstElementChild;
269 const toFocusOption = clickErrorHandler(nextOption, firstOption);
270 focusChooserOption(toFocusOption, currentOption);
271 }
272
273 // Previous.
274 if (e.keyCode === arrowLeft) {
275 e.preventDefault();
276 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
277 const previousOption = currentOption.previousElementSibling;
278 const lastOption = chooserOptions.lastElementChild;
279 const toFocusOption = clickErrorHandler(previousOption, lastOption);
280 focusChooserOption(toFocusOption, currentOption);
281 }
282
283 if (e.keyCode === home) {
284 e.preventDefault();
285 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
286 const firstOption = chooserOptions.firstElementChild;
287 focusChooserOption(firstOption, currentOption);
288 }
289
290 if (e.keyCode === end) {
291 e.preventDefault();
292 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
293 const lastOption = chooserOptions.lastElementChild;
294 focusChooserOption(lastOption, currentOption);
295 }
296 });
297 });
298};
299
300/**
301 * Focus on a chooser option element and remove the previous chooser element from the focus order
302 *
303 * @method focusChooserOption
304 * @param {HTMLElement} currentChooserOption The current chooser option element that we want to focus
305 * @param {HTMLElement} previousChooserOption The previous focused option element
306 */
307const focusChooserOption = (currentChooserOption, previousChooserOption = false) => {
308 if (previousChooserOption !== false) {
309 const previousChooserOptionLink = previousChooserOption.querySelector(selectors.actions.addChooser);
310 const previousChooserOptionHelp = previousChooserOption.querySelector(selectors.actions.optionActions.showSummary);
6e1a4477 311 const previousChooserOptionFavourite = previousChooserOption.querySelector(selectors.actions.optionActions.manageFavourite);
05b27f21
MM
312 // Set tabindex to -1 to remove the previous chooser option element from the focus order.
313 previousChooserOption.tabIndex = -1;
314 previousChooserOptionLink.tabIndex = -1;
315 previousChooserOptionHelp.tabIndex = -1;
6e1a4477 316 previousChooserOptionFavourite.tabIndex = -1;
05b27f21
MM
317 }
318
319 const currentChooserOptionLink = currentChooserOption.querySelector(selectors.actions.addChooser);
320 const currentChooserOptionHelp = currentChooserOption.querySelector(selectors.actions.optionActions.showSummary);
6e1a4477 321 const currentChooserOptionFavourite = currentChooserOption.querySelector(selectors.actions.optionActions.manageFavourite);
05b27f21
MM
322 // Set tabindex to 0 to add current chooser option element to the focus order.
323 currentChooserOption.tabIndex = 0;
324 currentChooserOptionLink.tabIndex = 0;
325 currentChooserOptionHelp.tabIndex = 0;
6e1a4477 326 currentChooserOptionFavourite.tabIndex = 0;
05b27f21
MM
327 // Focus the current chooser option element.
328 currentChooserOption.focus();
329};
330
331/**
332 * Small error handling function to make sure the navigated to object exists
333 *
334 * @method clickErrorHandler
335 * @param {HTMLElement} item What we want to check exists
336 * @param {HTMLElement} fallback If we dont match anything fallback the focus
337 * @return {String}
338 */
339const clickErrorHandler = (item, fallback) => {
340 if (item !== null) {
341 return item;
342 } else {
343 return fallback;
344 }
345};
346
347/**
348 * Display the module chooser.
349 *
350 * @method displayChooser
351 * @param {HTMLElement} origin The calling button
352 * @param {Object} modal Our created modal for the section
353 * @param {Array} sectionModules An array of all of the built module information
6e1a4477 354 * @param {Function} partialFavourite Partially applied function we need to manage favourite status
05b27f21 355 */
6e1a4477 356export const displayChooser = (origin, modal, sectionModules, partialFavourite) => {
05b27f21
MM
357
358 // Make a map so we can quickly fetch a specific module's object for either rendering or searching.
359 const mappedModules = new Map();
360 sectionModules.forEach((module) => {
806e736a 361 mappedModules.set(module.componentname + '_' + module.link, module);
05b27f21
MM
362 });
363
364 // Register event listeners.
6e1a4477 365 registerListenerEvents(modal, mappedModules, partialFavourite);
05b27f21
MM
366
367 // We want to focus on the action select when the dialog is closed.
368 modal.getRoot().on(ModalEvents.hidden, () => {
369 modal.destroy();
370 });
371
372 // We want to focus on the first chooser option element as soon as the modal is opened.
373 modal.getRoot().on(ModalEvents.shown, () => {
374 modal.getModal()[0].tabIndex = -1;
375
376 modal.getBodyPromise()
377 .then(body => {
378 const firstChooserOption = body[0].querySelector(selectors.regions.chooserOption.container);
379 focusChooserOption(firstChooserOption);
380
381 return;
382 })
383 .catch(Notification.exception);
384 });
385
386 modal.show();
387};