Merge branch 'MDL-42340-master' of git://github.com/andrewnicols/moodle
[moodle.git] / lib / yui / src / notification / js / dialogue.js
1 /**
2  * The generic dialogue class for use in Moodle.
3  *
4  * @module moodle-core-notification
5  * @submodule moodle-core-notification-dialogue
6  */
8 var DIALOGUE_NAME = 'Moodle dialogue',
9     DIALOGUE,
10     DIALOGUE_FULLSCREEN_CLASS = DIALOGUE_PREFIX + '-fullscreen',
11     DIALOGUE_HIDDEN_CLASS = DIALOGUE_PREFIX + '-hidden',
12     DIALOGUE_SELECTOR =' [role=dialog]',
13     MENUBAR_SELECTOR = '[role=menubar]',
14     CAN_RECEIVE_FOCUS_SELECTOR = 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]';
16 /**
17  * A re-usable dialogue box with Moodle classes applied.
18  *
19  * @param {Object} config Object literal specifying the dialogue configuration properties.
20  * @constructor
21  * @class M.core.dialogue
22  * @extends Y.Panel
23  */
24 DIALOGUE = function(config) {
25     COUNT++;
26     var id = 'moodle-dialogue-'+COUNT;
27     config.notificationBase =
28         Y.Node.create('<div class="'+CSS.BASE+'">')
29               .append(Y.Node.create('<div id="'+id+'" role="dialog" aria-labelledby="'+id+'-header-text" class="'+CSS.WRAP+'"></div>')
30               .append(Y.Node.create('<div id="'+id+'-header-text" class="'+CSS.HEADER+' yui3-widget-hd"></div>'))
31               .append(Y.Node.create('<div class="'+CSS.BODY+' yui3-widget-bd"></div>'))
32               .append(Y.Node.create('<div class="'+CSS.FOOTER+' yui3-widget-ft"></div>')));
33     Y.one(document.body).append(config.notificationBase);
35     if (config.additionalBaseClass) {
36         config.notificationBase.addClass(config.additionalBaseClass);
37     }
39     config.srcNode =    '#'+id;
40     config.width =      config.width || '400px';
41     config.visible =    config.visible || false;
42     config.center =     config.centered && true;
43     config.centered =   false;
44     config.COUNT = COUNT;
46     if (config.width === 'auto') {
47         delete config.width;
48     }
50     // lightbox param to keep the stable versions API.
51     if (config.lightbox !== false) {
52         config.modal = true;
53     }
54     delete config.lightbox;
56     // closeButton param to keep the stable versions API.
57     if (config.closeButton === false) {
58         config.buttons = null;
59     } else {
60         config.buttons = [
61             {
62                 section: Y.WidgetStdMod.HEADER,
63                 classNames: 'closebutton',
64                 action: function () {
65                     this.hide();
66                 }
67             }
68         ];
69     }
70     DIALOGUE.superclass.constructor.apply(this, [config]);
72     if (config.closeButton !== false) {
73         // The buttons constructor does not allow custom attributes
74         this.get('buttons').header[0].setAttribute('title', this.get('closeButtonTitle'));
75     }
76 };
77 Y.extend(DIALOGUE, Y.Panel, {
78     // Window resize event listener.
79     _resizeevent : null,
80     // Orientation change event listener.
81     _orientationevent : null,
83     /**
84      * Initialise the dialogue.
85      *
86      * @method initializer
87      * @return void
88      */
89     initializer : function(config) {
90         var bb;
92         this.render();
93         this.makeResponsive();
94         this.after('visibleChange', this.visibilityChanged, this);
95         if (config.center) {
96             this.centerDialogue();
97         }
98         this.set('COUNT', COUNT);
100         // Workaround upstream YUI bug http://yuilibrary.com/projects/yui3/ticket/2532507
101         // and allow setting of z-index in theme.
102         bb = this.get('boundingBox');
104         if (config.extraClasses) {
105             Y.Array.each(config.extraClasses, bb.addClass, bb);
106         }
107         if (config.visible) {
108             this.applyZIndex();
109         }
110         // We must show - after the dialogue has been positioned,
111         // either by centerDialogue or makeResonsive. This is because the show() will trigger
112         // a focus on the dialogue, which will scroll the page. If the dialogue has not
113         // been positioned it will scroll back to the top of the page.
114         if (config.visible) {
115             this.show();
116             this.keyDelegation();
117         }
118     },
120     /**
121      * Either set the zindex to the supplied value, or set it to one more than the highest existing
122      * dialog in the page.
123      *
124      * @method visibilityChanged
125      * @return void
126      */
127     applyZIndex : function() {
128         var highestzindex = 0,
129             bb = this.get('boundingBox'),
130             zindex = this.get('zIndex');
131         if (zindex) {
132             // The zindex was specified so we should use that.
133             bb.setStyle('zIndex', zindex);
134         } else {
135             // Determine the correct zindex by looking at all existing dialogs and menubars in the page.
136             Y.all(DIALOGUE_SELECTOR+', '+MENUBAR_SELECTOR).each(function (node) {
137                 var zindex = this.findZIndex(node);
138                 if (zindex > highestzindex) {
139                     highestzindex = zindex;
140                 }
141             }, this);
142             // Only set the zindex if we found a wrapper.
143             if (highestzindex > 0) {
144                 bb.setStyle('zIndex', (highestzindex + 1).toString());
145             }
146         }
147     },
149     /**
150      * Finds the zIndex of the given node or its parent.
151      *
152      * @method findZIndex
153      * @param Node node
154      * @returns int Return either the zIndex of 0 if one was not found.
155      */
156     findZIndex : function(node) {
157         // In most cases the zindex is set on the parent of the dialog.
158         var zindex = node.getStyle('zIndex') || node.ancestor().getStyle('zIndex');
159         if (zindex) {
160             return parseInt(zindex, 10);
161         }
162         return 0;
163     },
165     /**
166      * Event listener for the visibility changed event.
167      *
168      * @method visibilityChanged
169      * @return void
170      */
171     visibilityChanged : function(e) {
172         var titlebar, bb;
173         if (e.attrName === 'visible') {
174             this.get('maskNode').addClass(CSS.LIGHTBOX);
175             if (e.prevVal && !e.newVal) {
176                 bb = this.get('boundingBox');
177                 if (this._resizeevent) {
178                     this._resizeevent.detach();
179                     this._resizeevent = null;
180                 }
181                 if (this._orientationevent) {
182                     this._orientationevent.detach();
183                     this._orientationevent = null;
184                 }
185                 bb.detach('key', this.keyDelegation);
186             }
187             if (!e.prevVal && e.newVal) {
188                 // This needs to be done each time the dialog is shown as new dialogs may have been opened.
189                 this.applyZIndex();
190                 // This needs to be done each time the dialog is shown as the window may have been resized.
191                 this.makeResponsive();
192                 if (!this.shouldResizeFullscreen()) {
193                     if (this.get('draggable')) {
194                         titlebar = '#' + this.get('id') + ' .' + CSS.HEADER;
195                         this.plug(Y.Plugin.Drag, {handles : [titlebar]});
196                         Y.one(titlebar).setStyle('cursor', 'move');
197                     }
198                 }
199                 this.keyDelegation();
200             }
201             if (this.get('center') && !e.prevVal && e.newVal) {
202                 this.centerDialogue();
203             }
204         }
205     },
206     /**
207      * If the responsive attribute is set on the dialog, and the window size is
208      * smaller than the responsive width - make the dialog fullscreen.
209      *
210      * @method makeResponsive
211      * @return void
212      */
213     makeResponsive : function() {
214         var bb = this.get('boundingBox'),
215             content;
217         if (this.shouldResizeFullscreen()) {
218             // Make this dialogue fullscreen on a small screen.
219             // Disable the page scrollbars.
221             // Size and position the fullscreen dialog.
223             bb.addClass(DIALOGUE_FULLSCREEN_CLASS);
224             bb.setStyles({'left' : null,
225                           'top' : null,
226                           'width' : null,
227                           'height' : null,
228                           'right' : null,
229                           'bottom' : null});
231             content = Y.one('#' + this.get('id') + ' .' + CSS.BODY);
232         } else {
233             if (this.get('responsive')) {
234                 // We must reset any of the fullscreen changes.
235                 bb.removeClass(DIALOGUE_FULLSCREEN_CLASS)
236                     .setStyles({'width' : this.get('width'),
237                                 'height' : this.get('height')});
238                 content = Y.one('#' + this.get('id') + ' .' + CSS.BODY);
239             }
240         }
241     },
242     /**
243      * Center the dialog on the screen.
244      *
245      * @method centerDialogue
246      * @return void
247      */
248     centerDialogue : function() {
249         var bb = this.get('boundingBox'),
250             hidden = bb.hasClass(DIALOGUE_HIDDEN_CLASS),
251             x,
252             y;
254         // Don't adjust the position if we are in full screen mode.
255         if (this.shouldResizeFullscreen()) {
256             return;
257         }
258         if (hidden) {
259             bb.setStyle('top', '-1000px').removeClass(DIALOGUE_HIDDEN_CLASS);
260         }
261         x = Math.max(Math.round((bb.get('winWidth') - bb.get('offsetWidth'))/2), 15);
262         y = Math.max(Math.round((bb.get('winHeight') - bb.get('offsetHeight'))/2), 15) + Y.one(window).get('scrollTop');
263         bb.setStyles({ 'left' : x, 'top' : y});
265         if (hidden) {
266             bb.addClass(DIALOGUE_HIDDEN_CLASS);
267         }
268     },
269     /**
270      * Return if this dialogue should be fullscreen or not.
271      * Responsive attribute must be true and we should not be in an iframe and the screen width should
272      * be less than the responsive width.
273      *
274      * @method shouldResizeFullscreen
275      * @return Boolean
276      */
277     shouldResizeFullscreen : function() {
278         return (window === window.parent) && this.get('responsive') &&
279                Math.floor(Y.one(document.body).get('winWidth')) < this.get('responsiveWidth');
280     },
282     /**
283      * Override the show method to set keyboard focus on the dialogue.
284      *
285      * @method show
286      * @return void
287      */
288     show : function() {
289         var result = null,
290             header = this.headerNode,
291             content = this.bodyNode;
293         result = DIALOGUE.superclass.show.call(this);
294         if (header && header !== '') {
295             header.focus();
296         } else if (content && content !== '') {
297             content.focus();
298         }
299         return result;
300     },
301     /**
302      * Setup key delegation to keep tabbing within the open dialogue.
303      *
304      * @method keyDelegation
305      */
306     keyDelegation : function() {
307         var bb = this.get('boundingBox');
308         bb.delegate('key', function(e){
309             var target = e.target;
310             var direction = 'forward';
311             if (e.shiftKey) {
312                 direction = 'backward';
313             }
314             if (this.trapFocus(target, direction)) {
315                 e.preventDefault();
316             }
317         }, 'down:9', CAN_RECEIVE_FOCUS_SELECTOR, this);
318     },
319     /**
320      * Trap the tab focus within the open modal.
321      *
322      * @param string target the element target
323      * @param string direction tab key for forward and tab+shift for backward
324      * @returns bool
325      */
326     trapFocus : function(target, direction) {
327         var bb = this.get('boundingBox'),
328             firstitem = bb.one(CAN_RECEIVE_FOCUS_SELECTOR),
329             lastitem = bb.all(CAN_RECEIVE_FOCUS_SELECTOR).pop();
331         if (target === lastitem && direction === 'forward') { // Tab key.
332             return firstitem.focus();
333         } else if (target === firstitem && direction === 'backward') {  // Tab+shift key.
334             return lastitem.focus();
335         }
336     }
337 }, {
338     NAME : DIALOGUE_NAME,
339     CSS_PREFIX : DIALOGUE_PREFIX,
340     ATTRS : {
341         notificationBase : {
343         },
345         /**
346          * Whether to display the dialogue modally and with a
347          * lightbox style.
348          *
349          * @attribute lightbox
350          * @type Boolean
351          * @default true
352          */
353         lightbox : {
354             validator : Y.Lang.isBoolean,
355             value : true
356         },
358         /**
359          * Whether to display a close button on the dialogue.
360          *
361          * Note, we do not recommend hiding the close button as this has
362          * potential accessibility concerns.
363          *
364          * @attribute closeButton
365          * @type Boolean
366          * @default true
367          */
368         closeButton : {
369             validator : Y.Lang.isBoolean,
370             value : true
371         },
373         /**
374          * The title for the close button if one is to be shown.
375          *
376          * @attribute closeButtonTitle
377          * @type String
378          * @default 'Close'
379          */
380         closeButtonTitle : {
381             validator : Y.Lang.isString,
382             value : 'Close'
383         },
385         /**
386          * Whether to display the dialogue centrally on the screen.
387          *
388          * @attribute center
389          * @type Boolean
390          * @default true
391          */
392         center : {
393             validator : Y.Lang.isBoolean,
394             value : true
395         },
397         /**
398          * Whether to make the dialogue movable around the page.
399          *
400          * @attribute draggable
401          * @type Boolean
402          * @default false
403          */
404         draggable : {
405             validator : Y.Lang.isBoolean,
406             value : false
407         },
409         /**
410          * Used to generate a unique id for the dialogue.
411          *
412          * @attribute COUNT
413          * @type Integer
414          * @default 0
415          */
416         COUNT: {
417             value: 0
418         },
420         /**
421          * Used to disable the fullscreen resizing behaviour if required.
422          *
423          * @attribute responsive
424          * @type Boolean
425          * @default true
426          */
427         responsive : {
428             validator : Y.Lang.isBoolean,
429             value : true
430         },
432         /**
433          * The width that this dialogue should be resized to fullscreen.
434          *
435          * @attribute responsiveWidth
436          * @type Integer
437          * @default 768
438          */
439         responsiveWidth : {
440             value : 768
441         }
442     }
443 });
445 M.core.dialogue = DIALOGUE;