a163a3d3289b872f0c3b87213e2cfe6ad3f0b7ab
[lhc/web/wiklou.git] / resources / src / mediawiki.notification / 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: String alias for number of seconds for timeout of 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 // timeout: Holds appropriate methods to set/clear timeouts
84 this.autoHideSeconds = options.autoHideSeconds &&
85 notification.autoHideSeconds[ options.autoHideSeconds ] ||
86 notification.autoHideSeconds.short;
87 this.isOpen = false;
88 this.isPaused = true;
89 this.message = message;
90 this.options = options;
91 this.$notification = $notification;
92 if ( options.visibleTimeout ) {
93 this.timeout = require( 'mediawiki.visibleTimeout' );
94 } else {
95 this.timeout = {
96 set: setTimeout,
97 clear: clearTimeout
98 };
99 }
100 }
101
102 /**
103 * Start the notification. Called automatically by mw.notification#notify
104 * (possibly asynchronously on document-ready).
105 *
106 * This inserts the notification into the page, closes any matching tagged notifications,
107 * handles the fadeIn animations and replacement transitions, and starts autoHide timers.
108 *
109 * @private
110 */
111 Notification.prototype.start = function () {
112 var options, $notification, $tagMatches, autohideCount;
113
114 $area.css( 'display', '' );
115
116 if ( this.isOpen ) {
117 return;
118 }
119
120 this.isOpen = true;
121 openNotificationCount++;
122
123 options = this.options;
124 $notification = this.$notification;
125
126 if ( options.tag ) {
127 // Find notifications with the same tag
128 $tagMatches = $area.find( '.mw-notification-tag-' + options.tag );
129 }
130
131 // If we found existing notification with the same tag, replace them
132 if ( options.tag && $tagMatches.length ) {
133
134 // While there can be only one "open" notif with a given tag, there can be several
135 // matches here because they remain in the DOM until the animation is finished.
136 $tagMatches.each( function () {
137 var notif = $( this ).data( 'mw.notification' );
138 if ( notif && notif.isOpen ) {
139 // Detach from render flow with position absolute so that the new tag can
140 // occupy its space instead.
141 notif.$notification
142 .css( {
143 position: 'absolute',
144 width: notif.$notification.width()
145 } )
146 .css( notif.$notification.position() )
147 .addClass( 'mw-notification-replaced' );
148 notif.close();
149 }
150 } );
151
152 $notification
153 .insertBefore( $tagMatches.first() )
154 .addClass( 'mw-notification-visible' );
155 } else {
156 $area.append( $notification );
157 rAF( function () {
158 // This frame renders the element in the area (invisible)
159 rAF( function () {
160 $notification.addClass( 'mw-notification-visible' );
161 } );
162 } );
163 }
164
165 // By default a notification is paused.
166 // If this notification is within the first {autoHideLimit} notifications then
167 // start the auto-hide timer as soon as it's created.
168 autohideCount = $area.find( '.mw-notification-autohide' ).length;
169 if ( autohideCount <= notification.autoHideLimit ) {
170 this.resume();
171 }
172 };
173
174 /**
175 * Pause any running auto-hide timer for this notification
176 */
177 Notification.prototype.pause = function () {
178 if ( this.isPaused ) {
179 return;
180 }
181 this.isPaused = true;
182
183 if ( this.timeoutId ) {
184 this.timeout.clear( this.timeoutId );
185 delete this.timeoutId;
186 }
187 };
188
189 /**
190 * Start autoHide timer if not already started.
191 * Does nothing if autoHide is disabled.
192 * Either to resume from pause or to make the first start.
193 */
194 Notification.prototype.resume = function () {
195 var notif = this;
196
197 if ( !notif.isPaused ) {
198 return;
199 }
200 // Start any autoHide timeouts
201 if ( notif.options.autoHide ) {
202 notif.isPaused = false;
203 notif.timeoutId = notif.timeout.set( function () {
204 // Already finished, so don't try to re-clear it
205 delete notif.timeoutId;
206 notif.close();
207 }, this.autoHideSeconds * 1000 );
208 }
209 };
210
211 /**
212 * Close the notification.
213 */
214 Notification.prototype.close = function () {
215 var notif = this;
216
217 if ( !this.isOpen ) {
218 return;
219 }
220
221 this.isOpen = false;
222 openNotificationCount--;
223
224 // Clear any remaining timeout on close
225 this.pause();
226
227 // Remove the mw-notification-autohide class from the notification to avoid
228 // having a half-closed notification counted as a notification to resume
229 // when handling {autoHideLimit}.
230 this.$notification.removeClass( 'mw-notification-autohide' );
231
232 // Now that a notification is being closed. Start auto-hide timers for any
233 // notification that has now become one of the first {autoHideLimit} notifications.
234 notification.resume();
235
236 rAF( function () {
237 notif.$notification.removeClass( 'mw-notification-visible' );
238
239 setTimeout( function () {
240 if ( openNotificationCount === 0 ) {
241 // Hide the area after the last notification closes. Otherwise, the padding on
242 // the area can be obscure content, despite the area being empty/invisible (T54659). // FIXME
243 $area.css( 'display', 'none' );
244 notif.$notification.remove();
245 } else {
246 notif.$notification.slideUp( 'fast', function () {
247 $( this ).remove();
248 } );
249 }
250 }, 500 );
251 } );
252 };
253
254 /**
255 * Helper function, take a list of notification divs and call
256 * a function on the Notification instance attached to them.
257 *
258 * @private
259 * @static
260 * @param {jQuery} $notifications A jQuery object containing notification divs
261 * @param {string} fn The name of the function to call on the Notification instance
262 */
263 function callEachNotification( $notifications, fn ) {
264 $notifications.each( function () {
265 var notif = $( this ).data( 'mw.notification' );
266 if ( notif ) {
267 notif[ fn ]();
268 }
269 } );
270 }
271
272 /**
273 * Initialisation.
274 * Must only be called once, and not before the document is ready.
275 *
276 * @ignore
277 */
278 function init() {
279 var offset, notif,
280 isFloating = false;
281
282 function updateAreaMode() {
283 var shouldFloat = window.pageYOffset > offset.top;
284 if ( isFloating === shouldFloat ) {
285 return;
286 }
287 isFloating = shouldFloat;
288 $area
289 .toggleClass( 'mw-notification-area-floating', isFloating )
290 .toggleClass( 'mw-notification-area-layout', !isFloating );
291 }
292
293 // Write to the DOM:
294 // Prepend the notification area to the content area and save its object.
295 // The ID attribute here is deprecated.
296 $area = $( '<div id="mw-notification-area" class="mw-notification-area mw-notification-area-layout"></div>' )
297 // Pause auto-hide timers when the mouse is in the notification area.
298 .on( {
299 mouseenter: notification.pause,
300 mouseleave: notification.resume
301 } )
302 // When clicking on a notification close it.
303 .on( 'click', '.mw-notification', function () {
304 var notif = $( this ).data( 'mw.notification' );
305 if ( notif ) {
306 notif.close();
307 }
308 } )
309 // Stop click events from <a> tags from propogating to prevent clicking.
310 // on links from hiding a notification.
311 .on( 'click', 'a', function ( e ) {
312 e.stopPropagation();
313 } );
314
315 mw.util.$content.prepend( $area );
316
317 // Read from the DOM:
318 // Must be in the next frame to avoid synchronous layout
319 // computation from offset()/getBoundingClientRect().
320 rAF( function () {
321 offset = $area.offset();
322
323 // Initial mode (reads, and then maybe writes)
324 updateAreaMode();
325
326 // Once we have the offset for where it would normally render, set the
327 // initial state of the (currently empty) notification area to be hidden.
328 $area.css( 'display', 'none' );
329
330 $( window ).on( 'scroll', updateAreaMode );
331
332 // Handle pre-ready queue.
333 isPageReady = true;
334 while ( preReadyNotifQueue.length ) {
335 notif = preReadyNotifQueue.shift();
336 notif.start();
337 }
338 } );
339 }
340
341 /**
342 * @class mw.notification
343 * @singleton
344 */
345 notification = {
346 /**
347 * Pause auto-hide timers for all notifications.
348 * Notifications will not auto-hide until resume is called.
349 *
350 * @see mw.Notification#pause
351 */
352 pause: function () {
353 callEachNotification(
354 $area.children( '.mw-notification' ),
355 'pause'
356 );
357 },
358
359 /**
360 * Resume any paused auto-hide timers from the beginning.
361 * Only the first #autoHideLimit timers will be resumed.
362 */
363 resume: function () {
364 callEachNotification(
365 // Only call resume on the first #autoHideLimit notifications.
366 // Exclude noautohide notifications to avoid bugs where #autoHideLimit
367 // `{ autoHide: false }` notifications are at the start preventing any
368 // auto-hide notifications from being autohidden.
369 $area.children( '.mw-notification-autohide' ).slice( 0, notification.autoHideLimit ),
370 'resume'
371 );
372 },
373
374 /**
375 * Display a notification message to the user.
376 *
377 * @param {HTMLElement|HTMLElement[]|jQuery|mw.Message|string} message
378 * @param {Object} options The options to use for the notification.
379 * See #defaults for details.
380 * @return {mw.Notification} Notification object
381 */
382 notify: function ( message, options ) {
383 var notif;
384 options = $.extend( {}, notification.defaults, options );
385
386 notif = new Notification( message, options );
387
388 if ( isPageReady ) {
389 notif.start();
390 } else {
391 preReadyNotifQueue.push( notif );
392 }
393
394 return notif;
395 },
396
397 /**
398 * @property {Object}
399 * The defaults for #notify options parameter.
400 *
401 * - autoHide:
402 * A boolean indicating whether the notifification should automatically
403 * be hidden after shown. Or if it should persist.
404 *
405 * - autoHideSeconds:
406 * Key to #autoHideSeconds for number of seconds for timeout of auto-hide
407 * notifications.
408 *
409 * - tag:
410 * An optional string. When a notification is tagged only one message
411 * with that tag will be displayed. Trying to display a new notification
412 * with the same tag as one already being displayed will cause the other
413 * notification to be closed and this new notification to open up inside
414 * the same place as the previous notification.
415 *
416 * - title:
417 * An optional title for the notification. Will be displayed above the
418 * content. Usually in bold.
419 *
420 * - type:
421 * An optional string for the type of the message used for styling:
422 * Examples: 'info', 'warn', 'error'.
423 *
424 * - visibleTimeout:
425 * A boolean indicating if the autoHide timeout should be based on
426 * time the page was visible to user. Or if it should use wall clock time.
427 */
428 defaults: {
429 autoHide: true,
430 autoHideSeconds: 'short',
431 tag: null,
432 title: null,
433 type: null,
434 visibleTimeout: true
435 },
436
437 /**
438 * @private
439 * @property {Object}
440 */
441 autoHideSeconds: {
442 'short': 5,
443 'long': 30
444 },
445
446 /**
447 * @property {number}
448 * Maximum number of simultaneous notifications to start auto-hide timers for.
449 * Only this number of notifications being displayed will be auto-hidden at one time.
450 * Any additional notifications in the list will only start counting their timeout for
451 * auto-hiding after the previous messages have been closed.
452 *
453 * This basically represents the minimal number of notifications the user should
454 * be able to process during the {@link #defaults default} #autoHideSeconds time.
455 */
456 autoHideLimit: 3
457 };
458
459 $( init );
460
461 mw.notification = notification;
462
463 }( mediaWiki, jQuery ) );