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