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