Merge branch 'MDL-67818-check-api-fixes' of https://github.com/brendanheywood/moodle
[moodle.git] / lib / yui / src / dragdrop / js / dragdrop.js
1 /* eslint-disable no-empty-function */
2 /**
3  * The core drag and drop module for Moodle which extends the YUI drag and
4  * drop functionality with additional features.
5  *
6  * @module moodle-core-dragdrop
7  */
8 var MOVEICON = {
9     pix: "i/move_2d",
10     largepix: "i/dragdrop",
11     component: 'moodle',
12     cssclass: 'moodle-core-dragdrop-draghandle'
13 };
15 /**
16  * General DRAGDROP class, this should not be used directly,
17  * it is supposed to be extended by your class
18  *
19  * @class M.core.dragdrop
20  * @constructor
21  * @extends Base
22  */
23 var DRAGDROP = function() {
24     DRAGDROP.superclass.constructor.apply(this, arguments);
25 };
27 Y.extend(DRAGDROP, Y.Base, {
28     /**
29      * Whether the item is being moved upwards compared with the last
30      * location.
31      *
32      * @property goingup
33      * @type Boolean
34      * @default null
35      */
36     goingup: null,
38     /**
39      * Whether the item is being moved upwards compared with the start
40      * point.
41      *
42      * @property absgoingup
43      * @type Boolean
44      * @default null
45      */
46     absgoingup: null,
48     /**
49      * The class for the object.
50      *
51      * @property samenodeclass
52      * @type String
53      * @default null
54      */
55     samenodeclass: null,
57     /**
58      * The class on the parent of the item being moved.
59      *
60      * @property parentnodeclass
61      * @type String
62      * @default
63      */
64     parentnodeclass: null,
66     /**
67      * The label to use with keyboard drag/drop to describe items of the same Node.
68      *
69      * @property samenodelabel
70      * @type Object
71      * @default null
72      */
73     samenodelabel: null,
75     /**
76      * The label to use with keyboard drag/drop to describe items of the parent Node.
77      *
78      * @property samenodelabel
79      * @type Object
80      * @default null
81      */
82     parentnodelabel: null,
84     /**
85      * The groups for this instance.
86      *
87      * @property groups
88      * @type Array
89      * @default []
90      */
91     groups: [],
93     /**
94      * The previous drop location.
95      *
96      * @property lastdroptarget
97      * @type Node
98      * @default null
99      */
100     lastdroptarget: null,
102     /**
103      * Should the direction of a keyboard drag and drop item be detected.
104      *
105      * @property detectkeyboarddirection
106      * @type Boolean
107      * @default false
108      */
109     detectkeyboarddirection: false,
111     /**
112      * Listeners.
113      *
114      * @property listeners
115      * @type Array
116      * @default null
117      */
118     listeners: null,
120     /**
121      * The initializer which sets up the move action.
122      *
123      * @method initializer
124      * @protected
125      */
126     initializer: function() {
127         this.listeners = [];
129         // Listen for all drag:start events.
130         this.listeners.push(Y.DD.DDM.on('drag:start', this.global_drag_start, this));
132         // Listen for all drag:end events.
133         this.listeners.push(Y.DD.DDM.on('drag:end', this.global_drag_end, this));
135         // Listen for all drag:drag events.
136         this.listeners.push(Y.DD.DDM.on('drag:drag', this.global_drag_drag, this));
138         // Listen for all drop:over events.
139         this.listeners.push(Y.DD.DDM.on('drop:over', this.global_drop_over, this));
141         // Listen for all drop:hit events.
142         this.listeners.push(Y.DD.DDM.on('drop:hit', this.global_drop_hit, this));
144         // Listen for all drop:miss events.
145         this.listeners.push(Y.DD.DDM.on('drag:dropmiss', this.global_drag_dropmiss, this));
147         // Add keybaord listeners for accessible drag/drop
148         this.listeners.push(Y.one(Y.config.doc.body).delegate('key', this.global_keydown,
149                 'down:32, enter, esc', '.' + MOVEICON.cssclass, this));
151         // Make the accessible drag/drop respond to a single click.
152         this.listeners.push(Y.one(Y.config.doc.body).delegate('click', this.global_keydown,
153                 '.' + MOVEICON.cssclass, this));
154     },
156     /**
157      * The destructor to shut down the instance of the dragdrop system.
158      *
159      * @method destructor
160      * @protected
161      */
162     destructor: function() {
163         new Y.EventHandle(this.listeners).detach();
164     },
166     /**
167      * Build a new drag handle Node.
168      *
169      * @method get_drag_handle
170      * @param {String} title The title on the drag handle
171      * @param {String} classname The name of the class to add to the node
172      * wrapping the drag icon
173      * @param {String} iconclass Additional class to add to the icon.
174      * @return Node The built drag handle.
175      */
176     get_drag_handle: function(title, classname, iconclass) {
178         var dragelement = Y.Node.create('<span></span>')
179             .addClass(classname)
180             .setAttribute('title', title)
181             .setAttribute('tabIndex', 0)
182             .setAttribute('data-draggroups', this.groups)
183             .setAttribute('role', 'button');
184         dragelement.addClass(MOVEICON.cssclass);
186         window.require(['core/templates'], function(Templates) {
187             Templates.renderPix('i/move_2d', 'core').then(function(html) {
188                 var dragicon = Y.Node.create(html);
189                 dragicon.setStyle('cursor', 'move');
190                 if (typeof iconclass != 'undefined') {
191                     dragicon.addClass(iconclass);
192                 }
193                 dragelement.appendChild(dragicon);
194             });
195         });
197         return dragelement;
198     },
200     lock_drag_handle: function(drag, classname) {
201         drag.removeHandle('.' + classname);
202     },
204     unlock_drag_handle: function(drag, classname) {
205         drag.addHandle('.' + classname);
206         drag.get('activeHandle').focus();
207     },
209     ajax_failure: function(response) {
210         var e = {
211             name: response.status + ' ' + response.statusText,
212             message: response.responseText
213         };
214         return new M.core.exception(e);
215     },
217     in_group: function(target) {
218         var ret = false;
219         Y.each(this.groups, function(v) {
220             if (target._groups[v]) {
221                 ret = true;
222             }
223         }, this);
224         return ret;
225     },
226     /*
227         * Drag-dropping related functions
228         */
229     global_drag_start: function(e) {
230         // Get our drag object
231         var drag = e.target;
232         // Check that drag object belongs to correct group
233         if (!this.in_group(drag)) {
234             return;
235         }
236         // Store the nodes current style, so we can restore it later.
237         this.originalstyle = drag.get('node').getAttribute('style');
238         // Set some general styles here
239         drag.get('node').setStyle('opacity', '.25');
240         drag.get('dragNode').setStyles({
241             opacity: '.75',
242             borderColor: drag.get('node').getStyle('borderColor'),
243             backgroundColor: drag.get('node').getStyle('backgroundColor')
244         });
245         drag.get('dragNode').empty();
246         this.drag_start(e);
247     },
249     global_drag_end: function(e) {
250         var drag = e.target;
251         // Check that drag object belongs to correct group
252         if (!this.in_group(drag)) {
253             return;
254         }
255         // Put our general styles back
256         drag.get('node').setAttribute('style', this.originalstyle);
257         this.drag_end(e);
258     },
260     global_drag_drag: function(e) {
261         var drag = e.target,
262             info = e.info;
264         // Check that drag object belongs to correct group
265         if (!this.in_group(drag)) {
266             return;
267         }
269         // Note, we test both < and > situations here. We don't want to
270         // effect a change in direction if the user is only moving side
271         // to side with no Y position change.
273         // Detect changes in the position relative to the start point.
274         if (info.start[1] < info.xy[1]) {
275             // We are going up if our final position is higher than our start position.
276             this.absgoingup = true;
278         } else if (info.start[1] > info.xy[1]) {
279             // Otherwise we're going down.
280             this.absgoingup = false;
281         }
283         // Detect changes in the position relative to the last movement.
284         if (info.delta[1] < 0) {
285             // We are going up if our final position is higher than our start position.
286             this.goingup = true;
288         } else if (info.delta[1] > 0) {
289             // Otherwise we're going down.
290             this.goingup = false;
291         }
293         this.drag_drag(e);
294     },
296     global_drop_over: function(e) {
297         // Check that drop object belong to correct group.
298         if (!e.drop || !e.drop.inGroup(this.groups)) {
299             return;
300         }
302         // Get a reference to our drag and drop nodes.
303         var drag = e.drag.get('node'),
304             drop = e.drop.get('node');
306         // Save last drop target for the case of missed target processing.
307         this.lastdroptarget = e.drop;
309         // Are we dropping within the same parent node?
310         if (drop.hasClass(this.samenodeclass)) {
311             var where;
313             if (this.goingup) {
314                 where = "before";
315             } else {
316                 where = "after";
317             }
319             // Add the node contents so that it's moved, otherwise only the drag handle is moved.
320             drop.insert(drag, where);
321         } else if ((drop.hasClass(this.parentnodeclass) || drop.test('[data-droptarget="1"]')) && !drop.contains(drag)) {
322             // We are dropping on parent node and it is empty
323             if (this.goingup) {
324                 drop.append(drag);
325             } else {
326                 drop.prepend(drag);
327             }
328         }
329         this.drop_over(e);
330     },
332     global_drag_dropmiss: function(e) {
333         // drag:dropmiss does not have e.drag and e.drop properties
334         // we substitute them for the ease of use. For e.drop we use,
335         // this.lastdroptarget (ghost node we use for indicating where to drop)
336         e.drag = e.target;
337         e.drop = this.lastdroptarget;
338         // Check that drag object belongs to correct group
339         if (!this.in_group(e.drag)) {
340             return;
341         }
342         // Check that drop object belong to correct group
343         if (!e.drop || !e.drop.inGroup(this.groups)) {
344             return;
345         }
346         this.drag_dropmiss(e);
347     },
349     global_drop_hit: function(e) {
350         // Check that drop object belong to correct group
351         if (!e.drop || !e.drop.inGroup(this.groups)) {
352             return;
353         }
354         this.drop_hit(e);
355     },
357     /**
358      * This is used to build the text for the heading of the keyboard
359      * drag drop menu and the text for the nodes in the list.
360      * @method find_element_text
361      * @param {Node} n The node to start searching for a valid text node.
362      * @return {string} The text of the first text-like child node of n.
363      */
364     find_element_text: function(n) {
365         var text = '';
367         // Try to resolve using aria-label first.
368         text = n.get('aria-label') || '';
369         if (text.length > 0) {
370             return text;
371         }
373         // Now try to resolve using aria-labelledby.
374         var labelledByNode = n.get('aria-labelledby');
375         if (labelledByNode) {
376             var labelNode = Y.one('#' + labelledByNode);
377             if (labelNode && labelNode.get('text').length > 0) {
378                 return labelNode.get('text');
379             }
380         }
382         // The valid node types to get text from.
383         var nodes = n.all('h2, h3, h4, h5, span:not(.actions):not(.menu-action-text), p, div.no-overflow, div.dimmed_text');
385         nodes.each(function() {
386             if (text === '') {
387                 if (Y.Lang.trim(this.get('text')) !== '') {
388                     text = this.get('text');
389                 }
390             }
391         });
393         if (text !== '') {
394             return text;
395         }
396         return M.util.get_string('emptydragdropregion', 'moodle');
397     },
399     /**
400      * This is used to initiate a keyboard version of a drag and drop.
401      * A dialog will open listing all the valid drop targets that can be selected
402      * using tab, tab, tab, enter.
403      * @method global_start_keyboard_drag
404      * @param {Event} e The keydown / click event on the grab handle.
405      * @param {Node} dragcontainer The resolved draggable node (an ancestor of the drag handle).
406      * @param {Node} draghandle The node that triggered this action.
407      */
408     global_start_keyboard_drag: function(e, draghandle, dragcontainer) {
409         M.core.dragdrop.keydragcontainer = dragcontainer;
410         M.core.dragdrop.keydraghandle = draghandle;
412         // Get the name of the thing to move.
413         var nodetitle = this.find_element_text(dragcontainer);
414         var dialogtitle = M.util.get_string('movecontent', 'moodle', nodetitle);
416         // Build the list of drop targets.
417         var droplist = Y.Node.create('<ul></ul>');
418         droplist.addClass('dragdrop-keyboard-drag');
419         var listitem, listlink, listitemtext;
421         // Search for possible drop targets.
422         var droptargets = Y.all('.' + this.samenodeclass + ', .' + this.parentnodeclass);
424         droptargets.each(function(node) {
425             var validdrop = false;
426             var labelroot = node;
427             var className = node.getAttribute("class").split(' ').join(', .');
429             if (node.drop && node.drop.inGroup(this.groups) && node.drop.get('node') !== dragcontainer &&
430                     !(node.next(className) === dragcontainer && !this.detectkeyboarddirection)) {
431                 // This is a drag and drop target with the same class as the grabbed node.
432                 validdrop = true;
433             } else {
434                 var elementgroups = node.getAttribute('data-draggroups').split(' ');
435                 var i, j;
436                 for (i = 0; i < elementgroups.length; i++) {
437                     for (j = 0; j < this.groups.length; j++) {
438                         if (elementgroups[i] === this.groups[j] && !node.ancestor('.yui3-dd-proxy') && !(node == dragcontainer ||
439                             node.next(className) === dragcontainer || node.get('children').item(0) == dragcontainer)) {
440                                 // This is a parent node of the grabbed node (used for dropping in empty sections).
441                                 validdrop = true;
442                                 // This node will have no text - so we get the first valid text from the parent.
443                                 labelroot = node.get('parentNode');
444                                 break;
445                         }
446                     }
447                     if (validdrop) {
448                         break;
449                     }
450                 }
451             }
453             if (validdrop) {
454                 // It is a valid drop target - create a list item for it.
455                 listitem = Y.Node.create('<li></li>');
456                 listlink = Y.Node.create('<a></a>');
457                 nodetitle = this.find_element_text(labelroot);
459                 if (this.samenodelabel && node.hasClass(this.samenodeclass)) {
460                     listitemtext = M.util.get_string(this.samenodelabel.identifier, this.samenodelabel.component, nodetitle);
461                 } else if (this.parentnodelabel && node.hasClass(this.parentnodeclass)) {
462                     listitemtext = M.util.get_string(this.parentnodelabel.identifier, this.parentnodelabel.component, nodetitle);
463                 } else {
464                     listitemtext = M.util.get_string('tocontent', 'moodle', nodetitle);
465                 }
466                 listlink.setContent(listitemtext);
468                 // Add a data attribute so we can get the real drop target.
469                 listlink.setAttribute('data-drop-target', node.get('id'));
470                 // Allow tabbing to the link.
471                 listlink.setAttribute('tabindex', '0');
473                 // Set the event listeners for enter, space or click.
474                 listlink.on('click', this.global_keyboard_drop, this);
475                 listlink.on('key', this.global_keyboard_drop, 'down:enter,32', this);
477                 // Add to the list or drop targets.
478                 listitem.append(listlink);
479                 droplist.append(listitem);
480             }
481         }, this);
483         // Create the dialog for the interaction.
484         M.core.dragdrop.dropui = new M.core.dialogue({
485             headerContent: dialogtitle,
486             bodyContent: droplist,
487             draggable: true,
488             visible: true,
489             center: true,
490             modal: true
491         });
493         M.core.dragdrop.dropui.after('visibleChange', function(e) {
494             // After the dialogue has been closed, we call the cancel function. This will
495             // ensure that tidying up happens (e.g. focusing on the start Node).
496             if (e.prevVal && !e.newVal) {
497                 this.global_cancel_keyboard_drag();
498             }
499         }, this);
501         // Focus the first drop target.
502         if (droplist.one('a')) {
503             droplist.one('a').focus();
504         }
505     },
507     /**
508      * This is used as a simulated drag/drop event in order to prevent any
509      * subtle bugs from creating a real instance of a drag drop event. This means
510      * there are no state changes in the Y.DD.DDM and any undefined functions
511      * will trigger an obvious and fatal error.
512      * The end result is that we call all our drag/drop handlers but do not bubble the
513      * event to anyone else.
514      *
515      * The functions/properties implemented in the wrapper are:
516      * e.target
517      * e.drag
518      * e.drop
519      * e.drag.get('node')
520      * e.drop.get('node')
521      * e.drag.addHandle()
522      * e.drag.removeHandle()
523      *
524      * @method simulated_drag_drop_event
525      * @param {Node} dragnode The drag container node
526      * @param {Node} dropnode The node to initiate the drop on
527      */
528     simulated_drag_drop_event: function(dragnode, dropnode) {
530         // Subclass for wrapping both drag and drop.
531         var DragDropWrapper = function(node) {
532             this.node = node;
533         };
535         // Method e.drag.get() - get the node.
536         DragDropWrapper.prototype.get = function(param) {
537             if (param === 'node' || param === 'dragNode' || param === 'dropNode') {
538                 return this.node;
539             }
540             if (param === 'activeHandle') {
541                 return this.node.one('.editing_move');
542             }
543             return null;
544         };
546         // Method e.drag.inGroup() - we have already run the group checks before triggering the event.
547         DragDropWrapper.prototype.inGroup = function() {
548             return true;
549         };
551         // Method e.drag.addHandle() - we don't want to run this.
552         DragDropWrapper.prototype.addHandle = function() {};
553         // Method e.drag.removeHandle() - we don't want to run this.
554         DragDropWrapper.prototype.removeHandle = function() {};
556         // Create instances of the DragDropWrapper.
557         this.drop = new DragDropWrapper(dropnode);
558         this.drag = new DragDropWrapper(dragnode);
559         this.target = this.drop;
560     },
562     /**
563      * This is used to complete a keyboard version of a drag and drop.
564      * A drop event will be simulated based on the drag and drop nodes.
565      * @method global_keyboard_drop
566      * @param {Event} e The keydown / click event on the proxy drop node.
567      */
568     global_keyboard_drop: function(e) {
569         // The drag node was saved.
570         var dragcontainer = M.core.dragdrop.keydragcontainer;
571         // The real drop node is stored in an attribute of the proxy.
572         var droptarget = Y.one('#' + e.target.getAttribute('data-drop-target'));
574         // Close the dialog.
575         M.core.dragdrop.dropui.hide();
576         // Cancel the event.
577         e.preventDefault();
578         // Detect the direction of travel.
579         if (this.detectkeyboarddirection && dragcontainer.getY() > droptarget.getY()) {
580             // We can detect the keyboard direction and it is going up.
581             this.absgoingup = true;
582             this.goingup = true;
583         } else {
584             // The default behaviour is to treat everything as moving down.
585             this.absgoingup = false;
586             this.goingup = false;
587         }
588         // Convert to drag drop events.
589         var dragevent = new this.simulated_drag_drop_event(dragcontainer, dragcontainer);
590         var dropevent = new this.simulated_drag_drop_event(dragcontainer, droptarget);
591         // Simulate the full sequence.
592         this.drag_start(dragevent);
593         this.global_drop_over(dropevent);
595         if (droptarget.hasClass(this.parentnodeclass) && droptarget.contains(dragcontainer)) {
596             // Handle the case where an item is dropped into a container (for example an activity into a new section).
597             droptarget.prepend(dragcontainer);
598         }
600         this.global_drop_hit(dropevent);
601     },
603     /**
604      * This is used to cancel a keyboard version of a drag and drop.
605      *
606      * @method global_cancel_keyboard_drag
607      */
608     global_cancel_keyboard_drag: function() {
609         if (M.core.dragdrop.keydragcontainer) {
610             // Focus on the node which was being dragged.
611             M.core.dragdrop.keydraghandle.focus();
612             M.core.dragdrop.keydragcontainer = null;
613         }
614         if (M.core.dragdrop.dropui) {
615             M.core.dragdrop.dropui.destroy();
616         }
617     },
619     /**
620      * Process key events on the drag handles.
621      *
622      * @method global_keydown
623      * @param {EventFacade} e The keydown / click event on the drag handle.
624      */
625     global_keydown: function(e) {
626         var draghandle = e.target.ancestor('.' + MOVEICON.cssclass, true),
627             dragcontainer,
628             draggroups;
630         if (draghandle === null) {
631             // The element clicked did not have a a draghandle in it's lineage.
632             return;
633         }
635         if (e.keyCode === 27) {
636             // Escape to cancel from anywhere.
637             this.global_cancel_keyboard_drag();
638             e.preventDefault();
639             return;
640         }
642         // Only process events on a drag handle.
643         if (!draghandle.hasClass(MOVEICON.cssclass)) {
644             return;
645         }
647         // Do nothing if not space or enter.
648         if (e.keyCode !== 13 && e.keyCode !== 32 && e.type !== 'click') {
649             return;
650         }
652         // Check the drag groups to see if we are the handler for this node.
653         draggroups = draghandle.getAttribute('data-draggroups').split(' ');
654         var i, j;
655         var validgroup = false;
657         for (i = 0; i < draggroups.length; i++) {
658             for (j = 0; j < this.groups.length; j++) {
659                 if (draggroups[i] === this.groups[j]) {
660                     validgroup = true;
661                     break;
662                 }
663             }
664             if (validgroup) {
665                 break;
666             }
667         }
668         if (!validgroup) {
669             return;
670         }
672         // Valid event - start the keyboard drag.
673         dragcontainer = draghandle.ancestor('.yui3-dd-drop');
674         this.global_start_keyboard_drag(e, draghandle, dragcontainer);
676         e.preventDefault();
677     },
680     // Abstract functions definitions.
682     /**
683      * Callback to use when dragging starts.
684      *
685      * @method drag_start
686      * @param {EventFacade} e
687      */
688     drag_start: function() {},
690     /**
691      * Callback to use when dragging ends.
692      *
693      * @method drag_end
694      * @param {EventFacade} e
695      */
696     drag_end: function() {},
698     /**
699      * Callback to use during dragging.
700      *
701      * @method drag_drag
702      * @param {EventFacade} e
703      */
704     drag_drag: function() {},
706     /**
707      * Callback to use when dragging ends and is not over a drop target.
708      *
709      * @method drag_dropmiss
710      * @param {EventFacade} e
711      */
712     drag_dropmiss: function() {},
714     /**
715      * Callback to use when a drop over event occurs.
716      *
717      * @method drop_over
718      * @param {EventFacade} e
719      */
720     drop_over: function() {},
722     /**
723      * Callback to use on drop:hit.
724      *
725      * @method drop_hit
726      * @param {EventFacade} e
727      */
728     drop_hit: function() {}
729 }, {
730     NAME: 'dragdrop',
731     ATTRS: {}
732 });
734 M.core = M.core || {};
735 M.core.dragdrop = DRAGDROP;