Merge "Update .mailmap for the generation of CREDITS"
[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 rAF = window.requestAnimationFrame || setTimeout;
12
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;
31
32 $notification = $( '<div class="mw-notification"></div>' )
33 .data( 'mw.notification', this )
34 .addClass( options.autoHide ? 'mw-notification-autohide' : 'mw-notification-noautohide' );
35
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 }
45
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 }
51
52 if ( options.title ) {
53 $( '<div class="mw-notification-title"></div>' )
54 .text( options.title )
55 .appendTo( $notification );
56 }
57
58 $notificationContent = $( '<div class="mw-notification-content"></div>' );
59
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 }
70
71 $notificationContent.appendTo( $notification );
72
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 }
91
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;
103
104 $area.show();
105
106 if ( this.isOpen ) {
107 return;
108 }
109
110 this.isOpen = true;
111 openNotificationCount++;
112
113 options = this.options;
114 $notification = this.$notification;
115
116 if ( options.tag ) {
117 // Find notifications with the same tag
118 $tagMatches = $area.find( '.mw-notification-tag-' + options.tag );
119 }
120
121 // If we found existing notification with the same tag, replace them
122 if ( options.tag && $tagMatches.length ) {
123
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 } );
141
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 }
154
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 };
163
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;
172
173 if ( this.timeout ) {
174 clearTimeout( this.timeout );
175 delete this.timeout;
176 }
177 };
178
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 };
199
200 /**
201 * Close the notification.
202 */
203 Notification.prototype.close = function () {
204 var notif = this;
205
206 if ( !this.isOpen ) {
207 return;
208 }
209
210 this.isOpen = false;
211 openNotificationCount--;
212
213 // Clear any remaining timeout on close
214 this.pause();
215
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' );
220
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();
224
225 rAF( function () {
226 notif.$notification.removeClass( 'mw-notification-visible' );
227
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 };
242
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 }
260
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;
270
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 } );
289
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();
294
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 }
305
306 $( window ).on( 'scroll', updateAreaMode );
307
308 // Initial mode
309 updateAreaMode();
310 }
311
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 },
329
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 },
344
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 );
356
357 notif = new Notification( message, options );
358
359 if ( isPageReady ) {
360 notif.start();
361 } else {
362 preReadyNotifQueue.push( notif );
363 }
364
365 return notif;
366 },
367
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 },
401
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 };
414
415 $( function () {
416 var notif;
417
418 init();
419
420 // Handle pre-ready queue.
421 isPageReady = true;
422 while ( preReadyNotifQueue.length ) {
423 notif = preReadyNotifQueue.shift();
424 notif.start();
425 }
426 } );
427
428 mw.notification = notification;
429
430 }( mediaWiki, jQuery ) );