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