Merge "Put the HTML attribute whitelist closer to HTML5"
[lhc/web/wiklou.git] / resources / 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 isPageReady = false,
8 preReadyNotifQueue = [];
9
10 /**
11 * Creates a Notification object for 1 message.
12 * Does not insert anything into the document (see #start).
13 *
14 * The "_" in the name is to avoid a bug (http://github.com/senchalabs/jsduck/issues/304)
15 * It is not part of the actual class name.
16 *
17 * @class mw.Notification_
18 * @alternateClassName mw.Notification
19 * @private
20 *
21 * @constructor
22 */
23 function Notification( message, options ) {
24 var $notification, $notificationTitle, $notificationContent;
25
26 $notification = $( '<div class="mw-notification"></div>' )
27 .data( 'mw.notification', this )
28 .addClass( options.autoHide ? 'mw-notification-autohide' : 'mw-notification-noautohide' );
29
30 if ( options.tag ) {
31 // Sanitize options.tag before it is used by any code. (Including Notification class methods)
32 options.tag = options.tag.replace( /[ _\-]+/g, '-' ).replace( /[^\-a-z0-9]+/ig, '' );
33 if ( options.tag ) {
34 $notification.addClass( 'mw-notification-tag-' + options.tag );
35 } else {
36 delete options.tag;
37 }
38 }
39
40 if ( options.title ) {
41 $notificationTitle = $( '<div class="mw-notification-title"></div>' )
42 .text( options.title )
43 .appendTo( $notification );
44 }
45
46 $notificationContent = $( '<div class="mw-notification-content"></div>' );
47
48 if ( typeof message === 'object' ) {
49 // Handle mw.Message objects separately from DOM nodes and jQuery objects
50 if ( message instanceof mw.Message ) {
51 $notificationContent.html( message.parse() );
52 } else {
53 $notificationContent.append( message );
54 }
55 } else {
56 $notificationContent.text( message );
57 }
58
59 $notificationContent.appendTo( $notification );
60
61 // Private state parameters, meant for internal use only
62 // isOpen: Set to true after .start() is called to avoid double calls.
63 // Set back to false after .close() to avoid duplicating the close animation.
64 // isPaused: false after .resume(), true after .pause(). Avoids duplicating or breaking the hide timeouts.
65 // Set to true initially so .start() can call .resume().
66 // message: The message passed to the notification. Unused now but may be used in the future
67 // to stop replacement of a tagged notification with another notification using the same message.
68 // options: The options passed to the notification with a little sanitization. Used by various methods.
69 // $notification: jQuery object containing the notification DOM node.
70 this.isOpen = false;
71 this.isPaused = true;
72 this.message = message;
73 this.options = options;
74 this.$notification = $notification;
75 }
76
77 /**
78 * Start the notification.
79 * This inserts it into the page, closes any matching tagged notifications,
80 * handles the fadeIn animations and repacement transitions, and starts autoHide timers.
81 */
82 Notification.prototype.start = function () {
83 var
84 // Local references
85 $notification, options,
86 // Original opacity so that we can animate back to it later
87 opacity,
88 // Other notification elements matching the same tag
89 $tagMatches,
90 outerHeight,
91 placeholderHeight,
92 autohideCount,
93 notif;
94
95 if ( this.isOpen ) {
96 return;
97 }
98
99 this.isOpen = true;
100
101 options = this.options;
102 $notification = this.$notification;
103
104 opacity = this.$notification.css( 'opacity' );
105
106 // Set the opacity to 0 so we can fade in later.
107 $notification.css( 'opacity', 0 );
108
109 if ( options.tag ) {
110 // Check to see if there are any tagged notifications with the same tag as the new one
111 $tagMatches = $area.find( '.mw-notification-tag-' + options.tag );
112 }
113
114 // If we found a tagged notification use the replacement pattern instead of the new
115 // notification fade-in pattern.
116 if ( options.tag && $tagMatches.length ) {
117
118 // Iterate over the tag matches to find the outerHeight we should use
119 // for the placeholder.
120 outerHeight = 0;
121 $tagMatches.each( function () {
122 var notif = $( this ).data( 'mw.notification' );
123 if ( notif ) {
124 // Use the notification's height + padding + border + margins
125 // as the placeholder height.
126 outerHeight = notif.$notification.outerHeight( true );
127 if ( notif.$replacementPlaceholder ) {
128 // Grab the height of a placeholder that has not finished animating.
129 placeholderHeight = notif.$replacementPlaceholder.height();
130 // Remove any placeholders added by a previous tagged
131 // notification that was in the middle of replacing another.
132 // This also makes sure that we only grab the placeholderHeight
133 // for the most recent notification.
134 notif.$replacementPlaceholder.remove();
135 delete notif.$replacementPlaceholder;
136 }
137 // Close the previous tagged notification
138 // Since we're replacing it do this with a fast speed and don't output a placeholder
139 // since we're taking care of that transition ourselves.
140 notif.close( { speed: 'fast', placeholder: false } );
141 }
142 } );
143 if ( placeholderHeight !== undefined ) {
144 // If the other tagged notification was in the middle of replacing another
145 // tagged notification, continue from the placeholder's height instead of
146 // using the outerHeight of the notification.
147 outerHeight = placeholderHeight;
148 }
149
150 $notification
151 // Insert the new notification before the tagged notification(s)
152 .insertBefore( $tagMatches.first() )
153 .css( {
154 // Use an absolute position so that we can use a placeholder to gracefully push other notifications
155 // into the right spot.
156 position: 'absolute',
157 width: $notification.width()
158 } )
159 // Fade-in the notification
160 .animate( { opacity: opacity },
161 {
162 duration: 'slow',
163 complete: function () {
164 // After we've faded in clear the opacity and let css take over
165 $( this ).css( { opacity: '' } );
166 }
167 } );
168
169 notif = this;
170
171 // Create a clear placeholder we can use to make the notifications around the notification that is being
172 // replaced expand or contract gracefully to fit the height of the new notification.
173 notif.$replacementPlaceholder = $( '<div>' )
174 // Set the height to the space the previous notification or placeholder took
175 .css( 'height', outerHeight )
176 // Make sure that this placeholder is at the very end of this tagged notification group
177 .insertAfter( $tagMatches.eq( -1 ) )
178 // Animate the placeholder height to the space that this new notification will take up
179 .animate( { height: $notification.outerHeight( true ) },
180 {
181 // Do space animations fast
182 speed: 'fast',
183 complete: function () {
184 // Reset the notification position after we've finished the space animation
185 // However do not do it if the placeholder was removed because another tagged
186 // notification went and closed this one.
187 if ( notif.$replacementPlaceholder ) {
188 $notification.css( 'position', '' );
189 }
190 // Finally, remove the placeholder from the DOM
191 $( this ).remove();
192 }
193 } );
194 } else {
195 // Append to the notification area and fade in to the original opacity.
196 $notification
197 .appendTo( $area )
198 .animate( { opacity: opacity },
199 {
200 duration: 'fast',
201 complete: function () {
202 // After we've faded in clear the opacity and let css take over
203 $( this ).css( 'opacity', '' );
204 }
205 }
206 );
207 }
208
209 // By default a notification is paused.
210 // If this notification is within the first {autoHideLimit} notifications then
211 // start the auto-hide timer as soon as it's created.
212 autohideCount = $area.find( '.mw-notification-autohide' ).length;
213 if ( autohideCount <= notification.autoHideLimit ) {
214 this.resume();
215 }
216 };
217
218 /**
219 * Pause any running auto-hide timer for this notification
220 */
221 Notification.prototype.pause = function () {
222 if ( this.isPaused ) {
223 return;
224 }
225 this.isPaused = true;
226
227 if ( this.timeout ) {
228 clearTimeout( this.timeout );
229 delete this.timeout;
230 }
231 };
232
233 /**
234 * Start autoHide timer if not already started.
235 * Does nothing if autoHide is disabled.
236 * Either to resume from pause or to make the first start.
237 */
238 Notification.prototype.resume = function () {
239 var notif = this;
240 if ( !notif.isPaused ) {
241 return;
242 }
243 // Start any autoHide timeouts
244 if ( notif.options.autoHide ) {
245 notif.isPaused = false;
246 notif.timeout = setTimeout( function () {
247 // Already finished, so don't try to re-clear it
248 delete notif.timeout;
249 notif.close();
250 }, notification.autoHideSeconds * 1000 );
251 }
252 };
253
254 /**
255 * Close/hide the notification.
256 *
257 * @param {Object} options An object containing options for the closing of the notification.
258 * These are typically only used internally.
259 *
260 * - speed: Use a close speed different than the default 'slow'.
261 * - placeholder: Set to false to disable the placeholder transition.
262 */
263 Notification.prototype.close = function ( options ) {
264 if ( !this.isOpen ) {
265 return;
266 }
267 this.isOpen = false;
268 // Clear any remaining timeout on close
269 this.pause();
270
271 options = $.extend( {
272 speed: 'slow',
273 placeholder: true
274 }, options );
275
276 // Remove the mw-notification-autohide class from the notification to avoid
277 // having a half-closed notification counted as a notification to resume
278 // when handling {autoHideLimit}.
279 this.$notification.removeClass( 'mw-notification-autohide' );
280
281 // Now that a notification is being closed. Start auto-hide timers for any
282 // notification that has now become one of the first {autoHideLimit} notifications.
283 notification.resume();
284
285 this.$notification
286 .css( {
287 // Don't trigger any mouse events while fading out, just in case the cursor
288 // happens to be right above us when we transition upwards.
289 pointerEvents: 'none',
290 // Set an absolute position so we can move upwards in the animation.
291 // Notification replacement doesn't look right unless we use an animation like this.
292 position: 'absolute',
293 // We must fix the width to avoid it shrinking horizontally.
294 width: this.$notification.width()
295 } )
296 // Fix the top/left position to the current computed position from which we
297 // can animate upwards.
298 .css( this.$notification.position() );
299
300 // This needs to be done *after* notification's position has been made absolute.
301 if ( options.placeholder ) {
302 // Insert a placeholder with a height equal to the height of the
303 // notification plus it's vertical margins in place of the notification
304 var $placeholder = $( '<div>' )
305 .css( 'height', this.$notification.outerHeight( true ) )
306 .insertBefore( this.$notification );
307 }
308
309 // Animate opacity and top to create fade upwards animation for notification closing
310 this.$notification
311 .animate( {
312 opacity: 0,
313 top: '-=35'
314 }, {
315 duration: options.speed,
316 complete: function () {
317 // Remove the notification
318 $( this ).remove();
319 if ( options.placeholder ) {
320 // Use a fast slide up animation after closing to make it look like the notifications
321 // below slide up into place when the notification disappears
322 $placeholder.slideUp( 'fast', function () {
323 // Remove the placeholder
324 $( this ).remove();
325 } );
326 }
327 }
328 } );
329 };
330
331 /**
332 * Helper function, take a list of notification divs and call
333 * a function on the Notification instance attached to them.
334 *
335 * @param {jQuery} $notifications A jQuery object containing notification divs
336 * @param {string} fn The name of the function to call on the Notification instance
337 */
338 function callEachNotification( $notifications, fn ) {
339 $notifications.each( function () {
340 var notif = $( this ).data( 'mw.notification' );
341 if ( notif ) {
342 notif[fn]();
343 }
344 } );
345 }
346
347 /**
348 * Initialisation.
349 * Must only be called once, and not before the document is ready.
350 * @ignore
351 */
352 function init() {
353 var offset, $window = $( window );
354
355 $area = $( '<div id="mw-notification-area" class="mw-notification-area mw-notification-area-layout"></div>' )
356 // Pause auto-hide timers when the mouse is in the notification area.
357 .on( {
358 mouseenter: notification.pause,
359 mouseleave: notification.resume
360 } )
361 // When clicking on a notification close it.
362 .on( 'click', '.mw-notification', function () {
363 var notif = $( this ).data( 'mw.notification' );
364 if ( notif ) {
365 notif.close();
366 }
367 } )
368 // Stop click events from <a> tags from propogating to prevent clicking.
369 // on links from hiding a notification.
370 .on( 'click', 'a', function ( e ) {
371 e.stopPropagation();
372 } );
373
374 // Prepend the notification area to the content area and save it's object.
375 mw.util.$content.prepend( $area );
376 offset = $area.offset();
377
378 function updateAreaMode() {
379 var isFloating = $window.scrollTop() > offset.top;
380 $area
381 .toggleClass( 'mw-notification-area-floating', isFloating )
382 .toggleClass( 'mw-notification-area-layout', !isFloating );
383 }
384
385 $window.on( 'scroll', updateAreaMode );
386
387 // Initial mode
388 updateAreaMode();
389 }
390
391 /**
392 * @class mw.notification
393 * @singleton
394 */
395 notification = {
396 /**
397 * Pause auto-hide timers for all notifications.
398 * Notifications will not auto-hide until resume is called.
399 * @see mw.Notification#pause
400 */
401 pause: function () {
402 callEachNotification(
403 $area.children( '.mw-notification' ),
404 'pause'
405 );
406 },
407
408 /**
409 * Resume any paused auto-hide timers from the beginning.
410 * Only the first #autoHideLimit timers will be resumed.
411 */
412 resume: function () {
413 callEachNotification(
414 // Only call resume on the first #autoHideLimit notifications.
415 // Exclude noautohide notifications to avoid bugs where #autoHideLimit
416 // `{ autoHide: false }` notifications are at the start preventing any
417 // auto-hide notifications from being autohidden.
418 $area.children( '.mw-notification-autohide' ).slice( 0, notification.autoHideLimit ),
419 'resume'
420 );
421 },
422
423 /**
424 * Display a notification message to the user.
425 *
426 * @param {HTMLElement|jQuery|mw.Message|string} message
427 * @param {Object} options The options to use for the notification.
428 * See #defaults for details.
429 * @return {Object} Object with a close function to close the notification
430 */
431 notify: function ( message, options ) {
432 var notif;
433 options = $.extend( {}, notification.defaults, options );
434
435 notif = new Notification( message, options );
436
437 if ( isPageReady ) {
438 notif.start();
439 } else {
440 preReadyNotifQueue.push( notif );
441 }
442 return { close: $.proxy( notif.close, notif ) };
443 },
444
445 /**
446 * @property {Object}
447 * The defaults for #notify options parameter.
448 *
449 * - autoHide:
450 * A boolean indicating whether the notifification should automatically
451 * be hidden after shown. Or if it should persist.
452 *
453 * - tag:
454 * An optional string. When a notification is tagged only one message
455 * with that tag will be displayed. Trying to display a new notification
456 * with the same tag as one already being displayed will cause the other
457 * notification to be closed and this new notification to open up inside
458 * the same place as the previous notification.
459 *
460 * - title:
461 * An optional title for the notification. Will be displayed above the
462 * content. Usually in bold.
463 */
464 defaults: {
465 autoHide: true,
466 tag: false,
467 title: undefined
468 },
469
470 /**
471 * @property {number}
472 * Number of seconds to wait before auto-hiding notifications.
473 */
474 autoHideSeconds: 5,
475
476 /**
477 * @property {number}
478 * Maximum number of notifications to count down auto-hide timers for.
479 * Only the first #autoHideLimit notifications being displayed will
480 * auto-hide. Any notifications further down in the list will only start
481 * counting down to auto-hide after the first few messages have closed.
482 *
483 * This basically represents the number of notifications the user should
484 * be able to process in #autoHideSeconds time.
485 */
486 autoHideLimit: 3
487 };
488
489 $( function () {
490 var notif;
491
492 init();
493
494 // Handle pre-ready queue.
495 isPageReady = true;
496 while ( preReadyNotifQueue.length ) {
497 notif = preReadyNotifQueue.shift();
498 notif.start();
499 }
500 } );
501
502 mw.notification = notification;
503
504 }( mediaWiki, jQuery ) );