MDL-67818 check: Rename renderer to be less generic
[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      * Listeners.
104      *
105      * @property listeners
106      * @type Array
107      * @default null
108      */
109     listeners: null,
111     /**
112      * The initializer which sets up the move action.
113      *
114      * @method initializer
115      * @protected
116      */
117     initializer: function() {
118         this.listeners = [];
120         // Listen for all drag:start events.
121         this.listeners.push(Y.DD.DDM.on('drag:start', this.global_drag_start, this));
123         // Listen for all drag:end events.
124         this.listeners.push(Y.DD.DDM.on('drag:end', this.global_drag_end, this));
126         // Listen for all drag:drag events.
127         this.listeners.push(Y.DD.DDM.on('drag:drag', this.global_drag_drag, this));
129         // Listen for all drop:over events.
130         this.listeners.push(Y.DD.DDM.on('drop:over', this.global_drop_over, this));
132         // Listen for all drop:hit events.
133         this.listeners.push(Y.DD.DDM.on('drop:hit', this.global_drop_hit, this));
135         // Listen for all drop:miss events.
136         this.listeners.push(Y.DD.DDM.on('drag:dropmiss', this.global_drag_dropmiss, this));
138         // Add keybaord listeners for accessible drag/drop
139         this.listeners.push(Y.one(Y.config.doc.body).delegate('key', this.global_keydown,
140                 'down:32, enter, esc', '.' + MOVEICON.cssclass, this));
142         // Make the accessible drag/drop respond to a single click.
143         this.listeners.push(Y.one(Y.config.doc.body).delegate('click', this.global_keydown,
144                 '.' + MOVEICON.cssclass, this));
145     },
147     /**
148      * The destructor to shut down the instance of the dragdrop system.
149      *
150      * @method destructor
151      * @protected
152      */
153     destructor: function() {
154         new Y.EventHandle(this.listeners).detach();
155     },
157     /**
158      * Build a new drag handle Node.
159      *
160      * @method get_drag_handle
161      * @param {String} title The title on the drag handle
162      * @param {String} classname The name of the class to add to the node
163      * wrapping the drag icon
164      * @param {String} iconclass Additional class to add to the icon.
165      * @return Node The built drag handle.
166      */
167     get_drag_handle: function(title, classname, iconclass) {
169         var dragelement = Y.Node.create('<span></span>')
170             .addClass(classname)
171             .setAttribute('title', title)
172             .setAttribute('tabIndex', 0)
173             .setAttribute('data-draggroups', this.groups)
174             .setAttribute('role', 'button');
175         dragelement.addClass(MOVEICON.cssclass);
177         window.require(['core/templates'], function(Templates) {
178             Templates.renderPix('i/move_2d', 'core').then(function(html) {
179                 var dragicon = Y.Node.create(html);
180                 dragicon.setStyle('cursor', 'move');
181                 if (typeof iconclass != 'undefined') {
182                     dragicon.addClass(iconclass);
183                 }
184                 dragelement.appendChild(dragicon);
185             });
186         });
188         return dragelement;
189     },
191     lock_drag_handle: function(drag, classname) {
192         drag.removeHandle('.' + classname);
193     },
195     unlock_drag_handle: function(drag, classname) {
196         drag.addHandle('.' + classname);
197         drag.get('activeHandle').focus();
198     },
200     ajax_failure: function(response) {
201         var e = {
202             name: response.status + ' ' + response.statusText,
203             message: response.responseText
204         };
205         return new M.core.exception(e);
206     },
208     in_group: function(target) {
209         var ret = false;
210         Y.each(this.groups, function(v) {
211             if (target._groups[v]) {
212                 ret = true;
213             }
214         }, this);
215         return ret;
216     },
217     /*
218         * Drag-dropping related functions
219         */
220     global_drag_start: function(e) {
221         // Get our drag object
222         var drag = e.target;
223         // Check that drag object belongs to correct group
224         if (!this.in_group(drag)) {
225             return;
226         }
227         // Store the nodes current style, so we can restore it later.
228         this.originalstyle = drag.get('node').getAttribute('style');
229         // Set some general styles here
230         drag.get('node').setStyle('opacity', '.25');
231         drag.get('dragNode').setStyles({
232             opacity: '.75',
233             borderColor: drag.get('node').getStyle('borderColor'),
234             backgroundColor: drag.get('node').getStyle('backgroundColor')
235         });
236         drag.get('dragNode').empty();
237         this.drag_start(e);
238     },
240     global_drag_end: function(e) {
241         var drag = e.target;
242         // Check that drag object belongs to correct group
243         if (!this.in_group(drag)) {
244             return;
245         }
246         // Put our general styles back
247         drag.get('node').setAttribute('style', this.originalstyle);
248         this.drag_end(e);
249     },
251     global_drag_drag: function(e) {
252         var drag = e.target,
253             info = e.info;
255         // Check that drag object belongs to correct group
256         if (!this.in_group(drag)) {
257             return;
258         }
260         // Note, we test both < and > situations here. We don't want to
261         // effect a change in direction if the user is only moving side
262         // to side with no Y position change.
264         // Detect changes in the position relative to the start point.
265         if (info.start[1] < info.xy[1]) {
266             // We are going up if our final position is higher than our start position.
267             this.absgoingup = true;
269         } else if (info.start[1] > info.xy[1]) {
270             // Otherwise we're going down.
271             this.absgoingup = false;
272         }
274         // Detect changes in the position relative to the last movement.
275         if (info.delta[1] < 0) {
276             // We are going up if our final position is higher than our start position.
277             this.goingup = true;
279         } else if (info.delta[1] > 0) {
280             // Otherwise we're going down.
281             this.goingup = false;
282         }
284         this.drag_drag(e);
285     },
287     global_drop_over: function(e) {
288         // Check that drop object belong to correct group.
289         if (!e.drop || !e.drop.inGroup(this.groups)) {
290             return;
291         }
293         // Get a reference to our drag and drop nodes.
294         var drag = e.drag.get('node'),
295             drop = e.drop.get('node');
297         // Save last drop target for the case of missed target processing.
298         this.lastdroptarget = e.drop;
300         // Are we dropping within the same parent node?
301         if (drop.hasClass(this.samenodeclass)) {
302             var where;
304             if (this.goingup) {
305                 where = "before";
306             } else {
307                 where = "after";
308             }
310             // Add the node contents so that it's moved, otherwise only the drag handle is moved.
311             drop.insert(drag, where);
312         } else if ((drop.hasClass(this.parentnodeclass) || drop.test('[data-droptarget="1"]')) && !drop.contains(drag)) {
313             // We are dropping on parent node and it is empty
314             if (this.goingup) {
315                 drop.append(drag);
316             } else {
317                 drop.prepend(drag);
318             }
319         }
320         this.drop_over(e);
321     },
323     global_drag_dropmiss: function(e) {
324         // drag:dropmiss does not have e.drag and e.drop properties
325         // we substitute them for the ease of use. For e.drop we use,
326         // this.lastdroptarget (ghost node we use for indicating where to drop)
327         e.drag = e.target;
328         e.drop = this.lastdroptarget;
329         // Check that drag object belongs to correct group
330         if (!this.in_group(e.drag)) {
331             return;
332         }
333         // Check that drop object belong to correct group
334         if (!e.drop || !e.drop.inGroup(this.groups)) {
335             return;
336         }
337         this.drag_dropmiss(e);
338     },
340     global_drop_hit: function(e) {
341         // Check that drop object belong to correct group
342         if (!e.drop || !e.drop.inGroup(this.groups)) {
343             return;
344         }
345         this.drop_hit(e);
346     },
348     /**
349      * This is used to build the text for the heading of the keyboard
350      * drag drop menu and the text for the nodes in the list.
351      * @method find_element_text
352      * @param {Node} n The node to start searching for a valid text node.
353      * @return {string} The text of the first text-like child node of n.
354      */
355     find_element_text: function(n) {
356         var text = '';
358         // Try to resolve using aria-label first.
359         text = n.get('aria-label') || '';
360         if (text.length > 0) {
361             return text;
362         }
364         // Now try to resolve using aria-labelledby.
365         var labelledByNode = n.get('aria-labelledby');
366         if (labelledByNode) {
367             var labelNode = Y.one('#' + labelledByNode);
368             if (labelNode && labelNode.get('text').length > 0) {
369                 return labelNode.get('text');
370             }
371         }
373         // The valid node types to get text from.
374         var nodes = n.all('h2, h3, h4, h5, span:not(.actions):not(.menu-action-text), p, div.no-overflow, div.dimmed_text');
376         nodes.each(function() {
377             if (text === '') {
378                 if (Y.Lang.trim(this.get('text')) !== '') {
379                     text = this.get('text');
380                 }
381             }
382         });
384         if (text !== '') {
385             return text;
386         }
387         return M.util.get_string('emptydragdropregion', 'moodle');
388     },
390     /**
391      * This is used to initiate a keyboard version of a drag and drop.
392      * A dialog will open listing all the valid drop targets that can be selected
393      * using tab, tab, tab, enter.
394      * @method global_start_keyboard_drag
395      * @param {Event} e The keydown / click event on the grab handle.
396      * @param {Node} dragcontainer The resolved draggable node (an ancestor of the drag handle).
397      * @param {Node} draghandle The node that triggered this action.
398      */
399     global_start_keyboard_drag: function(e, draghandle, dragcontainer) {
400         M.core.dragdrop.keydragcontainer = dragcontainer;
401         M.core.dragdrop.keydraghandle = draghandle;
403         // Get the name of the thing to move.
404         var nodetitle = this.find_element_text(dragcontainer);
405         var dialogtitle = M.util.get_string('movecontent', 'moodle', nodetitle);
407         // Build the list of drop targets.
408         var droplist = Y.Node.create('<ul></ul>');
409         droplist.addClass('dragdrop-keyboard-drag');
410         var listitem, listlink, listitemtext;
412         // Search for possible drop targets.
413         var droptargets = Y.all('.' + this.samenodeclass + ', .' + this.parentnodeclass);
415         droptargets.each(function(node) {
416             var validdrop = false;
417             var labelroot = node;
418             var className = node.getAttribute("class").split(' ').join(', .');
420             if (node.drop && node.drop.inGroup(this.groups) && node.drop.get('node') !== dragcontainer &&
421                     node.next(className) !== dragcontainer) {
422                 // This is a drag and drop target with the same class as the grabbed node.
423                 validdrop = true;
424             } else {
425                 var elementgroups = node.getAttribute('data-draggroups').split(' ');
426                 var i, j;
427                 for (i = 0; i < elementgroups.length; i++) {
428                     for (j = 0; j < this.groups.length; j++) {
429                         if (elementgroups[i] === this.groups[j] && !(node == dragcontainer ||
430                             node.next(className) === dragcontainer || node.get('children').item(0) == dragcontainer)) {
431                                 // This is a parent node of the grabbed node (used for dropping in empty sections).
432                                 validdrop = true;
433                                 // This node will have no text - so we get the first valid text from the parent.
434                                 labelroot = node.get('parentNode');
435                                 break;
436                         }
437                     }
438                     if (validdrop) {
439                         break;
440                     }
441                 }
442             }
444             if (validdrop) {
445                 // It is a valid drop target - create a list item for it.
446                 listitem = Y.Node.create('<li></li>');
447                 listlink = Y.Node.create('<a></a>');
448                 nodetitle = this.find_element_text(labelroot);
450                 if (this.samenodelabel && node.hasClass(this.samenodeclass)) {
451                     listitemtext = M.util.get_string(this.samenodelabel.identifier, this.samenodelabel.component, nodetitle);
452                 } else if (this.parentnodelabel && node.hasClass(this.parentnodeclass)) {
453                     listitemtext = M.util.get_string(this.parentnodelabel.identifier, this.parentnodelabel.component, nodetitle);
454                 } else {
455                     listitemtext = M.util.get_string('tocontent', 'moodle', nodetitle);
456                 }
457                 listlink.setContent(listitemtext);
459                 // Add a data attribute so we can get the real drop target.
460                 listlink.setAttribute('data-drop-target', node.get('id'));
461                 // Allow tabbing to the link.
462                 listlink.setAttribute('tabindex', '0');
464                 // Set the event listeners for enter, space or click.
465                 listlink.on('click', this.global_keyboard_drop, this);
466                 listlink.on('key', this.global_keyboard_drop, 'down:enter,32', this);
468                 // Add to the list or drop targets.
469                 listitem.append(listlink);
470                 droplist.append(listitem);
471             }
472         }, this);
474         // Create the dialog for the interaction.
475         M.core.dragdrop.dropui = new M.core.dialogue({
476             headerContent: dialogtitle,
477             bodyContent: droplist,
478             draggable: true,
479             visible: true,
480             center: true,
481             modal: true
482         });
484         M.core.dragdrop.dropui.after('visibleChange', function(e) {
485             // After the dialogue has been closed, we call the cancel function. This will
486             // ensure that tidying up happens (e.g. focusing on the start Node).
487             if (e.prevVal && !e.newVal) {
488                 this.global_cancel_keyboard_drag();
489             }
490         }, this);
492         // Focus the first drop target.
493         if (droplist.one('a')) {
494             droplist.one('a').focus();
495         }
496     },
498     /**
499      * This is used as a simulated drag/drop event in order to prevent any
500      * subtle bugs from creating a real instance of a drag drop event. This means
501      * there are no state changes in the Y.DD.DDM and any undefined functions
502      * will trigger an obvious and fatal error.
503      * The end result is that we call all our drag/drop handlers but do not bubble the
504      * event to anyone else.
505      *
506      * The functions/properties implemented in the wrapper are:
507      * e.target
508      * e.drag
509      * e.drop
510      * e.drag.get('node')
511      * e.drop.get('node')
512      * e.drag.addHandle()
513      * e.drag.removeHandle()
514      *
515      * @method simulated_drag_drop_event
516      * @param {Node} dragnode The drag container node
517      * @param {Node} dropnode The node to initiate the drop on
518      */
519     simulated_drag_drop_event: function(dragnode, dropnode) {
521         // Subclass for wrapping both drag and drop.
522         var DragDropWrapper = function(node) {
523             this.node = node;
524         };
526         // Method e.drag.get() - get the node.
527         DragDropWrapper.prototype.get = function(param) {
528             if (param === 'node' || param === 'dragNode' || param === 'dropNode') {
529                 return this.node;
530             }
531             if (param === 'activeHandle') {
532                 return this.node.one('.editing_move');
533             }
534             return null;
535         };
537         // Method e.drag.inGroup() - we have already run the group checks before triggering the event.
538         DragDropWrapper.prototype.inGroup = function() {
539             return true;
540         };
542         // Method e.drag.addHandle() - we don't want to run this.
543         DragDropWrapper.prototype.addHandle = function() {};
544         // Method e.drag.removeHandle() - we don't want to run this.
545         DragDropWrapper.prototype.removeHandle = function() {};
547         // Create instances of the DragDropWrapper.
548         this.drop = new DragDropWrapper(dropnode);
549         this.drag = new DragDropWrapper(dragnode);
550         this.target = this.drop;
551     },
553     /**
554      * This is used to complete a keyboard version of a drag and drop.
555      * A drop event will be simulated based on the drag and drop nodes.
556      * @method global_keyboard_drop
557      * @param {Event} e The keydown / click event on the proxy drop node.
558      */
559     global_keyboard_drop: function(e) {
560         // The drag node was saved.
561         var dragcontainer = M.core.dragdrop.keydragcontainer;
562         // The real drop node is stored in an attribute of the proxy.
563         var droptarget = Y.one('#' + e.target.getAttribute('data-drop-target'));
565         // Close the dialog.
566         M.core.dragdrop.dropui.hide();
567         // Cancel the event.
568         e.preventDefault();
569         // Convert to drag drop events.
570         var dragevent = new this.simulated_drag_drop_event(dragcontainer, dragcontainer);
571         var dropevent = new this.simulated_drag_drop_event(dragcontainer, droptarget);
572         // Simulate the full sequence.
573         this.drag_start(dragevent);
574         this.global_drop_over(dropevent);
576         if (droptarget.hasClass(this.parentnodeclass) && droptarget.contains(dragcontainer)) {
577             // The global_drop_over function does not handle the case where an item was moved up, without the
578             // 'goingup' variable being set, as is the case wih keyboard drag/drop. We must detect this case and
579             // apply it after the drop_over, but before the drop_hit event in order for it to be moved to the
580             // correct location.
581             droptarget.prepend(dragcontainer);
582         }
584         this.global_drop_hit(dropevent);
585     },
587     /**
588      * This is used to cancel a keyboard version of a drag and drop.
589      *
590      * @method global_cancel_keyboard_drag
591      */
592     global_cancel_keyboard_drag: function() {
593         if (M.core.dragdrop.keydragcontainer) {
594             // Focus on the node which was being dragged.
595             M.core.dragdrop.keydraghandle.focus();
596             M.core.dragdrop.keydragcontainer = null;
597         }
598         if (M.core.dragdrop.dropui) {
599             M.core.dragdrop.dropui.destroy();
600         }
601     },
603     /**
604      * Process key events on the drag handles.
605      *
606      * @method global_keydown
607      * @param {EventFacade} e The keydown / click event on the drag handle.
608      */
609     global_keydown: function(e) {
610         var draghandle = e.target.ancestor('.' + MOVEICON.cssclass, true),
611             dragcontainer,
612             draggroups;
614         if (draghandle === null) {
615             // The element clicked did not have a a draghandle in it's lineage.
616             return;
617         }
619         if (e.keyCode === 27) {
620             // Escape to cancel from anywhere.
621             this.global_cancel_keyboard_drag();
622             e.preventDefault();
623             return;
624         }
626         // Only process events on a drag handle.
627         if (!draghandle.hasClass(MOVEICON.cssclass)) {
628             return;
629         }
631         // Do nothing if not space or enter.
632         if (e.keyCode !== 13 && e.keyCode !== 32 && e.type !== 'click') {
633             return;
634         }
636         // Check the drag groups to see if we are the handler for this node.
637         draggroups = draghandle.getAttribute('data-draggroups').split(' ');
638         var i, j;
639         var validgroup = false;
641         for (i = 0; i < draggroups.length; i++) {
642             for (j = 0; j < this.groups.length; j++) {
643                 if (draggroups[i] === this.groups[j]) {
644                     validgroup = true;
645                     break;
646                 }
647             }
648             if (validgroup) {
649                 break;
650             }
651         }
652         if (!validgroup) {
653             return;
654         }
656         // Valid event - start the keyboard drag.
657         dragcontainer = draghandle.ancestor('.yui3-dd-drop');
658         this.global_start_keyboard_drag(e, draghandle, dragcontainer);
660         e.preventDefault();
661     },
664     // Abstract functions definitions.
666     /**
667      * Callback to use when dragging starts.
668      *
669      * @method drag_start
670      * @param {EventFacade} e
671      */
672     drag_start: function() {},
674     /**
675      * Callback to use when dragging ends.
676      *
677      * @method drag_end
678      * @param {EventFacade} e
679      */
680     drag_end: function() {},
682     /**
683      * Callback to use during dragging.
684      *
685      * @method drag_drag
686      * @param {EventFacade} e
687      */
688     drag_drag: function() {},
690     /**
691      * Callback to use when dragging ends and is not over a drop target.
692      *
693      * @method drag_dropmiss
694      * @param {EventFacade} e
695      */
696     drag_dropmiss: function() {},
698     /**
699      * Callback to use when a drop over event occurs.
700      *
701      * @method drop_over
702      * @param {EventFacade} e
703      */
704     drop_over: function() {},
706     /**
707      * Callback to use on drop:hit.
708      *
709      * @method drop_hit
710      * @param {EventFacade} e
711      */
712     drop_hit: function() {}
713 }, {
714     NAME: 'dragdrop',
715     ATTRS: {}
716 });
718 M.core = M.core || {};
719 M.core.dragdrop = DRAGDROP;