Commit | Line | Data |
---|---|---|
53c1b936 ARN |
1 | YUI.add('moodle-course-categoryexpander', function (Y, NAME) { |
2 | ||
3 | /** | |
4 | * Adds toggling of subcategory with automatic loading using AJAX. | |
5 | * | |
6 | * This also includes application of an animation to improve user experience. | |
7 | * | |
8 | * @module moodle-course-categoryexpander | |
9 | */ | |
10 | ||
11 | /** | |
12 | * The course category expander. | |
13 | * | |
14 | * @constructor | |
15 | * @class Y.Moodle.course.categoryexpander | |
16 | */ | |
17 | ||
18 | var CSS = { | |
19 | CONTENTNODE: 'content', | |
20 | COLLAPSEALL: 'collapse-all', | |
21 | DISABLED: 'disabled', | |
22 | LOADED: 'loaded', | |
23 | NOTLOADED: 'notloaded', | |
24 | SECTIONCOLLAPSED: 'collapsed', | |
25 | HASCHILDREN: 'with_children' | |
26 | }, | |
27 | SELECTORS = { | |
28 | LOADEDTREES: '.with_children.loaded', | |
29 | CONTENTNODE: '.content', | |
30 | CATEGORYLISTENLINK: '.category .info .name', | |
31 | CATEGORYSPINNERLOCATION: '.name', | |
32 | CATEGORYWITHCOLLAPSEDLOADEDCHILDREN: '.category.with_children.loaded.collapsed', | |
33 | CATEGORYWITHMAXIMISEDLOADEDCHILDREN: '.category.with_children.loaded:not(.collapsed)', | |
34 | COLLAPSEEXPAND: '.collapseexpand', | |
35 | COURSEBOX: '.coursebox', | |
36 | COURSEBOXLISTENLINK: '.coursebox .moreinfo', | |
37 | COURSEBOXSPINNERLOCATION: '.name a', | |
38 | COURSECATEGORYTREE: '.course_category_tree', | |
39 | PARENTWITHCHILDREN: '.category' | |
40 | }, | |
41 | NS = Y.namespace('Moodle.course.categoryexpander'), | |
42 | TYPE_CATEGORY = 0, | |
43 | TYPE_COURSE = 1, | |
44 | URL = M.cfg.wwwroot + '/course/category.ajax.php'; | |
45 | ||
46 | /** | |
47 | * Set up the category expander. | |
48 | * | |
49 | * No arguments are required. | |
50 | * | |
51 | * @method init | |
52 | */ | |
53 | NS.init = function() { | |
1574e652 AN |
54 | var doc = Y.one(Y.config.doc); |
55 | doc.delegate('click', this.toggle_category_expansion, SELECTORS.CATEGORYLISTENLINK, this); | |
56 | doc.delegate('click', this.toggle_coursebox_expansion, SELECTORS.COURSEBOXLISTENLINK, this); | |
57 | doc.delegate('click', this.collapse_expand_all, SELECTORS.COLLAPSEEXPAND, this); | |
58 | ||
59 | // Only set up they keybaord listeners when tab is first pressed - it | |
60 | // may never happen and modifying the DOM on a large number of nodes | |
61 | // can be very expensive. | |
62 | doc.once('key', this.setup_keyboard_listeners, 'tab', this); | |
63 | }; | |
64 | ||
65 | /** | |
66 | * Set up keyboard expansion for course content. | |
67 | * | |
68 | * This includes setting up the delegation but also adding the nodes to the | |
69 | * tabflow. | |
70 | * | |
71 | * @method setup_keyboard_listeners | |
72 | */ | |
73 | NS.setup_keyboard_listeners = function() { | |
74 | var doc = Y.one(Y.config.doc); | |
75 | ||
76 | Y.log('Setting the tabindex for all expandable course nodes', 'info', 'moodle-course-categoryexpander'); | |
77 | doc.all(SELECTORS.CATEGORYLISTENLINK, SELECTORS.COURSEBOXLISTENLINK, SELECTORS.COLLAPSEEXPAND).setAttribute('tabindex', '0'); | |
78 | ||
79 | ||
80 | Y.one(Y.config.doc).delegate('key', this.toggle_category_expansion, 'enter', SELECTORS.CATEGORYLISTENLINK, this); | |
81 | Y.one(Y.config.doc).delegate('key', this.toggle_coursebox_expansion, 'enter', SELECTORS.COURSEBOXLISTENLINK, this); | |
82 | Y.one(Y.config.doc).delegate('key', this.collapse_expand_all, 'enter', SELECTORS.COLLAPSEEXPAND, this); | |
53c1b936 ARN |
83 | }; |
84 | ||
85 | /** | |
86 | * Toggle the animation of the clicked category node. | |
87 | * | |
88 | * @method toggle_category_expansion | |
89 | * @private | |
90 | * @param {EventFacade} e | |
91 | */ | |
92 | NS.toggle_category_expansion = function(e) { | |
93 | // Load the actual dependencies now that we've been called. | |
94 | Y.use('io-base', 'json-parse', 'moodle-core-notification', 'anim', function() { | |
95 | // Overload the toggle_category_expansion with the _toggle_category_expansion function to ensure that | |
96 | // this function isn't called in the future, and call it for the first time. | |
97 | NS.toggle_category_expansion = NS._toggle_category_expansion; | |
98 | NS.toggle_category_expansion(e); | |
99 | }); | |
100 | }; | |
101 | ||
102 | /** | |
103 | * Toggle the animation of the clicked coursebox node. | |
104 | * | |
105 | * @method toggle_coursebox_expansion | |
106 | * @private | |
107 | * @param {EventFacade} e | |
108 | */ | |
109 | NS.toggle_coursebox_expansion = function(e) { | |
110 | // Load the actual dependencies now that we've been called. | |
111 | Y.use('io-base', 'json-parse', 'moodle-core-notification', 'anim', function() { | |
112 | // Overload the toggle_coursebox_expansion with the _toggle_coursebox_expansion function to ensure that | |
113 | // this function isn't called in the future, and call it for the first time. | |
114 | NS.toggle_coursebox_expansion = NS._toggle_coursebox_expansion; | |
115 | NS.toggle_coursebox_expansion(e); | |
116 | }); | |
117 | ||
118 | e.preventDefault(); | |
119 | }; | |
120 | ||
121 | NS._toggle_coursebox_expansion = function(e) { | |
122 | var courseboxnode; | |
123 | ||
124 | // Grab the parent category container - this is where the new content will be added. | |
125 | courseboxnode = e.target.ancestor(SELECTORS.COURSEBOX, true); | |
126 | e.preventDefault(); | |
127 | ||
128 | if (courseboxnode.hasClass(CSS.LOADED)) { | |
129 | // We've already loaded this content so we just need to toggle the view of it. | |
130 | this.run_expansion(courseboxnode); | |
131 | return; | |
132 | } | |
133 | ||
134 | this._toggle_generic_expansion({ | |
135 | parentnode: courseboxnode, | |
136 | childnode: courseboxnode.one(SELECTORS.CONTENTNODE), | |
137 | spinnerhandle: SELECTORS.COURSEBOXSPINNERLOCATION, | |
138 | data: { | |
139 | courseid: courseboxnode.getData('courseid'), | |
140 | type: TYPE_COURSE | |
141 | } | |
142 | }); | |
143 | }; | |
144 | ||
145 | NS._toggle_category_expansion = function(e) { | |
146 | var categorynode, | |
147 | categoryid, | |
148 | depth; | |
149 | ||
150 | if (e.target.test('a') || e.target.test('img')) { | |
151 | // Return early if either an anchor or an image were clicked. | |
152 | return; | |
153 | } | |
154 | ||
155 | // Grab the parent category container - this is where the new content will be added. | |
156 | categorynode = e.target.ancestor(SELECTORS.PARENTWITHCHILDREN, true); | |
157 | ||
158 | if (!categorynode.hasClass(CSS.HASCHILDREN)) { | |
159 | // Nothing to do here - this category has no children. | |
160 | return; | |
161 | } | |
162 | ||
163 | if (categorynode.hasClass(CSS.LOADED)) { | |
164 | // We've already loaded this content so we just need to toggle the view of it. | |
165 | this.run_expansion(categorynode); | |
166 | return; | |
167 | } | |
168 | ||
169 | // We use Data attributes to store the category. | |
170 | categoryid = categorynode.getData('categoryid'); | |
171 | depth = categorynode.getData('depth'); | |
172 | if (typeof categoryid === "undefined" || typeof depth === "undefined") { | |
173 | return; | |
174 | } | |
175 | ||
176 | this._toggle_generic_expansion({ | |
177 | parentnode: categorynode, | |
178 | childnode: categorynode.one(SELECTORS.CONTENTNODE), | |
179 | spinnerhandle: SELECTORS.CATEGORYSPINNERLOCATION, | |
180 | data: { | |
181 | categoryid: categoryid, | |
182 | depth: depth, | |
183 | showcourses: categorynode.getData('showcourses'), | |
184 | type: TYPE_CATEGORY | |
185 | } | |
186 | }); | |
187 | }; | |
188 | ||
189 | /** | |
190 | * Wrapper function to handle toggling of generic types. | |
191 | * | |
192 | * @method _toggle_generic_expansion | |
193 | * @private | |
194 | * @param {Object} config | |
195 | */ | |
196 | NS._toggle_generic_expansion = function(config) { | |
197 | if (config.spinnerhandle) { | |
198 | // Add a spinner to give some feedback to the user. | |
199 | spinner = M.util.add_spinner(Y, config.parentnode.one(config.spinnerhandle)).show(); | |
200 | } | |
201 | ||
202 | // Fetch the data. | |
203 | Y.io(URL, { | |
204 | method: 'POST', | |
205 | context: this, | |
206 | on: { | |
207 | complete: this.process_results | |
208 | }, | |
209 | data: config.data, | |
210 | "arguments": { | |
211 | parentnode: config.parentnode, | |
212 | childnode: config.childnode, | |
213 | spinner: spinner | |
214 | } | |
215 | }); | |
216 | }; | |
217 | ||
218 | /** | |
219 | * Apply the animation on the supplied node. | |
220 | * | |
221 | * @method run_expansion | |
222 | * @private | |
223 | * @param {Node} categorynode The node to apply the animation to | |
224 | */ | |
225 | NS.run_expansion = function(categorynode) { | |
226 | var categorychildren = categorynode.one(SELECTORS.CONTENTNODE), | |
227 | self = this, | |
228 | ancestor = categorynode.ancestor(SELECTORS.COURSECATEGORYTREE); | |
229 | ||
230 | // Add our animation to the categorychildren. | |
231 | this.add_animation(categorychildren); | |
232 | ||
233 | ||
234 | // If we already have the class, remove it before showing otherwise we perform the | |
235 | // animation whilst the node is hidden. | |
236 | if (categorynode.hasClass(CSS.SECTIONCOLLAPSED)) { | |
237 | // To avoid a jump effect, we need to set the height of the children to 0 here before removing the SECTIONCOLLAPSED class. | |
238 | categorychildren.setStyle('height', '0'); | |
239 | categorynode.removeClass(CSS.SECTIONCOLLAPSED); | |
4dab1c39 | 240 | categorynode.setAttribute('aria-expanded', 'true'); |
53c1b936 ARN |
241 | categorychildren.fx.set('reverse', false); |
242 | } else { | |
243 | categorychildren.fx.set('reverse', true); | |
244 | categorychildren.fx.once('end', function(e, categorynode) { | |
245 | categorynode.addClass(CSS.SECTIONCOLLAPSED); | |
4dab1c39 | 246 | categorynode.setAttribute('aria-expanded', 'false'); |
53c1b936 ARN |
247 | }, this, categorynode); |
248 | } | |
249 | ||
250 | categorychildren.fx.once('end', function(e, categorychildren) { | |
251 | // Remove the styles that the animation has set. | |
252 | categorychildren.setStyles({ | |
253 | height: '', | |
254 | opacity: '' | |
255 | }); | |
256 | ||
257 | // To avoid memory gobbling, remove the animation. It will be added back if called again. | |
258 | this.destroy(); | |
259 | self.update_collapsible_actions(ancestor); | |
260 | }, categorychildren.fx, categorychildren); | |
261 | ||
262 | // Now that everything has been set up, run the animation. | |
263 | categorychildren.fx.run(); | |
264 | }; | |
265 | ||
266 | NS.collapse_expand_all = function(e) { | |
267 | // The collapse/expand button has no actual target but we need to prevent it's default | |
268 | // action to ensure we don't make the page reload/jump. | |
269 | e.preventDefault(); | |
270 | ||
271 | if (e.currentTarget.hasClass(CSS.DISABLED)) { | |
272 | // The collapse/expand is currently disabled. | |
273 | return; | |
274 | } | |
275 | ||
276 | var ancestor = e.currentTarget.ancestor(SELECTORS.COURSECATEGORYTREE); | |
277 | if (!ancestor) { | |
278 | return; | |
279 | } | |
280 | ||
281 | var collapseall = ancestor.one(SELECTORS.COLLAPSEEXPAND); | |
282 | if (collapseall.hasClass(CSS.COLLAPSEALL)) { | |
283 | this.collapse_all(ancestor); | |
284 | } else { | |
285 | this.expand_all(ancestor); | |
286 | } | |
287 | this.update_collapsible_actions(ancestor); | |
288 | }; | |
289 | ||
290 | NS.expand_all = function(ancestor) { | |
291 | var finalexpansions = []; | |
292 | ||
293 | ancestor.all(SELECTORS.CATEGORYWITHCOLLAPSEDLOADEDCHILDREN) | |
294 | .each(function(c) { | |
295 | if (c.ancestor(SELECTORS.CATEGORYWITHCOLLAPSEDLOADEDCHILDREN)) { | |
296 | // Expand the hidden children first without animation. | |
297 | c.removeClass(CSS.SECTIONCOLLAPSED); | |
298 | c.all(SELECTORS.LOADEDTREES).removeClass(CSS.SECTIONCOLLAPSED); | |
299 | } else { | |
300 | finalexpansions.push(c); | |
301 | } | |
302 | }, this); | |
303 | ||
304 | // Run the final expansion with animation on the visible items. | |
305 | Y.all(finalexpansions).each(function(c) { | |
306 | this.run_expansion(c); | |
307 | }, this); | |
308 | ||
309 | }; | |
310 | ||
311 | NS.collapse_all = function(ancestor) { | |
312 | var finalcollapses = []; | |
313 | ||
314 | ancestor.all(SELECTORS.CATEGORYWITHMAXIMISEDLOADEDCHILDREN) | |
315 | .each(function(c) { | |
316 | if (c.ancestor(SELECTORS.CATEGORYWITHMAXIMISEDLOADEDCHILDREN)) { | |
317 | finalcollapses.push(c); | |
318 | } else { | |
319 | // Collapse the visible items first | |
320 | this.run_expansion(c); | |
321 | } | |
322 | }, this); | |
323 | ||
324 | // Run the final collapses now that the these are hidden hidden. | |
325 | Y.all(finalcollapses).each(function(c) { | |
326 | c.addClass(CSS.SECTIONCOLLAPSED); | |
327 | c.all(SELECTORS.LOADEDTREES).addClass(CSS.SECTIONCOLLAPSED); | |
328 | }, this); | |
329 | }; | |
330 | ||
331 | NS.update_collapsible_actions = function(ancestor) { | |
332 | var foundmaximisedchildren = false, | |
333 | // Grab the anchor for the collapseexpand all link. | |
334 | togglelink = ancestor.one(SELECTORS.COLLAPSEEXPAND); | |
335 | ||
336 | if (!togglelink) { | |
337 | // We should always have a togglelink but ensure. | |
338 | return; | |
339 | } | |
340 | ||
341 | // Search for any visibly expanded children. | |
342 | ancestor.all(SELECTORS.CATEGORYWITHMAXIMISEDLOADEDCHILDREN).each(function(n) { | |
343 | // If we can find any collapsed ancestors, skip. | |
344 | if (n.ancestor(SELECTORS.CATEGORYWITHCOLLAPSEDLOADEDCHILDREN)) { | |
345 | return false; | |
346 | } | |
347 | foundmaximisedchildren = true; | |
348 | return true; | |
349 | }); | |
350 | ||
351 | if (foundmaximisedchildren) { | |
352 | // At least one maximised child found. Show the collapseall. | |
353 | togglelink.setHTML(M.util.get_string('collapseall', 'moodle')) | |
354 | .addClass(CSS.COLLAPSEALL) | |
355 | .removeClass(CSS.DISABLED); | |
356 | } else { | |
357 | // No maximised children found but there are collapsed children. Show the expandall. | |
358 | togglelink.setHTML(M.util.get_string('expandall', 'moodle')) | |
359 | .removeClass(CSS.COLLAPSEALL) | |
360 | .removeClass(CSS.DISABLED); | |
361 | } | |
362 | }; | |
363 | ||
364 | /** | |
365 | * Process the data returned by Y.io. | |
366 | * This includes appending it to the relevant part of the DOM, and applying our animations. | |
367 | * | |
368 | * @method process_results | |
369 | * @private | |
370 | * @param {String} tid The Transaction ID | |
371 | * @param {Object} response The Reponse returned by Y.IO | |
372 | * @param {Object} ioargs The additional arguments provided by Y.IO | |
373 | */ | |
374 | NS.process_results = function(tid, response, args) { | |
375 | var newnode, | |
376 | data; | |
377 | try { | |
378 | data = Y.JSON.parse(response.responseText); | |
379 | if (data.error) { | |
380 | return new M.core.ajaxException(data); | |
381 | } | |
382 | } catch (e) { | |
383 | return new M.core.exception(e); | |
384 | } | |
385 | ||
386 | // Insert the returned data into a new Node. | |
387 | newnode = Y.Node.create(data); | |
388 | ||
389 | // Append to the existing child location. | |
390 | args.childnode.appendChild(newnode); | |
391 | ||
392 | // Now that we have content, we can swap the classes on the toggled container. | |
393 | args.parentnode | |
394 | .addClass(CSS.LOADED) | |
395 | .removeClass(CSS.NOTLOADED); | |
396 | ||
397 | // Toggle the open/close status of the node now that it's content has been loaded. | |
398 | this.run_expansion(args.parentnode); | |
399 | ||
400 | // Remove the spinner now that we've started to show the content. | |
401 | if (args.spinner) { | |
402 | args.spinner.hide().destroy(); | |
403 | } | |
404 | }; | |
405 | ||
406 | /** | |
407 | * Add our animation to the Node. | |
408 | * | |
409 | * @method add_animation | |
410 | * @private | |
411 | * @param {Node} childnode | |
412 | */ | |
413 | NS.add_animation = function(childnode) { | |
414 | if (typeof childnode.fx !== "undefined") { | |
415 | // The animation has already been plugged to this node. | |
416 | return childnode; | |
417 | } | |
418 | ||
419 | childnode.plug(Y.Plugin.NodeFX, { | |
420 | from: { | |
421 | height: 0, | |
422 | opacity: 0 | |
423 | }, | |
424 | to: { | |
425 | // This sets a dynamic height in case the node content changes. | |
426 | height: function(node) { | |
427 | // Get expanded height (offsetHeight may be zero). | |
428 | return node.get('scrollHeight'); | |
429 | }, | |
430 | opacity: 1 | |
431 | }, | |
432 | duration: 0.2 | |
433 | }); | |
434 | ||
435 | return childnode; | |
436 | }; | |
437 | ||
438 | ||
1574e652 | 439 | }, '@VERSION@', {"requires": ["node", "event-key"]}); |