Merge "jquery.tablesorter: Silence an expected "sort-rowspan-error" warning"
[mediawiki.git] / resources / src / mediawiki.notification / notification.js
blob67d483b795fbe7028339473d1ed731a737dca9a6
1 ( function () {
2 'use strict';
4 let notification = null,
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 const preReadyNotifQueue = [];
12 /**
13 * @classdesc Describes a notification. See [mw.notification module]{@link mw.notification}. A Notification object for 1 message.
15 * The constructor is not publicly accessible; use [mw.notification.notify]{@link mw.notification} instead.
16 * This does not insert anything into the document. To add to document use
17 * [mw.notification.notify]{@link mw.notification#notify}.
19 * @class Notification
20 * @global
21 * @hideconstructor
22 * @param {mw.Message|jQuery|HTMLElement|string} message
23 * @param {mw.notification.NotificationOptions} options
25 function Notification( message, options ) {
27 const $notification = $( '<div>' )
28 .data( 'mw-notification', this )
29 .attr( 'role', 'status' )
30 .addClass( [
31 'mw-notification',
32 options.autoHide ? 'mw-notification-autohide' : 'mw-notification-noautohide'
33 ] );
35 if ( options.tag ) {
36 // Sanitize options.tag before it is used by any code. (Including Notification class methods)
37 options.tag = options.tag.replace( /[ _-]+/g, '-' ).replace( /[^-a-z0-9]+/ig, '' );
38 if ( options.tag ) {
39 // eslint-disable-next-line mediawiki/class-doc
40 $notification.addClass( 'mw-notification-tag-' + options.tag );
41 } else {
42 delete options.tag;
46 if ( options.type ) {
47 // Sanitize options.type
48 options.type = options.type.replace( /[ _-]+/g, '-' ).replace( /[^-a-z0-9]+/ig, '' );
49 // The following classes are used here:
50 // * mw-notification-type-error
51 // * mw-notification-type-warn
52 $notification.addClass( 'mw-notification-type-' + options.type );
55 if ( options.title ) {
56 $( '<div>' )
57 .addClass( 'mw-notification-title' )
58 .text( options.title )
59 .appendTo( $notification );
62 if ( options.id ) {
63 $notification.attr( 'id', options.id );
66 if ( options.classes ) {
67 // eslint-disable-next-line mediawiki/class-doc
68 $notification.addClass( options.classes );
71 const $notificationContent = $( '<div>' ).addClass( 'mw-notification-content' );
73 if ( typeof message === 'object' ) {
74 // Handle mw.Message objects separately from DOM nodes and jQuery objects
75 if ( message instanceof mw.Message ) {
76 $notificationContent.html( message.parse() );
77 } else {
78 $notificationContent.append( message );
80 } else {
81 $notificationContent.text( message );
84 $notificationContent.appendTo( $notification );
86 // Private state parameters, meant for internal use only
87 // autoHideSeconds: String alias for number of seconds for timeout of auto-hiding notifications.
88 // isOpen: Set to true after .start() is called to avoid double calls.
89 // Set back to false after .close() to avoid duplicating the close animation.
90 // isPaused: false after .resume(), true after .pause(). Avoids duplicating or breaking the hide timeouts.
91 // Set to true initially so .start() can call .resume().
92 // message: The message passed to the notification. Unused now but may be used in the future
93 // to stop replacement of a tagged notification with another notification using the same message.
94 // options: The options passed to the notification with a little sanitization. Used by various methods.
95 // $notification: jQuery object containing the notification DOM node.
96 // timeout: Holds appropriate methods to set/clear timeouts
97 this.autoHideSeconds = options.autoHideSeconds &&
98 notification.autoHideSeconds[ options.autoHideSeconds ] ||
99 notification.autoHideSeconds.short;
100 this.isOpen = false;
101 this.isPaused = true;
102 this.message = message;
103 this.options = options;
104 this.$notification = $notification;
105 if ( options.visibleTimeout ) {
106 this.timeout = require( 'mediawiki.visibleTimeout' );
107 } else {
108 this.timeout = {
109 set: setTimeout,
110 clear: clearTimeout
116 * Start the notification. Called automatically by mw.notification#notify
117 * (possibly asynchronously on document-ready).
119 * This inserts the notification into the page, closes any matching tagged notifications,
120 * handles the fadeIn animations and replacement transitions, and starts autoHide timers.
122 * @private
124 Notification.prototype.start = function () {
125 $area.css( 'display', '' );
127 if ( this.isOpen ) {
128 return;
131 this.isOpen = true;
132 openNotificationCount++;
134 const options = this.options;
135 const $notification = this.$notification;
137 let $tagMatches;
138 if ( options.tag ) {
139 // Find notifications with the same tag
140 $tagMatches = $area.find( '.mw-notification-tag-' + options.tag );
143 // If we found existing notification with the same tag, replace them
144 if ( options.tag && $tagMatches.length ) {
146 // While there can be only one "open" notif with a given tag, there can be several
147 // matches here because they remain in the DOM until the animation is finished.
148 $tagMatches.each( function () {
149 const notif = $( this ).data( 'mw-notification' );
150 if ( notif && notif.isOpen ) {
151 // Detach from render flow with position absolute so that the new tag can
152 // occupy its space instead.
153 notif.$notification
154 .css( {
155 position: 'absolute',
156 width: notif.$notification.width()
158 .css( notif.$notification.position() )
159 .addClass( 'mw-notification-replaced' );
160 notif.close();
162 } );
164 $notification
165 .insertBefore( $tagMatches.first() )
166 .addClass( 'mw-notification-visible' );
167 } else {
168 $area.append( $notification );
169 requestAnimationFrame( () => {
170 // This frame renders the element in the area (invisible)
171 requestAnimationFrame( () => {
172 $notification.addClass( 'mw-notification-visible' );
173 } );
174 } );
177 // By default a notification is paused.
178 // If this notification is within the first {autoHideLimit} notifications then
179 // start the auto-hide timer as soon as it's created.
180 const autohideCount = $area.find( '.mw-notification-autohide' ).length;
181 if ( autohideCount <= notification.autoHideLimit ) {
182 this.resume();
187 * Pause any running auto-hide timer for this notification.
189 * @memberof Notification
191 Notification.prototype.pause = function () {
192 if ( this.isPaused ) {
193 return;
195 this.isPaused = true;
197 if ( this.timeoutId ) {
198 this.timeout.clear( this.timeoutId );
199 delete this.timeoutId;
204 * Start autoHide timer if not already started.
205 * Does nothing if autoHide is disabled.
206 * Either to resume from pause or to make the first start.
208 * @memberof Notification
210 Notification.prototype.resume = function () {
211 if ( !this.isPaused ) {
212 return;
214 // Start any autoHide timeouts
215 if ( this.options.autoHide ) {
216 this.isPaused = false;
217 this.timeoutId = this.timeout.set( () => {
218 // Already finished, so don't try to re-clear it
219 delete this.timeoutId;
220 this.close();
221 }, this.autoHideSeconds * 1000 );
226 * Close the notification.
228 * @memberof Notification
230 Notification.prototype.close = function () {
231 if ( !this.isOpen ) {
232 return;
235 this.isOpen = false;
236 openNotificationCount--;
238 // Clear any remaining timeout on close
239 this.pause();
241 // Remove the mw-notification-autohide class from the notification to avoid
242 // having a half-closed notification counted as a notification to resume
243 // when handling {autoHideLimit}.
244 this.$notification.removeClass( 'mw-notification-autohide' );
246 // Now that a notification is being closed. Start auto-hide timers for any
247 // notification that has now become one of the first {autoHideLimit} notifications.
248 notification.resume();
250 requestAnimationFrame( () => {
251 this.$notification.removeClass( 'mw-notification-visible' );
253 setTimeout( () => {
254 if ( openNotificationCount === 0 ) {
255 // Hide the area after the last notification closes. Otherwise, the padding on
256 // the area can be obscure content, despite the area being empty/invisible (T54659). // FIXME
257 $area.css( 'display', 'none' );
258 this.$notification.remove();
259 } else {
260 // FIXME: Use CSS transition
261 // eslint-disable-next-line no-jquery/no-slide
262 this.$notification.slideUp( 'fast', function () {
263 $( this ).remove();
264 } );
266 }, 500 );
267 } );
271 * Helper function, take a list of notification divs and call
272 * a function on the Notification instance attached to them.
274 * @private
275 * @static
276 * @param {jQuery} $notifications A jQuery object containing notification divs
277 * @param {string} fn The name of the function to call on the Notification instance
279 function callEachNotification( $notifications, fn ) {
280 $notifications.each( function () {
281 const notif = $( this ).data( 'mw-notification' );
282 if ( notif ) {
283 notif[ fn ]();
285 } );
289 * Initialisation.
290 * Must only be called once, and not before the document is ready.
292 * @ignore
294 function init() {
295 let offset, $overlay,
296 isFloating = false;
298 function updateAreaMode() {
299 const shouldFloat = window.pageYOffset > offset.top;
300 if ( isFloating === shouldFloat ) {
301 return;
303 isFloating = shouldFloat;
304 $area
305 .toggleClass( 'mw-notification-area-floating', isFloating )
306 .toggleClass( 'mw-notification-area-layout', !isFloating );
309 // Look for a preset notification area in the skin.
310 // 'data-mw*' attributes are banned from user content in Sanitizer.
311 $area = $( '.mw-notification-area[data-mw="interface"]' ).first();
312 if ( !$area.length ) {
313 $area = $( '<div>' ).addClass( 'mw-notification-area' );
314 // Create overlay div for the notification area
315 $overlay = $( '<div>' ).addClass( 'mw-notification-area-overlay' );
316 // Append the notification area to the overlay wrapper area
317 $overlay.append( $area );
318 $( document.body ).append( $overlay );
320 $area
321 .addClass( 'mw-notification-area-layout' )
322 // The ID attribute here is deprecated.
323 .attr( 'id', 'mw-notification-area' )
324 // Pause auto-hide timers when the mouse is in the notification area.
325 .on( {
326 mouseenter: notification.pause,
327 mouseleave: notification.resume
329 // When clicking on a notification close it.
330 .on( 'click', '.mw-notification', function () {
331 const notif = $( this ).data( 'mw-notification' );
332 if ( notif ) {
333 notif.close();
336 // Stop click events from <a> and <select> tags from propagating to prevent clicks
337 // from hiding a notification. stopPropagation() bubbles up, not down,
338 // hence this should not conflict with OOUI's own click handlers.
339 .on( 'click', 'a, select, .oo-ui-dropdownInputWidget', ( e ) => {
340 e.stopPropagation();
341 } );
343 // Read from the DOM:
344 // Must be in the next frame to avoid synchronous layout
345 // computation from offset()/getBoundingClientRect().
346 requestAnimationFrame( () => {
347 let notif;
349 offset = $area.offset();
351 // Initial mode (reads, and then maybe writes)
352 updateAreaMode();
354 // Once we have the offset for where it would normally render, set the
355 // initial state of the (currently empty) notification area to be hidden.
356 $area.css( 'display', 'none' );
358 $( window ).on( 'scroll', updateAreaMode );
360 // Handle pre-ready queue.
361 isPageReady = true;
362 while ( preReadyNotifQueue.length ) {
363 notif = preReadyNotifQueue.shift();
364 notif.start();
366 } );
370 * Library for sending notifications to end users.
372 * @namespace mw.notification
373 * @memberof mw
374 * @singleton
376 notification = {
378 * Pause auto-hide timers for all notifications.
379 * Notifications will not auto-hide until resume is called.
381 * @see Notification#pause
382 * @memberof mw.notification
384 pause: function () {
385 callEachNotification(
386 $area.children( '.mw-notification' ),
387 'pause'
392 * Resume any paused auto-hide timers from the beginning.
393 * Only the first {@link mw.notification.autoHideLimit} timers will be resumed.
395 * @memberof mw.notification
397 resume: function () {
398 callEachNotification(
399 // Only call resume on the first #autoHideLimit notifications.
400 // Exclude noautohide notifications to avoid bugs where #autoHideLimit
401 // `{ autoHide: false }` notifications are at the start preventing any
402 // auto-hide notifications from being autohidden.
403 $area.children( '.mw-notification-autohide' ).slice( 0, notification.autoHideLimit ),
404 'resume'
409 * Display a notification message to the user.
411 * @memberof mw.notification
412 * @param {HTMLElement|HTMLElement[]|jQuery|mw.Message|string} message
413 * @param {mw.notification.NotificationOptions} [options] The options to use
414 * for the notification. Options not specified default to the values in
415 * [#defaults]{@link mw.notification.defaults}.
416 * @return {Notification} Notification object
418 notify: function ( message, options ) {
419 options = Object.assign( {}, notification.defaults, options );
421 const notif = new Notification( message, options );
423 if ( isPageReady ) {
424 notif.start();
425 } else {
426 preReadyNotifQueue.push( notif );
429 return notif;
433 * @memberof mw.notification
434 * @typedef {Object} NotificationOptions
435 * @property {boolean} autoHide Whether the notification should automatically
436 * be hidden after shown. Or if it should persist.
437 * @property {string} autoHideSeconds Key to
438 * [#autoHideSeconds]{@link mw.notification.autoHideSeconds} for number of
439 * seconds for timeout of auto-hide notifications.
440 * @property {string|null} tag When a notification is tagged only one message
441 * with that tag will be displayed. Trying to display a new notification
442 * with the same tag as one already being displayed will cause the other
443 * notification to be closed and this new notification to open up inside
444 * the same place as the previous notification.
445 * @property {string|null} title Title for the notification. Will be displayed
446 * above the content. Usually in bold.
447 * @property {string|null} type The type of the message used for styling.
448 * Examples: `info`, `warn`, `error`, `success`.
449 * @property {boolean} visibleTimeout Whether the autoHide timeout should be
450 * based on time the page was visible to user. Or if it should use wall
451 * clock time.
452 * @property {string|false} id HTML ID to set on the notification element.
453 * @property {string|string[]|false} classes CSS class names to be set on the
454 * notification element.
458 * The defaults for [#notify]{@link mw.notification.notify} options parameter.
460 * @memberof mw.notification
461 * @type {mw.notification.NotificationOptions}
463 defaults: {
464 autoHide: true,
465 autoHideSeconds: 'short',
466 tag: null,
467 title: null,
468 type: null,
469 visibleTimeout: true,
470 id: false,
471 classes: false
475 * Map of predefined auto-hide timeout keys to second values. `short` is
476 * used by default, and other values can be added for use in [#notify]{@link mw.notification.notify}.
478 * @memberof mw.notification
479 * @type {Object.<string, number>}
480 * @property {number} short 5 seconds (default)
481 * @property {number} long 30 seconds
483 autoHideSeconds: {
484 short: 5,
485 long: 30
489 * Maximum number of simultaneous notifications to start auto-hide timers for.
490 * Only this number of notifications being displayed will be auto-hidden at one time.
491 * Any additional notifications in the list will only start counting their timeout for
492 * auto-hiding after the previous messages have been closed.
494 * This basically represents the minimal number of notifications the user should
495 * be able to process during the {@link mw.notification.defaults default} `autoHideSeconds` time.
497 * @memberof mw.notification
498 * @type {number}
500 autoHideLimit: 3
503 if ( window.QUnit ) {
504 $area = $( document.body );
505 } else {
506 // Don't run UI logic while under test.
507 // Let the test control this instead.
508 $( init );
511 mw.notification = notification;
513 }() );