MDL-67586 core_course: Recommended modules frontend
[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';
31
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 */
39const showModuleHelp = (carousel, moduleData) => {
40 const help = carousel.find(selectors.regions.help)[0];
41 help.innerHTML = '';
42
43 // Add a spinner.
44 const spinnerPromise = addIconToContainer(help);
45
46 // Used later...
47 let transitionPromiseResolver = null;
48 const transitionPromise = new Promise(resolve => {
49 transitionPromiseResolver = resolve;
50 });
51
52 // Build up the html & js ready to place into the help section.
53 const contentPromise = Templates.renderForPromise('core_course/chooser_help', moduleData);
54
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);
63
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};
71
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 */
79const 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));
83
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 }
89
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));
93
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 };
103
104 modal.getBodyPromise()
105
106 // The return value of getBodyPromise is a jquery object containing the body NodeElement.
107 .then(body => body[0])
108
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 });
117
118 return body;
119 })
120
121 // Add the listener for clicks on the body.
122 .then(body => {
123 body.addEventListener('click', bodyClickListener);
124 return body;
125 })
126
127 // Register event listeners related to the keyboard navigation controls.
128 .then(body => {
129 initKeyboardNavigation(body, mappedModules);
130 return body;
131 })
132 .catch();
133
134};
135
136/**
137 * Initialise the keyboard navigation controls for the chooser.
138 *
139 * @method initKeyboardNavigation
c58c23d6 140 * @param {HTMLElement} body Our modal that we are working with
05b27f21
MM
141 * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
142 */
143const initKeyboardNavigation = (body, mappedModules) => {
144
c58c23d6
MM
145 // Set up the tab handlers.
146 const favTabNav = body.querySelector(selectors.regions.favouriteTabNav);
147 const recommendedTabNav = body.querySelector(selectors.regions.recommendedTabNav);
148 const defaultTabNav = body.querySelector(selectors.regions.defaultTabNav);
149 const tabNavArray = [favTabNav, recommendedTabNav, defaultTabNav];
150 tabNavArray.forEach((element) => {
151 return element.addEventListener('keyup', (e) => {
152 const firstLink = e.target.parentElement.parentElement.firstElementChild.firstElementChild;
153 const lastLink = e.target.parentElement.parentElement.lastElementChild.firstElementChild;
154
155 if (e.keyCode === arrowRight) {
156 const nextLink = e.target.parentElement.nextElementSibling;
157 if (nextLink === null) {
158 e.srcElement.tabIndex = -1;
159 firstLink.tabIndex = 0;
160 firstLink.focus();
161 } else if (nextLink.firstElementChild.classList.contains('d-none')) {
162 e.srcElement.tabIndex = -1;
163 lastLink.tabIndex = 0;
164 lastLink.focus();
165 } else {
166 e.srcElement.tabIndex = -1;
167 nextLink.firstElementChild.tabIndex = 0;
168 nextLink.firstElementChild.focus();
169 }
170 }
171 if (e.keyCode === arrowLeft) {
172 const previousLink = e.target.parentElement.previousElementSibling;
173 if (previousLink === null) {
174 e.srcElement.tabIndex = -1;
175 lastLink.tabIndex = 0;
176 lastLink.focus();
177 } else if (previousLink.firstElementChild.classList.contains('d-none')) {
178 e.srcElement.tabIndex = -1;
179 firstLink.tabIndex = 0;
180 firstLink.focus();
181 } else {
182 e.srcElement.tabIndex = -1;
183 previousLink.firstElementChild.tabIndex = 0;
184 previousLink.firstElementChild.focus();
185 }
186 }
187 if (e.keyCode === home) {
188 e.srcElement.tabIndex = -1;
189 firstLink.tabIndex = 0;
190 firstLink.focus();
191 }
192 if (e.keyCode === end) {
193 e.srcElement.tabIndex = -1;
194 lastLink.tabIndex = 0;
195 lastLink.focus();
196 }
197 if (e.keyCode === space) {
198 e.preventDefault();
199 e.target.click();
200 }
201 });
202 });
203
204 // Set up the handlers for the modules.
05b27f21
MM
205 const chooserOptions = body.querySelectorAll(selectors.regions.chooserOption.container);
206
207 Array.from(chooserOptions).forEach((element) => {
208 return element.addEventListener('keyup', (e) => {
209 const chooserOptions = document.querySelector(selectors.regions.chooserOptions);
210
211 // Check for enter/ space triggers for showing the help.
212 if (e.keyCode === enter || e.keyCode === space) {
213 if (e.target.matches(selectors.actions.optionActions.showSummary)) {
214 e.preventDefault();
215 const module = e.target.closest(selectors.regions.chooserOption.container);
216 const moduleName = module.dataset.modname;
217 const moduleData = mappedModules.get(moduleName);
218 const carousel = $(body.querySelector(selectors.regions.carousel));
219 carousel.carousel({
220 interval: false,
221 pause: true,
222 keyboard: false
223 });
224 showModuleHelp(carousel, moduleData);
225 }
226 }
227
228 // Next.
229 if (e.keyCode === arrowRight) {
230 e.preventDefault();
231 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
232 const nextOption = currentOption.nextElementSibling;
233 const firstOption = chooserOptions.firstElementChild;
234 const toFocusOption = clickErrorHandler(nextOption, firstOption);
235 focusChooserOption(toFocusOption, currentOption);
236 }
237
238 // Previous.
239 if (e.keyCode === arrowLeft) {
240 e.preventDefault();
241 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
242 const previousOption = currentOption.previousElementSibling;
243 const lastOption = chooserOptions.lastElementChild;
244 const toFocusOption = clickErrorHandler(previousOption, lastOption);
245 focusChooserOption(toFocusOption, currentOption);
246 }
247
248 if (e.keyCode === home) {
249 e.preventDefault();
250 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
251 const firstOption = chooserOptions.firstElementChild;
252 focusChooserOption(firstOption, currentOption);
253 }
254
255 if (e.keyCode === end) {
256 e.preventDefault();
257 const currentOption = e.target.closest(selectors.regions.chooserOption.container);
258 const lastOption = chooserOptions.lastElementChild;
259 focusChooserOption(lastOption, currentOption);
260 }
261 });
262 });
263};
264
265/**
266 * Focus on a chooser option element and remove the previous chooser element from the focus order
267 *
268 * @method focusChooserOption
269 * @param {HTMLElement} currentChooserOption The current chooser option element that we want to focus
270 * @param {HTMLElement} previousChooserOption The previous focused option element
271 */
272const focusChooserOption = (currentChooserOption, previousChooserOption = false) => {
273 if (previousChooserOption !== false) {
274 const previousChooserOptionLink = previousChooserOption.querySelector(selectors.actions.addChooser);
275 const previousChooserOptionHelp = previousChooserOption.querySelector(selectors.actions.optionActions.showSummary);
276 // Set tabindex to -1 to remove the previous chooser option element from the focus order.
277 previousChooserOption.tabIndex = -1;
278 previousChooserOptionLink.tabIndex = -1;
279 previousChooserOptionHelp.tabIndex = -1;
280 }
281
282 const currentChooserOptionLink = currentChooserOption.querySelector(selectors.actions.addChooser);
283 const currentChooserOptionHelp = currentChooserOption.querySelector(selectors.actions.optionActions.showSummary);
284 // Set tabindex to 0 to add current chooser option element to the focus order.
285 currentChooserOption.tabIndex = 0;
286 currentChooserOptionLink.tabIndex = 0;
287 currentChooserOptionHelp.tabIndex = 0;
288 // Focus the current chooser option element.
289 currentChooserOption.focus();
290};
291
292/**
293 * Small error handling function to make sure the navigated to object exists
294 *
295 * @method clickErrorHandler
296 * @param {HTMLElement} item What we want to check exists
297 * @param {HTMLElement} fallback If we dont match anything fallback the focus
298 * @return {String}
299 */
300const clickErrorHandler = (item, fallback) => {
301 if (item !== null) {
302 return item;
303 } else {
304 return fallback;
305 }
306};
307
308/**
309 * Display the module chooser.
310 *
311 * @method displayChooser
312 * @param {HTMLElement} origin The calling button
313 * @param {Object} modal Our created modal for the section
314 * @param {Array} sectionModules An array of all of the built module information
315 */
316export const displayChooser = (origin, modal, sectionModules) => {
317
318 // Make a map so we can quickly fetch a specific module's object for either rendering or searching.
319 const mappedModules = new Map();
320 sectionModules.forEach((module) => {
806e736a 321 mappedModules.set(module.componentname + '_' + module.link, module);
05b27f21
MM
322 });
323
324 // Register event listeners.
325 registerListenerEvents(modal, mappedModules);
326
327 // We want to focus on the action select when the dialog is closed.
328 modal.getRoot().on(ModalEvents.hidden, () => {
329 modal.destroy();
330 });
331
332 // We want to focus on the first chooser option element as soon as the modal is opened.
333 modal.getRoot().on(ModalEvents.shown, () => {
334 modal.getModal()[0].tabIndex = -1;
335
336 modal.getBodyPromise()
337 .then(body => {
338 const firstChooserOption = body[0].querySelector(selectors.regions.chooserOption.container);
339 focusChooserOption(firstChooserOption);
340
341 return;
342 })
343 .catch(Notification.exception);
344 });
345
346 modal.show();
347};