Merge "Update docs/hooks.txt for ShowSearchHitTitle"
[mediawiki.git] / resources / src / mediawiki / mediawiki.notification.js
blob7a3fb0a09e72f8c54f3f96d3dc926704574b49be
1 ( function ( mw, $ ) {
2         'use strict';
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                 rAF = window.requestAnimationFrame || setTimeout;
13         /**
14          * A Notification object for 1 message.
15          *
16          * The underscore in the name is to avoid a bug <https://github.com/senchalabs/jsduck/issues/304>.
17          * It is not part of the actual class name.
18          *
19          * The constructor is not publicly accessible; use mw.notification#notify instead.
20          * This does not insert anything into the document (see #start).
21          *
22          * @class mw.Notification_
23          * @alternateClassName mw.Notification
24          * @constructor
25          * @private
26          * @param {mw.Message|jQuery|HTMLElement|string} message
27          * @param {Object} options
28          */
29         function Notification( message, options ) {
30                 var $notification, $notificationContent;
32                 $notification = $( '<div class="mw-notification"></div>' )
33                         .data( 'mw.notification', this )
34                         .addClass( options.autoHide ? 'mw-notification-autohide' : 'mw-notification-noautohide' );
36                 if ( options.tag ) {
37                         // Sanitize options.tag before it is used by any code. (Including Notification class methods)
38                         options.tag = options.tag.replace( /[ _\-]+/g, '-' ).replace( /[^\-a-z0-9]+/ig, '' );
39                         if ( options.tag ) {
40                                 $notification.addClass( 'mw-notification-tag-' + options.tag );
41                         } else {
42                                 delete options.tag;
43                         }
44                 }
46                 if ( options.type ) {
47                         // Sanitize options.type
48                         options.type = options.type.replace( /[ _\-]+/g, '-' ).replace( /[^\-a-z0-9]+/ig, '' );
49                         $notification.addClass( 'mw-notification-type-' + options.type );
50                 }
52                 if ( options.title ) {
53                         $( '<div class="mw-notification-title"></div>' )
54                                 .text( options.title )
55                                 .appendTo( $notification );
56                 }
58                 $notificationContent = $( '<div class="mw-notification-content"></div>' );
60                 if ( typeof message === 'object' ) {
61                         // Handle mw.Message objects separately from DOM nodes and jQuery objects
62                         if ( message instanceof mw.Message ) {
63                                 $notificationContent.html( message.parse() );
64                         } else {
65                                 $notificationContent.append( message );
66                         }
67                 } else {
68                         $notificationContent.text( message );
69                 }
71                 $notificationContent.appendTo( $notification );
73                 // Private state parameters, meant for internal use only
74                 // autoHideSeconds: Number of seconds to wait before auto-hiding notifications.
75                 // isOpen: Set to true after .start() is called to avoid double calls.
76                 //         Set back to false after .close() to avoid duplicating the close animation.
77                 // isPaused: false after .resume(), true after .pause(). Avoids duplicating or breaking the hide timeouts.
78                 //           Set to true initially so .start() can call .resume().
79                 // message: The message passed to the notification. Unused now but may be used in the future
80                 //          to stop replacement of a tagged notification with another notification using the same message.
81                 // options: The options passed to the notification with a little sanitization. Used by various methods.
82                 // $notification: jQuery object containing the notification DOM node.
83                 // Set hide delay
84                 this.autoHideSeconds = options.autoHideSeconds;
85                 this.isOpen = false;
86                 this.isPaused = true;
87                 this.message = message;
88                 this.options = options;
89                 this.$notification = $notification;
90         }
92         /**
93          * Start the notification. Called automatically by mw.notification#notify
94          * (possibly asynchronously on document-ready).
95          *
96          * This inserts the notification into the page, closes any matching tagged notifications,
97          * handles the fadeIn animations and replacement transitions, and starts autoHide timers.
98          *
99          * @private
100          */
101         Notification.prototype.start = function () {
102                 var options, $notification, $tagMatches, autohideCount;
104                 $area.show();
106                 if ( this.isOpen ) {
107                         return;
108                 }
110                 this.isOpen = true;
111                 openNotificationCount++;
113                 options = this.options;
114                 $notification = this.$notification;
116                 if ( options.tag ) {
117                         // Find notifications with the same tag
118                         $tagMatches = $area.find( '.mw-notification-tag-' + options.tag );
119                 }
121                 // If we found existing notification with the same tag, replace them
122                 if ( options.tag && $tagMatches.length ) {
124                         // While there can be only one "open" notif with a given tag, there can be several
125                         // matches here because they remain in the DOM until the animation is finished.
126                         $tagMatches.each( function () {
127                                 var notif = $( this ).data( 'mw.notification' );
128                                 if ( notif && notif.isOpen ) {
129                                         // Detach from render flow with position absolute so that the new tag can
130                                         // occupy its space instead.
131                                         notif.$notification
132                                                 .css( {
133                                                         position: 'absolute',
134                                                         width: notif.$notification.width()
135                                                 } )
136                                                 .css( notif.$notification.position() )
137                                                 .addClass( 'mw-notification-replaced' );
138                                         notif.close();
139                                 }
140                         } );
142                         $notification
143                                 .insertBefore( $tagMatches.first() )
144                                 .addClass( 'mw-notification-visible' );
145                 } else {
146                         $area.append( $notification );
147                         rAF( function () {
148                                 // This frame renders the element in the area (invisible)
149                                 rAF( function () {
150                                         $notification.addClass( 'mw-notification-visible' );
151                                 } );
152                         } );
153                 }
155                 // By default a notification is paused.
156                 // If this notification is within the first {autoHideLimit} notifications then
157                 // start the auto-hide timer as soon as it's created.
158                 autohideCount = $area.find( '.mw-notification-autohide' ).length;
159                 if ( autohideCount <= notification.autoHideLimit ) {
160                         this.resume();
161                 }
162         };
164         /**
165          * Pause any running auto-hide timer for this notification
166          */
167         Notification.prototype.pause = function () {
168                 if ( this.isPaused ) {
169                         return;
170                 }
171                 this.isPaused = true;
173                 if ( this.timeout ) {
174                         clearTimeout( this.timeout );
175                         delete this.timeout;
176                 }
177         };
179         /**
180          * Start autoHide timer if not already started.
181          * Does nothing if autoHide is disabled.
182          * Either to resume from pause or to make the first start.
183          */
184         Notification.prototype.resume = function () {
185                 var notif = this;
186                 if ( !notif.isPaused ) {
187                         return;
188                 }
189                 // Start any autoHide timeouts
190                 if ( notif.options.autoHide ) {
191                         notif.isPaused = false;
192                         notif.timeout = setTimeout( function () {
193                                 // Already finished, so don't try to re-clear it
194                                 delete notif.timeout;
195                                 notif.close();
196                         }, this.autoHideSeconds * 1000 );
197                 }
198         };
200         /**
201          * Close the notification.
202          */
203         Notification.prototype.close = function () {
204                 var notif = this;
206                 if ( !this.isOpen ) {
207                         return;
208                 }
210                 this.isOpen = false;
211                 openNotificationCount--;
213                 // Clear any remaining timeout on close
214                 this.pause();
216                 // Remove the mw-notification-autohide class from the notification to avoid
217                 // having a half-closed notification counted as a notification to resume
218                 // when handling {autoHideLimit}.
219                 this.$notification.removeClass( 'mw-notification-autohide' );
221                 // Now that a notification is being closed. Start auto-hide timers for any
222                 // notification that has now become one of the first {autoHideLimit} notifications.
223                 notification.resume();
225                 rAF( function () {
226                         notif.$notification.removeClass( 'mw-notification-visible' );
228                         setTimeout( function () {
229                                 if ( openNotificationCount === 0 ) {
230                                         // Hide the area after the last notification closes. Otherwise, the padding on
231                                         // the area can be obscure content, despite the area being empty/invisible (T54659). // FIXME
232                                         $area.hide();
233                                         notif.$notification.remove();
234                                 } else {
235                                         notif.$notification.slideUp( 'fast', function () {
236                                                 $( this ).remove();
237                                         } );
238                                 }
239                         }, 500 );
240                 } );
241         };
243         /**
244          * Helper function, take a list of notification divs and call
245          * a function on the Notification instance attached to them.
246          *
247          * @private
248          * @static
249          * @param {jQuery} $notifications A jQuery object containing notification divs
250          * @param {string} fn The name of the function to call on the Notification instance
251          */
252         function callEachNotification( $notifications, fn ) {
253                 $notifications.each( function () {
254                         var notif = $( this ).data( 'mw.notification' );
255                         if ( notif ) {
256                                 notif[ fn ]();
257                         }
258                 } );
259         }
261         /**
262          * Initialisation.
263          * Must only be called once, and not before the document is ready.
264          *
265          * @ignore
266          */
267         function init() {
268                 var offset,
269                         isFloating = false;
271                 $area = $( '<div id="mw-notification-area" class="mw-notification-area mw-notification-area-layout"></div>' )
272                         // Pause auto-hide timers when the mouse is in the notification area.
273                         .on( {
274                                 mouseenter: notification.pause,
275                                 mouseleave: notification.resume
276                         } )
277                         // When clicking on a notification close it.
278                         .on( 'click', '.mw-notification', function () {
279                                 var notif = $( this ).data( 'mw.notification' );
280                                 if ( notif ) {
281                                         notif.close();
282                                 }
283                         } )
284                         // Stop click events from <a> tags from propogating to prevent clicking.
285                         // on links from hiding a notification.
286                         .on( 'click', 'a', function ( e ) {
287                                 e.stopPropagation();
288                         } );
290                 // Prepend the notification area to the content area and save it's object.
291                 mw.util.$content.prepend( $area );
292                 offset = $area.offset();
293                 $area.hide();
295                 function updateAreaMode() {
296                         var shouldFloat = window.pageYOffset > offset.top;
297                         if ( isFloating === shouldFloat ) {
298                                 return;
299                         }
300                         isFloating = shouldFloat;
301                         $area
302                                 .toggleClass( 'mw-notification-area-floating', isFloating )
303                                 .toggleClass( 'mw-notification-area-layout', !isFloating );
304                 }
306                 $( window ).on( 'scroll', updateAreaMode );
308                 // Initial mode
309                 updateAreaMode();
310         }
312         /**
313          * @class mw.notification
314          * @singleton
315          */
316         notification = {
317                 /**
318                  * Pause auto-hide timers for all notifications.
319                  * Notifications will not auto-hide until resume is called.
320                  *
321                  * @see mw.Notification#pause
322                  */
323                 pause: function () {
324                         callEachNotification(
325                                 $area.children( '.mw-notification' ),
326                                 'pause'
327                         );
328                 },
330                 /**
331                  * Resume any paused auto-hide timers from the beginning.
332                  * Only the first #autoHideLimit timers will be resumed.
333                  */
334                 resume: function () {
335                         callEachNotification(
336                                 // Only call resume on the first #autoHideLimit notifications.
337                                 // Exclude noautohide notifications to avoid bugs where #autoHideLimit
338                                 // `{ autoHide: false }` notifications are at the start preventing any
339                                 // auto-hide notifications from being autohidden.
340                                 $area.children( '.mw-notification-autohide' ).slice( 0, notification.autoHideLimit ),
341                                 'resume'
342                         );
343                 },
345                 /**
346                  * Display a notification message to the user.
347                  *
348                  * @param {HTMLElement|HTMLElement[]|jQuery|mw.Message|string} message
349                  * @param {Object} options The options to use for the notification.
350                  *  See #defaults for details.
351                  * @return {mw.Notification} Notification object
352                  */
353                 notify: function ( message, options ) {
354                         var notif;
355                         options = $.extend( {}, notification.defaults, options );
357                         notif = new Notification( message, options );
359                         if ( isPageReady ) {
360                                 notif.start();
361                         } else {
362                                 preReadyNotifQueue.push( notif );
363                         }
365                         return notif;
366                 },
368                 /**
369                  * @property {Object}
370                  * The defaults for #notify options parameter.
371                  *
372                  * - autoHide:
373                  *   A boolean indicating whether the notifification should automatically
374                  *   be hidden after shown. Or if it should persist.
375                  *
376                  * - autoHideSeconds:
377                  *   Number of seconds to wait before auto-hiding notifications.
378                  *
379                  * - tag:
380                  *   An optional string. When a notification is tagged only one message
381                  *   with that tag will be displayed. Trying to display a new notification
382                  *   with the same tag as one already being displayed will cause the other
383                  *   notification to be closed and this new notification to open up inside
384                  *   the same place as the previous notification.
385                  *
386                  * - title:
387                  *   An optional title for the notification. Will be displayed above the
388                  *   content. Usually in bold.
389                  *
390                  * - type:
391                  *   An optional string for the type of the message used for styling:
392                  *   Examples: 'info', 'warn', 'error'.
393                  */
394                 defaults: {
395                         autoHide: true,
396                         autoHideSeconds: 5,
397                         tag: false,
398                         title: undefined,
399                         type: false
400                 },
402                 /**
403                  * @property {number}
404                  * Maximum number of notifications to count down auto-hide timers for.
405                  * Only the first #autoHideLimit notifications being displayed will
406                  * auto-hide. Any notifications further down in the list will only start
407                  * counting down to auto-hide after the first few messages have closed.
408                  *
409                  * This basically represents the number of notifications the user should
410                  * be able to process in #autoHideSeconds time.
411                  */
412                 autoHideLimit: 3
413         };
415         $( function () {
416                 var notif;
418                 init();
420                 // Handle pre-ready queue.
421                 isPageReady = true;
422                 while ( preReadyNotifQueue.length ) {
423                         notif = preReadyNotifQueue.shift();
424                         notif.start();
425                 }
426         } );
428         mw.notification = notification;
430 }( mediaWiki, jQuery ) );