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