MDL-67264 core_course: Activity chooser new feature
[moodle.git] / course / amd / src / local / activitychooser / dialogue.js
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/>.
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  */
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';
32 /**
33  * Given an event from the main module 'page' navigate to it's help section via a carousel.
34  *
35  * @method showModuleHelp
36  * @param {jQuery} carousel Our initialized carousel to manipulate
37  * @param {Object} moduleData Data of the module to carousel to
38  */
39 const showModuleHelp = (carousel, moduleData) => {
40     const help = carousel.find(selectors.regions.help)[0];
41     help.innerHTML = '';
43     // Add a spinner.
44     const spinnerPromise = addIconToContainer(help);
46     // Used later...
47     let transitionPromiseResolver = null;
48     const transitionPromise = new Promise(resolve => {
49         transitionPromiseResolver = resolve;
50     });
52     // Build up the html & js ready to place into the help section.
53     const contentPromise = Templates.renderForPromise('core_course/chooser_help', moduleData);
55     // Wait for the content to be ready, and for the transition to be complet.
56     Promise.all([contentPromise, spinnerPromise, transitionPromise])
57         .then(([{html, js}]) => Templates.replaceNodeContents(help, html, js))
58         .then(() => {
59             help.querySelector(selectors.regions.chooserSummary.description).focus();
60             return help;
61         })
62         .catch(Notification.exception);
64     // Move to the next slide, and resolve the transition promise when it's done.
65     carousel.one('slid.bs.carousel', () => {
66         transitionPromiseResolver();
67     });
68     // Trigger the transition between 'pages'.
69     carousel.carousel('next');
70 };
72 /**
73  * Register chooser related event listeners.
74  *
75  * @method registerListenerEvents
76  * @param {Promise} modal Our modal that we are working with
77  * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
78  */
79 const registerListenerEvents = (modal, mappedModules) => {
80     const bodyClickListener = e => {
81         if (e.target.closest(selectors.actions.optionActions.showSummary)) {
82             const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));
84             const module = e.target.closest(selectors.regions.chooserOption.container);
85             const moduleName = module.dataset.modname;
86             const moduleData = mappedModules.get(moduleName);
87             showModuleHelp(carousel, moduleData);
88         }
90         // From the help screen go back to the module overview.
91         if (e.target.matches(selectors.actions.closeOption)) {
92             const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));
94             // Trigger the transition between 'pages'.
95             carousel.carousel('prev');
96             carousel.on('slid.bs.carousel', () => {
97                 const allModules = modal.getBody()[0].querySelector(selectors.regions.modules);
98                 const caller = allModules.querySelector(selectors.regions.getModuleSelector(e.target.dataset.modname));
99                 caller.focus();
100             });
101         }
102     };
104     modal.getBodyPromise()
106     // The return value of getBodyPromise is a jquery object containing the body NodeElement.
107     .then(body => body[0])
109     // Set up the carousel.
110     .then(body => {
111         $(body.querySelector(selectors.regions.carousel))
112             .carousel({
113                 interval: false,
114                 pause: true,
115                 keyboard: false
116             });
118         return body;
119     })
121     // Add the listener for clicks on the body.
122     .then(body => {
123         body.addEventListener('click', bodyClickListener);
124         return body;
125     })
127     // Register event listeners related to the keyboard navigation controls.
128     .then(body => {
129         initKeyboardNavigation(body, mappedModules);
130         return body;
131     })
132     .catch();
134 };
136 /**
137  * Initialise the keyboard navigation controls for the chooser.
138  *
139  * @method initKeyboardNavigation
140  * @param {NodeElement} body Our modal that we are working with
141  * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
142  */
143 const initKeyboardNavigation = (body, mappedModules) => {
145     const chooserOptions = body.querySelectorAll(selectors.regions.chooserOption.container);
147     Array.from(chooserOptions).forEach((element) => {
148         return element.addEventListener('keyup', (e) => {
149             const chooserOptions = document.querySelector(selectors.regions.chooserOptions);
151             // Check for enter/ space triggers for showing the help.
152             if (e.keyCode === enter || e.keyCode === space) {
153                 if (e.target.matches(selectors.actions.optionActions.showSummary)) {
154                     e.preventDefault();
155                     const module = e.target.closest(selectors.regions.chooserOption.container);
156                     const moduleName = module.dataset.modname;
157                     const moduleData = mappedModules.get(moduleName);
158                     const carousel = $(body.querySelector(selectors.regions.carousel));
159                     carousel.carousel({
160                         interval: false,
161                         pause: true,
162                         keyboard: false
163                     });
164                     showModuleHelp(carousel, moduleData);
165                 }
166             }
168             // Next.
169             if (e.keyCode === arrowRight) {
170                 e.preventDefault();
171                 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
172                 const nextOption = currentOption.nextElementSibling;
173                 const firstOption = chooserOptions.firstElementChild;
174                 const toFocusOption = clickErrorHandler(nextOption, firstOption);
175                 focusChooserOption(toFocusOption, currentOption);
176             }
178             // Previous.
179             if (e.keyCode === arrowLeft) {
180                 e.preventDefault();
181                 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
182                 const previousOption = currentOption.previousElementSibling;
183                 const lastOption = chooserOptions.lastElementChild;
184                 const toFocusOption = clickErrorHandler(previousOption, lastOption);
185                 focusChooserOption(toFocusOption, currentOption);
186             }
188             if (e.keyCode === home) {
189                 e.preventDefault();
190                 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
191                 const firstOption = chooserOptions.firstElementChild;
192                 focusChooserOption(firstOption, currentOption);
193             }
195             if (e.keyCode === end) {
196                 e.preventDefault();
197                 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
198                 const lastOption = chooserOptions.lastElementChild;
199                 focusChooserOption(lastOption, currentOption);
200             }
201         });
202     });
203 };
205 /**
206  * Focus on a chooser option element and remove the previous chooser element from the focus order
207  *
208  * @method focusChooserOption
209  * @param {HTMLElement} currentChooserOption The current chooser option element that we want to focus
210  * @param {HTMLElement} previousChooserOption The previous focused option element
211  */
212 const focusChooserOption = (currentChooserOption, previousChooserOption = false) => {
213     if (previousChooserOption !== false) {
214         const previousChooserOptionLink = previousChooserOption.querySelector(selectors.actions.addChooser);
215         const previousChooserOptionHelp = previousChooserOption.querySelector(selectors.actions.optionActions.showSummary);
216         // Set tabindex to -1 to remove the previous chooser option element from the focus order.
217         previousChooserOption.tabIndex = -1;
218         previousChooserOptionLink.tabIndex = -1;
219         previousChooserOptionHelp.tabIndex = -1;
220     }
222     const currentChooserOptionLink = currentChooserOption.querySelector(selectors.actions.addChooser);
223     const currentChooserOptionHelp = currentChooserOption.querySelector(selectors.actions.optionActions.showSummary);
224     // Set tabindex to 0 to add current chooser option element to the focus order.
225     currentChooserOption.tabIndex = 0;
226     currentChooserOptionLink.tabIndex = 0;
227     currentChooserOptionHelp.tabIndex = 0;
228     // Focus the current chooser option element.
229     currentChooserOption.focus();
230 };
232 /**
233  * Small error handling function to make sure the navigated to object exists
234  *
235  * @method clickErrorHandler
236  * @param {HTMLElement} item What we want to check exists
237  * @param {HTMLElement} fallback If we dont match anything fallback the focus
238  * @return {String}
239  */
240 const clickErrorHandler = (item, fallback) => {
241     if (item !== null) {
242         return item;
243     } else {
244         return fallback;
245     }
246 };
248 /**
249  * Display the module chooser.
250  *
251  * @method displayChooser
252  * @param {HTMLElement} origin The calling button
253  * @param {Object} modal Our created modal for the section
254  * @param {Array} sectionModules An array of all of the built module information
255  */
256 export const displayChooser = (origin, modal, sectionModules) => {
258     // Make a map so we can quickly fetch a specific module's object for either rendering or searching.
259     const mappedModules = new Map();
260     sectionModules.forEach((module) => {
261         mappedModules.set(module.modulename, module);
262     });
264     // Register event listeners.
265     registerListenerEvents(modal, mappedModules);
267     // We want to focus on the action select when the dialog is closed.
268     modal.getRoot().on(ModalEvents.hidden, () => {
269         modal.destroy();
270     });
272     // We want to focus on the first chooser option element as soon as the modal is opened.
273     modal.getRoot().on(ModalEvents.shown, () => {
274         modal.getModal()[0].tabIndex = -1;
276         modal.getBodyPromise()
277         .then(body => {
278             const firstChooserOption = body[0].querySelector(selectors.regions.chooserOption.container);
279             focusChooserOption(firstChooserOption);
281             return;
282         })
283         .catch(Notification.exception);
284     });
286     modal.show();
287 };