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