MDL-67264 core_course: Activity chooser new feature
[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
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 */
143const initKeyboardNavigation = (body, mappedModules) => {
144
145 const chooserOptions = body.querySelectorAll(selectors.regions.chooserOption.container);
146
147 Array.from(chooserOptions).forEach((element) => {
148 return element.addEventListener('keyup', (e) => {
149 const chooserOptions = document.querySelector(selectors.regions.chooserOptions);
150
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 }
167
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 }
177
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 }
187
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 }
194
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};
204
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 */
212const 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 }
221
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};
231
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 */
240const clickErrorHandler = (item, fallback) => {
241 if (item !== null) {
242 return item;
243 } else {
244 return fallback;
245 }
246};
247
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 */
256export const displayChooser = (origin, modal, sectionModules) => {
257
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 });
263
264 // Register event listeners.
265 registerListenerEvents(modal, mappedModules);
266
267 // We want to focus on the action select when the dialog is closed.
268 modal.getRoot().on(ModalEvents.hidden, () => {
269 modal.destroy();
270 });
271
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;
275
276 modal.getBodyPromise()
277 .then(body => {
278 const firstChooserOption = body[0].querySelector(selectors.regions.chooserOption.container);
279 focusChooserOption(firstChooserOption);
280
281 return;
282 })
283 .catch(Notification.exception);
284 });
285
286 modal.show();
287};