Merge "SpecialMovepage: Convert form to use OOUI controls"
[lhc/web/wiklou.git] / resources / src / mediawiki / mediawiki.notification.js
1 ( function ( mw, $ ) {
2 'use strict';
3
4 var notification,
5 // The #mw-notification-area div that all notifications are contained inside.
6 $area,
7 // Number of open notification boxes at any time
8 openNotificationCount = 0,
9 isPageReady = false,
10 preReadyNotifQueue = [];
11
12 /**
13 * A Notification object for 1 message.
14 *
15 * The underscore in the name is to avoid a bug <https://github.com/senchalabs/jsduck/issues/304>.
16 * It is not part of the actual class name.
17 *
18 * @class mw.Notification_
19 * @alternateClassName mw.Notification
20 *
21 * @constructor The constructor is not publicly accessible; use mw.notification#notify instead.
22 * This does not insert anything into the document (see #start).
23 * @private
24 */
25 function Notification( message, options ) {
26 var $notification, $notificationTitle, $notificationContent;
27
28 $notification = $( '<div class="mw-notification"></div>' )
29 .data( 'mw.notification', this )
30 .addClass( options.autoHide ? 'mw-notification-autohide' : 'mw-notification-noautohide' );
31
32 if ( options.tag ) {
33 // Sanitize options.tag before it is used by any code. (Including Notification class methods)
34 options.tag = options.tag.replace( /[ _\-]+/g, '-' ).replace( /[^\-a-z0-9]+/ig, '' );
35 if ( options.tag ) {
36 $notification.addClass( 'mw-notification-tag-' + options.tag );
37 } else {
38 delete options.tag;
39 }
40 }
41
42 if ( options.type ) {
43 // Sanitize options.type
44 options.type = options.type.replace( /[ _\-]+/g, '-' ).replace( /[^\-a-z0-9]+/ig, '' );
45 $notification.addClass( 'mw-notification-type-' + options.type );
46 }
47
48 if ( options.title ) {
49 $notificationTitle = $( '<div class="mw-notification-title"></div>' )
50 .text( options.title )
51 .appendTo( $notification );
52 }
53
54 $notificationContent = $( '<div class="mw-notification-content"></div>' );
55
56 if ( typeof message === 'object' ) {
57 // Handle mw.Message objects separately from DOM nodes and jQuery objects
58 if ( message instanceof mw.Message ) {
59 $notificationContent.html( message.parse() );
60 } else {
61 $notificationContent.append( message );
62 }
63 } else {
64 $notificationContent.text( message );
65 }
66
67 $notificationContent.appendTo( $notification );
68
69 // Private state parameters, meant for internal use only
70 // isOpen: Set to true after .start() is called to avoid double calls.
71 // Set back to false after .close() to avoid duplicating the close animation.
72 // isPaused: false after .resume(), true after .pause(). Avoids duplicating or breaking the hide timeouts.
73 // Set to true initially so .start() can call .resume().
74 // message: The message passed to the notification. Unused now but may be used in the future
75 // to stop replacement of a tagged notification with another notification using the same message.
76 // options: The options passed to the notification with a little sanitization. Used by various methods.
77 // $notification: jQuery object containing the notification DOM node.
78 this.isOpen = false;
79 this.isPaused = true;
80 this.message = message;
81 this.options = options;
82 this.$notification = $notification;
83 }
84
85 /**
86 * Start the notification. Called automatically by mw.notification#notify
87 * (possibly asynchronously on document-ready).
88 *
89 * This inserts the notification into the page, closes any matching tagged notifications,
90 * handles the fadeIn animations and replacement transitions, and starts autoHide timers.
91 *
92 * @private
93 */
94 Notification.prototype.start = function () {
95 var
96 // Local references
97 $notification, options,
98 // Original opacity so that we can animate back to it later
99 opacity,
100 // Other notification elements matching the same tag
101 $tagMatches,
102 outerHeight,
103 placeholderHeight,
104 autohideCount,
105 notif;
106
107 $area.show();
108
109 if ( this.isOpen ) {
110 return;
111 }
112
113 this.isOpen = true;
114 openNotificationCount++;
115
116 options = this.options;
117 $notification = this.$notification;
118
119 opacity = this.$notification.css( 'opacity' );
120
121 // Set the opacity to 0 so we can fade in later.
122 $notification.css( 'opacity', 0 );
123
124 if ( options.tag ) {
125 // Check to see if there are any tagged notifications with the same tag as the new one
126 $tagMatches = $area.find( '.mw-notification-tag-' + options.tag );
127 }
128
129 // If we found a tagged notification use the replacement pattern instead of the new
130 // notification fade-in pattern.
131 if ( options.tag && $tagMatches.length ) {
132
133 // Iterate over the tag matches to find the outerHeight we should use
134 // for the placeholder.
135 outerHeight = 0;
136 $tagMatches.each( function () {
137 var notif = $( this ).data( 'mw.notification' );
138 if ( notif ) {
139 // Use the notification's height + padding + border + margins
140 // as the placeholder height.
141 outerHeight = notif.$notification.outerHeight( true );
142 if ( notif.$replacementPlaceholder ) {
143 // Grab the height of a placeholder that has not finished animating.
144 placeholderHeight = notif.$replacementPlaceholder.height();
145 // Remove any placeholders added by a previous tagged
146 // notification that was in the middle of replacing another.
147 // This also makes sure that we only grab the placeholderHeight
148 // for the most recent notification.
149 notif.$replacementPlaceholder.remove();
150 delete notif.$replacementPlaceholder;
151 }
152 // Close the previous tagged notification
153 // Since we're replacing it do this with a fast speed and don't output a placeholder
154 // since we're taking care of that transition ourselves.
155 notif.close( { speed: 'fast', placeholder: false } );
156 }
157 } );
158 if ( placeholderHeight !== undefined ) {
159 // If the other tagged notification was in the middle of replacing another
160 // tagged notification, continue from the placeholder's height instead of
161 // using the outerHeight of the notification.
162 outerHeight = placeholderHeight;
163 }
164
165 $notification
166 // Insert the new notification before the tagged notification(s)
167 .insertBefore( $tagMatches.first() )
168 .css( {
169 // Use an absolute position so that we can use a placeholder to gracefully push other notifications
170 // into the right spot.
171 position: 'absolute',
172 width: $notification.width()
173 } )
174 // Fade-in the notification
175 .animate( { opacity: opacity },
176 {
177 duration: 'slow',
178 complete: function () {
179 // After we've faded in clear the opacity and let css take over
180 $( this ).css( { opacity: '' } );
181 }
182 } );
183
184 notif = this;
185
186 // Create a clear placeholder we can use to make the notifications around the notification that is being
187 // replaced expand or contract gracefully to fit the height of the new notification.
188 notif.$replacementPlaceholder = $( '<div>' )
189 // Set the height to the space the previous notification or placeholder took
190 .css( 'height', outerHeight )
191 // Make sure that this placeholder is at the very end of this tagged notification group
192 .insertAfter( $tagMatches.eq( -1 ) )
193 // Animate the placeholder height to the space that this new notification will take up
194 .animate( { height: $notification.outerHeight( true ) },
195 {
196 // Do space animations fast
197 speed: 'fast',
198 complete: function () {
199 // Reset the notification position after we've finished the space animation
200 // However do not do it if the placeholder was removed because another tagged
201 // notification went and closed this one.
202 if ( notif.$replacementPlaceholder ) {
203 $notification.css( 'position', '' );
204 }
205 // Finally, remove the placeholder from the DOM
206 $( this ).remove();
207 }
208 } );
209 } else {
210 // Append to the notification area and fade in to the original opacity.
211 $notification
212 .appendTo( $area )
213 .animate( { opacity: opacity },
214 {
215 duration: 'fast',
216 complete: function () {
217 // After we've faded in clear the opacity and let css take over
218 $( this ).css( 'opacity', '' );
219 }
220 }
221 );
222 }
223
224 // By default a notification is paused.
225 // If this notification is within the first {autoHideLimit} notifications then
226 // start the auto-hide timer as soon as it's created.
227 autohideCount = $area.find( '.mw-notification-autohide' ).length;
228 if ( autohideCount <= notification.autoHideLimit ) {
229 this.resume();
230 }
231 };
232
233 /**
234 * Pause any running auto-hide timer for this notification
235 */
236 Notification.prototype.pause = function () {
237 if ( this.isPaused ) {
238 return;
239 }
240 this.isPaused = true;
241
242 if ( this.timeout ) {
243 clearTimeout( this.timeout );
244 delete this.timeout;
245 }
246 };
247
248 /**
249 * Start autoHide timer if not already started.
250 * Does nothing if autoHide is disabled.
251 * Either to resume from pause or to make the first start.
252 */
253 Notification.prototype.resume = function () {
254 var notif = this;
255 if ( !notif.isPaused ) {
256 return;
257 }
258 // Start any autoHide timeouts
259 if ( notif.options.autoHide ) {
260 notif.isPaused = false;
261 notif.timeout = setTimeout( function () {
262 // Already finished, so don't try to re-clear it
263 delete notif.timeout;
264 notif.close();
265 }, notification.autoHideSeconds * 1000 );
266 }
267 };
268
269 /**
270 * Close/hide the notification.
271 *
272 * @param {Object} options An object containing options for the closing of the notification.
273 *
274 * - speed: Use a close speed different than the default 'slow'.
275 * - placeholder: Set to false to disable the placeholder transition.
276 */
277 Notification.prototype.close = function ( options ) {
278 if ( !this.isOpen ) {
279 return;
280 }
281 this.isOpen = false;
282 openNotificationCount--;
283 // Clear any remaining timeout on close
284 this.pause();
285
286 options = $.extend( {
287 speed: 'slow',
288 placeholder: true
289 }, options );
290
291 // Remove the mw-notification-autohide class from the notification to avoid
292 // having a half-closed notification counted as a notification to resume
293 // when handling {autoHideLimit}.
294 this.$notification.removeClass( 'mw-notification-autohide' );
295
296 // Now that a notification is being closed. Start auto-hide timers for any
297 // notification that has now become one of the first {autoHideLimit} notifications.
298 notification.resume();
299
300 this.$notification
301 .css( {
302 // Don't trigger any mouse events while fading out, just in case the cursor
303 // happens to be right above us when we transition upwards.
304 pointerEvents: 'none',
305 // Set an absolute position so we can move upwards in the animation.
306 // Notification replacement doesn't look right unless we use an animation like this.
307 position: 'absolute',
308 // We must fix the width to avoid it shrinking horizontally.
309 width: this.$notification.width()
310 } )
311 // Fix the top/left position to the current computed position from which we
312 // can animate upwards.
313 .css( this.$notification.position() );
314
315 // This needs to be done *after* notification's position has been made absolute.
316 if ( options.placeholder ) {
317 // Insert a placeholder with a height equal to the height of the
318 // notification plus it's vertical margins in place of the notification
319 var $placeholder = $( '<div>' )
320 .css( 'height', this.$notification.outerHeight( true ) )
321 .insertBefore( this.$notification );
322 }
323
324 // Animate opacity and top to create fade upwards animation for notification closing
325 this.$notification
326 .animate( {
327 opacity: 0,
328 top: '-=35'
329 }, {
330 duration: options.speed,
331 complete: function () {
332 // Remove the notification
333 $( this ).remove();
334 // Hide the area manually after closing the last notification, since it has padding,
335 // causing it to obscure whatever is behind it in spite of being invisible (bug 52659).
336 // It's okay to do this before getting rid of the placeholder, as it's invisible as well.
337 if ( openNotificationCount === 0 ) {
338 $area.hide();
339 }
340 if ( options.placeholder ) {
341 // Use a fast slide up animation after closing to make it look like the notifications
342 // below slide up into place when the notification disappears
343 $placeholder.slideUp( 'fast', function () {
344 // Remove the placeholder
345 $( this ).remove();
346 } );
347 }
348 }
349 } );
350 };
351
352 /**
353 * Helper function, take a list of notification divs and call
354 * a function on the Notification instance attached to them.
355 *
356 * @private
357 * @static
358 * @param {jQuery} $notifications A jQuery object containing notification divs
359 * @param {string} fn The name of the function to call on the Notification instance
360 */
361 function callEachNotification( $notifications, fn ) {
362 $notifications.each( function () {
363 var notif = $( this ).data( 'mw.notification' );
364 if ( notif ) {
365 notif[ fn ]();
366 }
367 } );
368 }
369
370 /**
371 * Initialisation.
372 * Must only be called once, and not before the document is ready.
373 *
374 * @ignore
375 */
376 function init() {
377 var offset, $window = $( window );
378
379 $area = $( '<div id="mw-notification-area" class="mw-notification-area mw-notification-area-layout"></div>' )
380 // Pause auto-hide timers when the mouse is in the notification area.
381 .on( {
382 mouseenter: notification.pause,
383 mouseleave: notification.resume
384 } )
385 // When clicking on a notification close it.
386 .on( 'click', '.mw-notification', function () {
387 var notif = $( this ).data( 'mw.notification' );
388 if ( notif ) {
389 notif.close();
390 }
391 } )
392 // Stop click events from <a> tags from propogating to prevent clicking.
393 // on links from hiding a notification.
394 .on( 'click', 'a', function ( e ) {
395 e.stopPropagation();
396 } )
397 .hide();
398
399 // Prepend the notification area to the content area and save it's object.
400 mw.util.$content.prepend( $area );
401 offset = $area.offset();
402
403 function updateAreaMode() {
404 var isFloating = $window.scrollTop() > offset.top;
405 $area
406 .toggleClass( 'mw-notification-area-floating', isFloating )
407 .toggleClass( 'mw-notification-area-layout', !isFloating );
408 }
409
410 $window.on( 'scroll', updateAreaMode );
411
412 // Initial mode
413 updateAreaMode();
414 }
415
416 /**
417 * @class mw.notification
418 * @singleton
419 */
420 notification = {
421 /**
422 * Pause auto-hide timers for all notifications.
423 * Notifications will not auto-hide until resume is called.
424 *
425 * @see mw.Notification#pause
426 */
427 pause: function () {
428 callEachNotification(
429 $area.children( '.mw-notification' ),
430 'pause'
431 );
432 },
433
434 /**
435 * Resume any paused auto-hide timers from the beginning.
436 * Only the first #autoHideLimit timers will be resumed.
437 */
438 resume: function () {
439 callEachNotification(
440 // Only call resume on the first #autoHideLimit notifications.
441 // Exclude noautohide notifications to avoid bugs where #autoHideLimit
442 // `{ autoHide: false }` notifications are at the start preventing any
443 // auto-hide notifications from being autohidden.
444 $area.children( '.mw-notification-autohide' ).slice( 0, notification.autoHideLimit ),
445 'resume'
446 );
447 },
448
449 /**
450 * Display a notification message to the user.
451 *
452 * @param {HTMLElement|HTMLElement[]|jQuery|mw.Message|string} message
453 * @param {Object} options The options to use for the notification.
454 * See #defaults for details.
455 * @return {mw.Notification} Notification object
456 */
457 notify: function ( message, options ) {
458 var notif;
459 options = $.extend( {}, notification.defaults, options );
460
461 notif = new Notification( message, options );
462
463 if ( isPageReady ) {
464 notif.start();
465 } else {
466 preReadyNotifQueue.push( notif );
467 }
468
469 return notif;
470 },
471
472 /**
473 * @property {Object}
474 * The defaults for #notify options parameter.
475 *
476 * - autoHide:
477 * A boolean indicating whether the notifification should automatically
478 * be hidden after shown. Or if it should persist.
479 *
480 * - tag:
481 * An optional string. When a notification is tagged only one message
482 * with that tag will be displayed. Trying to display a new notification
483 * with the same tag as one already being displayed will cause the other
484 * notification to be closed and this new notification to open up inside
485 * the same place as the previous notification.
486 *
487 * - title:
488 * An optional title for the notification. Will be displayed above the
489 * content. Usually in bold.
490 *
491 * - type:
492 * An optional string for the type of the message used for styling:
493 * Examples: 'info', 'warn', 'error'.
494 */
495 defaults: {
496 autoHide: true,
497 tag: false,
498 title: undefined,
499 type: false
500 },
501
502 /**
503 * @property {number}
504 * Number of seconds to wait before auto-hiding notifications.
505 */
506 autoHideSeconds: 5,
507
508 /**
509 * @property {number}
510 * Maximum number of notifications to count down auto-hide timers for.
511 * Only the first #autoHideLimit notifications being displayed will
512 * auto-hide. Any notifications further down in the list will only start
513 * counting down to auto-hide after the first few messages have closed.
514 *
515 * This basically represents the number of notifications the user should
516 * be able to process in #autoHideSeconds time.
517 */
518 autoHideLimit: 3
519 };
520
521 $( function () {
522 var notif;
523
524 init();
525
526 // Handle pre-ready queue.
527 isPageReady = true;
528 while ( preReadyNotifQueue.length ) {
529 notif = preReadyNotifQueue.shift();
530 notif.start();
531 }
532 } );
533
534 mw.notification = notification;
535
536 }( mediaWiki, jQuery ) );