Update OOjs UI to v0.1.0-pre (6379e76bf5)
[lhc/web/wiklou.git] / resources / lib / oojs-ui / oojs-ui.js
index cbfc470..cf680f5 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.1.0-pre (d4086ff6e6)
+ * OOjs UI v0.1.0-pre (6379e76bf5)
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
  * Copyright 2011–2014 OOjs Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: Fri May 16 2014 16:32:36 GMT-0700 (PDT)
+ * Date: Mon Jun 02 2014 17:52:03 GMT-0700 (PDT)
  */
 ( function ( OO ) {
 
@@ -743,8 +743,7 @@ OO.ui.Frame = function OoUiFrame( config ) {
        OO.EventEmitter.call( this );
 
        // Properties
-       this.loading = false;
-       this.loaded = false;
+       this.loading = null;
        this.config = config;
 
        // Initialize
@@ -780,10 +779,10 @@ OO.ui.Frame.static.tagName = 'iframe';
  *
  * This loops over the style sheets in the parent document, and copies their nodes to the
  * frame's document. It then polls the document to see when all styles have loaded, and once they
- * have, invokes the callback.
+ * have, resolves the promise.
  *
  * If the styles still haven't loaded after a long time (5 seconds by default), we give up waiting
- * and invoke the callback anyway. This protects against cases like a display: none; iframe in
+ * and resolve the promise anyway. This protects against cases like a display: none; iframe in
  * Firefox, where the styles won't load until the iframe becomes visible.
  *
  * For details of how we arrived at the strategy used in this function, see #load.
@@ -792,18 +791,19 @@ OO.ui.Frame.static.tagName = 'iframe';
  * @inheritable
  * @param {HTMLDocument} parentDoc Document to transplant styles from
  * @param {HTMLDocument} frameDoc Document to transplant styles to
- * @param {Function} [callback] Callback to execute once styles have loaded
  * @param {number} [timeout=5000] How long to wait before giving up (in ms). If 0, never give up.
+ * @return {jQuery.Promise} Promise resolved when styles have loaded
  */
-OO.ui.Frame.static.transplantStyles = function ( parentDoc, frameDoc, callback, timeout ) {
+OO.ui.Frame.static.transplantStyles = function ( parentDoc, frameDoc, timeout ) {
        var i, numSheets, styleNode, newNode, timeoutID, pollNodeId, $pendingPollNodes,
                $pollNodes = $( [] ),
                // Fake font-family value
-               fontFamily = 'oo-ui-frame-transplantStyles-loaded';
+               fontFamily = 'oo-ui-frame-transplantStyles-loaded',
+               deferred = $.Deferred();
 
        for ( i = 0, numSheets = parentDoc.styleSheets.length; i < numSheets; i++ ) {
                styleNode = parentDoc.styleSheets[i].ownerNode;
-               if ( callback && styleNode.nodeName.toLowerCase() === 'link' ) {
+               if ( styleNode.nodeName.toLowerCase() === 'link' ) {
                        // External stylesheet
                        // Create a node with a unique ID that we're going to monitor to see when the CSS
                        // has loaded
@@ -825,40 +825,40 @@ OO.ui.Frame.static.transplantStyles = function ( parentDoc, frameDoc, callback,
                frameDoc.head.appendChild( newNode );
        }
 
-       if ( callback ) {
-               // Poll every 100ms until all external stylesheets have loaded
-               $pendingPollNodes = $pollNodes;
-               timeoutID = setTimeout( function pollExternalStylesheets() {
-                       while (
-                               $pendingPollNodes.length > 0 &&
-                               $pendingPollNodes.eq( 0 ).css( 'font-family' ) === fontFamily
-                       ) {
-                               $pendingPollNodes = $pendingPollNodes.slice( 1 );
-                       }
+       // Poll every 100ms until all external stylesheets have loaded
+       $pendingPollNodes = $pollNodes;
+       timeoutID = setTimeout( function pollExternalStylesheets() {
+               while (
+                       $pendingPollNodes.length > 0 &&
+                       $pendingPollNodes.eq( 0 ).css( 'font-family' ) === fontFamily
+               ) {
+                       $pendingPollNodes = $pendingPollNodes.slice( 1 );
+               }
 
-                       if ( $pendingPollNodes.length === 0 ) {
-                               // We're done!
-                               if ( timeoutID !== null ) {
-                                       timeoutID = null;
-                                       $pollNodes.remove();
-                                       callback();
-                               }
-                       } else {
-                               timeoutID = setTimeout( pollExternalStylesheets, 100 );
+               if ( $pendingPollNodes.length === 0 ) {
+                       // We're done!
+                       if ( timeoutID !== null ) {
+                               timeoutID = null;
+                               $pollNodes.remove();
+                               deferred.resolve();
                        }
-               }, 100 );
-               // ...but give up after a while
-               if ( timeout !== 0 ) {
-                       setTimeout( function () {
-                               if ( timeoutID ) {
-                                       clearTimeout( timeoutID );
-                                       timeoutID = null;
-                                       $pollNodes.remove();
-                                       callback();
-                               }
-                       }, timeout || 5000 );
+               } else {
+                       timeoutID = setTimeout( pollExternalStylesheets, 100 );
                }
+       }, 100 );
+       // ...but give up after a while
+       if ( timeout !== 0 ) {
+               setTimeout( function () {
+                       if ( timeoutID ) {
+                               clearTimeout( timeoutID );
+                               timeoutID = null;
+                               $pollNodes.remove();
+                               deferred.reject();
+                       }
+               }, timeout || 5000 );
        }
+
+       return deferred.promise();
 };
 
 /* Methods */
@@ -866,7 +866,10 @@ OO.ui.Frame.static.transplantStyles = function ( parentDoc, frameDoc, callback,
 /**
  * Load the frame contents.
  *
- * Once the iframe's stylesheets are loaded, the `initialize` event will be emitted.
+ * Once the iframe's stylesheets are loaded, the `load` event will be emitted and the returned
+ * promise will be resolved. Calling while loading will return a promise but not trigger a new
+ * loading cycle. Calling after loading is complete will return a promise that's already been
+ * resolved.
  *
  * Sounds simple right? Read on...
  *
@@ -894,18 +897,25 @@ OO.ui.Frame.static.transplantStyles = function ( parentDoc, frameDoc, callback,
  *
  * All this stylesheet injection and polling magic is in #transplantStyles.
  *
- * @private
+ * @return {jQuery.Promise} Promise resolved when loading is complete
  * @fires load
  */
 OO.ui.Frame.prototype.load = function () {
-       var win = this.$element.prop( 'contentWindow' ),
-               doc = win.document,
-               frame = this;
+       var win, doc;
+
+       // Return existing promise if already loading or loaded
+       if ( this.loading ) {
+               return this.loading.promise();
+       }
+
+       // Load the frame
+       this.loading = $.Deferred();
 
-       this.loading = true;
+       win = this.$element.prop( 'contentWindow' );
+       doc = win.document;
 
        // Figure out directionality:
-       this.dir = this.$element.closest( '[dir]' ).prop( 'dir' ) || 'ltr';
+       this.dir = OO.ui.Element.getDir( this.$element ) || 'ltr';
 
        // Initialize contents
        doc.open();
@@ -924,37 +934,14 @@ OO.ui.Frame.prototype.load = function () {
        this.$content = this.$( '.oo-ui-frame-content' ).attr( 'tabIndex', 0 );
        this.$document = this.$( doc );
 
-       this.constructor.static.transplantStyles(
-               this.getElementDocument(),
-               this.$document[0],
-               function () {
-                       frame.loading = false;
-                       frame.loaded = true;
-                       frame.emit( 'load' );
-               }
-       );
-};
+       // Initialization
+       this.constructor.static.transplantStyles( this.getElementDocument(), this.$document[0] )
+               .always( OO.ui.bind( function () {
+                       this.emit( 'load' );
+                       this.loading.resolve();
+               }, this ) );
 
-/**
- * Run a callback as soon as the frame has been loaded.
- *
- *
- * This will start loading if it hasn't already, and runs
- * immediately if the frame is already loaded.
- *
- * Don't call this until the element is attached.
- *
- * @param {Function} callback
- */
-OO.ui.Frame.prototype.run = function ( callback ) {
-       if ( this.loaded ) {
-               callback();
-       } else {
-               if ( !this.loading ) {
-                       this.load();
-               }
-               this.once( 'load', callback );
-       }
+       return this.loading.promise();
 };
 
 /**
@@ -986,6 +973,7 @@ OO.ui.Frame.prototype.setSize = function ( width, height ) {
  * @fires initialize
  */
 OO.ui.Window = function OoUiWindow( config ) {
+       var element = this;
        // Parent constructor
        OO.ui.Window.super.call( this, config );
 
@@ -994,8 +982,9 @@ OO.ui.Window = function OoUiWindow( config ) {
 
        // Properties
        this.visible = false;
-       this.opening = false;
-       this.closing = false;
+       this.opening = null;
+       this.closing = null;
+       this.opened = null;
        this.title = OO.ui.resolveMsg( config.title || this.constructor.static.title );
        this.icon = config.icon || this.constructor.static.icon;
        this.frame = new OO.ui.Frame( { '$': this.$ } );
@@ -1016,7 +1005,14 @@ OO.ui.Window = function OoUiWindow( config ) {
                .append( this.frame.$element );
 
        // Events
-       this.frame.connect( this, { 'load': 'initialize' } );
+       this.frame.on( 'load', function () {
+               element.initialize();
+               // Undo the visibility: hidden; hack and apply display: none;
+               // We can do this safely now that the iframe has initialized
+               // (don't do this from within #initialize because it has to happen
+               // after the all subclasses have been handled as well).
+               element.$element.hide().css( 'visibility', '' );
+       } );
 };
 
 /* Setup */
@@ -1026,14 +1022,6 @@ OO.mixinClass( OO.ui.Window, OO.EventEmitter );
 
 /* Events */
 
-/**
- * Initialize contents.
- *
- * Fired asynchronously after construction when iframe is ready.
- *
- * @event initialize
- */
-
 /**
  * Open window.
  *
@@ -1093,7 +1081,7 @@ OO.ui.Window.prototype.isVisible = function () {
  * @return {boolean} Window is opening
  */
 OO.ui.Window.prototype.isOpening = function () {
-       return this.opening;
+       return !!this.opening && this.opening.state() !== 'resolved';
 };
 
 /**
@@ -1102,7 +1090,16 @@ OO.ui.Window.prototype.isOpening = function () {
  * @return {boolean} Window is closing
  */
 OO.ui.Window.prototype.isClosing = function () {
-       return this.closing;
+       return !!this.closing && this.closing.state() !== 'resolved';
+};
+
+/**
+ * Check if window is opened.
+ *
+ * @return {boolean} Window is opened
+ */
+OO.ui.Window.prototype.isOpened = function () {
+       return !!this.opened && this.opened.state() !== 'resolved';
 };
 
 /**
@@ -1238,7 +1235,6 @@ OO.ui.Window.prototype.fitWidthToContents = function ( min, max ) {
  *
  * Once this method is called, this.$$ can be used to create elements within the frame.
  *
- * @fires initialize
  * @chainable
  */
 OO.ui.Window.prototype.initialize = function () {
@@ -1261,103 +1257,144 @@ OO.ui.Window.prototype.initialize = function () {
                this.$overlay
        );
 
-       // Undo the visibility: hidden; hack from the constructor and apply display: none;
-       // We can do this safely now that the iframe has initialized
-       this.$element.hide().css( 'visibility', '' );
-
-       this.emit( 'initialize' );
-
        return this;
 };
 
 /**
- * Setup window for use.
+ * Get a process for setting up a window for use.
  *
- * Each time the window is opened, once it's ready to be interacted with, this will set it up for
- * use in a particular context, based on the `data` argument.
+ * Each time the window is opened this process will set it up for use in a particular context, based
+ * on the `data` argument.
  *
- * When you override this method, you must call the parent method at the very beginning.
+ * When you override this method, you can add additional setup steps to the process the parent
+ * method provides using the 'first' and 'next' methods.
  *
  * @abstract
  * @param {Object} [data] Window opening data
+ * @return {OO.ui.Process} Setup process
  */
-OO.ui.Window.prototype.setup = function () {
-       // Override to do something
+OO.ui.Window.prototype.getSetupProcess = function () {
+       return new OO.ui.Process();
 };
 
 /**
- * Tear down window after use.
+ * Get a process for readying a window for use.
  *
- * Each time the window is closed, and it's done being interacted with, this will tear it down and
- * do something with the user's interactions within the window, based on the `data` argument.
+ * Each time the window is open and setup, this process will ready it up for use in a particular
+ * context, based on the `data` argument.
  *
- * When you override this method, you must call the parent method at the very end.
+ * When you override this method, you can add additional setup steps to the process the parent
+ * method provides using the 'first' and 'next' methods.
+ *
+ * @abstract
+ * @param {Object} [data] Window opening data
+ * @return {OO.ui.Process} Setup process
+ */
+OO.ui.Window.prototype.getReadyProcess = function () {
+       return new OO.ui.Process();
+};
+
+/**
+ * Get a process for tearing down a window after use.
+ *
+ * Each time the window is closed this process will tear it down and do something with the user's
+ * interactions within the window, based on the `data` argument.
+ *
+ * When you override this method, you can add additional teardown steps to the process the parent
+ * method provides using the 'first' and 'next' methods.
  *
  * @abstract
  * @param {Object} [data] Window closing data
+ * @return {OO.ui.Process} Teardown process
  */
-OO.ui.Window.prototype.teardown = function () {
-       // Override to do something
+OO.ui.Window.prototype.getTeardownProcess = function () {
+       return new OO.ui.Process();
 };
 
 /**
  * Open window.
  *
- * Do not override this method. See #setup for a way to make changes each time the window opens.
+ * Do not override this method. Use #geSetupProcess to do something each time the window closes.
  *
  * @param {Object} [data] Window opening data
+ * @fires initialize
  * @fires opening
  * @fires open
  * @fires ready
- * @chainable
+ * @return {jQuery.Promise} Promise resolved when window is opened; when the promise is resolved the
+ *   first argument will be a promise which will be resolved when the window begins closing
  */
 OO.ui.Window.prototype.open = function ( data ) {
-       if ( !this.opening && !this.closing && !this.visible ) {
-               this.opening = true;
-               this.frame.run( OO.ui.bind( function () {
-                       this.$element.show();
-                       this.visible = true;
-                       this.emit( 'opening', data );
-                       this.setup( data );
+       // Return existing promise if already opening or open
+       if ( this.opening ) {
+               return this.opening.promise();
+       }
+
+       // Open the window
+       this.opening = $.Deferred();
+       this.frame.load().done( OO.ui.bind( function () {
+               this.$element.show();
+               this.visible = true;
+               this.emit( 'opening', data );
+               this.getSetupProcess( data ).execute().done( OO.ui.bind( function () {
                        this.emit( 'open', data );
                        setTimeout( OO.ui.bind( function () {
                                // Focus the content div (which has a tabIndex) to inactivate
                                // (but not clear) selections in the parent frame.
                                // Must happen after 'open' is emitted (to ensure it is visible)
-                               // but before 'ready' is emitted (so subclasses can give focus to something else)
+                               // but before 'ready' is emitted (so subclasses can give focus to something
+                               // else)
                                this.frame.$content.focus();
-                               this.emit( 'ready', data );
-                               this.opening = false;
+                               this.getReadyProcess( data ).execute().done( OO.ui.bind( function () {
+                                       this.emit( 'ready', data );
+                                       this.opened = $.Deferred();
+                                       this.opening.resolve( this.opened.promise() );
+                                       // Now that we are totally done opening, it's safe to allow closing
+                                       this.closing = null;
+                               }, this ) );
                        }, this ) );
                }, this ) );
-       }
+       }, this ) );
 
-       return this;
+       return this.opening.promise();
 };
 
 /**
  * Close window.
  *
- * See #teardown for a way to do something each time the window closes.
+ * Do not override this method. Use #getTeardownProcess to do something each time the window closes.
  *
  * @param {Object} [data] Window closing data
  * @fires closing
  * @fires close
- * @chainable
+ * @return {jQuery.Promise} Promise resolved when window is closed
  */
 OO.ui.Window.prototype.close = function ( data ) {
-       if ( !this.opening && !this.closing && this.visible ) {
-               this.frame.$content.find( ':focus' ).blur();
-               this.closing = true;
+       // Return existing promise if already closing or closed
+       if ( this.closing ) {
+               return this.closing.promise();
+       }
+
+       // Close the window
+       // This.closing needs to exist before we emit the closing event so that handlers can call
+       // window.close() and trigger the safety check above
+       this.closing = $.Deferred();
+       this.frame.$content.find( ':focus' ).blur();
+       this.emit( 'closing', data );
+       this.getTeardownProcess( data ).execute().done( OO.ui.bind( function () {
+               // To do something different with #opened, resolve/reject #opened in the teardown process
+               if ( this.opened.state() === 'pending' ) {
+                       this.opened.resolve();
+               }
+               this.emit( 'close', data );
                this.$element.hide();
                this.visible = false;
-               this.emit( 'closing', data );
-               this.teardown( data );
-               this.emit( 'close', data );
-               this.closing = false;
-       }
+               this.closing.resolve();
+               // Now that we are totally done closing, it's safe to allow opening
+               this.opening = null;
+       }, this ) );
 
-       return this;
+       return this.closing.promise();
 };
 /**
  * Set of mutually exclusive windows.
@@ -1571,7 +1608,7 @@ OO.ui.Dialog = function OoUiDialog( config ) {
 
        // Events
        this.$element.on( 'mousedown', false );
-       this.connect( this, { 'opening': 'onOpening' } );
+       this.connect( this, { 'open': 'onOpen' } );
 
        // Initialization
        this.$element.addClass( 'oo-ui-dialog' );
@@ -1657,8 +1694,10 @@ OO.ui.Dialog.prototype.onFrameDocumentKeyDown = function ( e ) {
        }
 };
 
-/** */
-OO.ui.Dialog.prototype.onOpening = function () {
+/**
+ * Handle window open events.
+ */
+OO.ui.Dialog.prototype.onOpen = function () {
        this.$element.addClass( 'oo-ui-dialog-open' );
 };
 
@@ -1690,7 +1729,7 @@ OO.ui.Dialog.prototype.setSize = function ( size ) {
  */
 OO.ui.Dialog.prototype.initialize = function () {
        // Parent method
-       OO.ui.Window.prototype.initialize.call( this );
+       OO.ui.Dialog.super.prototype.initialize.call( this );
 
        // Properties
        this.closeButton = new OO.ui.ButtonWidget( {
@@ -1716,41 +1755,29 @@ OO.ui.Dialog.prototype.initialize = function () {
 /**
  * @inheritdoc
  */
-OO.ui.Dialog.prototype.setup = function ( data ) {
-       // Parent method
-       OO.ui.Window.prototype.setup.call( this, data );
-
-       // Prevent scrolling in top-level window
-       this.$( window ).on( 'mousewheel', this.onWindowMouseWheelHandler );
-       this.$( document ).on( 'keydown', this.onDocumentKeyDownHandler );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.Dialog.prototype.teardown = function ( data ) {
-       // Parent method
-       OO.ui.Window.prototype.teardown.call( this, data );
-
-       // Allow scrolling in top-level window
-       this.$( window ).off( 'mousewheel', this.onWindowMouseWheelHandler );
-       this.$( document ).off( 'keydown', this.onDocumentKeyDownHandler );
+OO.ui.Dialog.prototype.getSetupProcess = function ( data ) {
+       return OO.ui.Dialog.super.prototype.getSetupProcess.call( this, data )
+               .next( function () {
+                       // Prevent scrolling in top-level window
+                       this.$( window ).on( 'mousewheel', this.onWindowMouseWheelHandler );
+                       this.$( document ).on( 'keydown', this.onDocumentKeyDownHandler );
+               }, this );
 };
 
 /**
  * @inheritdoc
  */
-OO.ui.Dialog.prototype.close = function ( data ) {
-       var dialog = this;
-       if ( !dialog.opening && !dialog.closing && dialog.visible ) {
-               // Trigger transition
-               dialog.$element.removeClass( 'oo-ui-dialog-open' );
-               // Allow transition to complete before actually closing
-               setTimeout( function () {
-                       // Parent method
-                       OO.ui.Window.prototype.close.call( dialog, data );
-               }, 250 );
-       }
+OO.ui.Dialog.prototype.getTeardownProcess = function ( data ) {
+       return OO.ui.Dialog.super.prototype.getTeardownProcess.call( this, data )
+               .first( function () {
+                       this.$element.removeClass( 'oo-ui-dialog-open' );
+                       return OO.ui.Process.static.delay( 250 );
+               }, this )
+               .next( function () {
+                       // Allow scrolling in top-level window
+                       this.$( window ).off( 'mousewheel', this.onWindowMouseWheelHandler );
+                       this.$( document ).off( 'keydown', this.onDocumentKeyDownHandler );
+               }, this );
 };
 
 /**
@@ -1909,6 +1936,122 @@ OO.ui.Widget.prototype.setDisabled = function ( disabled ) {
        this.wasDisabled = isDisabled;
        return this;
 };
+/**
+ * A list of functions, called in sequence.
+ *
+ * If a function added to a process returns boolean false the process will stop; if it returns an
+ * object with a `promise` method the process will use the promise to either continue to the next
+ * step when the promise is resolved or stop when the promise is rejected.
+ *
+ * @class
+ *
+ * @constructor
+ */
+OO.ui.Process = function () {
+       // Properties
+       this.steps = [];
+};
+
+/* Setup */
+
+OO.initClass( OO.ui.Process );
+
+/* Static Methods */
+
+/**
+ * Generate a promise which is resolved after a set amount of time.
+ *
+ * @param {number} length Number of milliseconds before resolving the promise
+ * @return {jQuery.Promise} Promise that will be resolved after a set amount of time
+ */
+OO.ui.Process.static.delay = function ( length ) {
+       var deferred = $.Deferred();
+
+       setTimeout( function () {
+               deferred.resolve();
+       }, length );
+
+       return deferred.promise();
+};
+
+/* Methods */
+
+/**
+ * Start the process.
+ *
+ * @return {jQuery.Promise} Promise that is resolved when all steps have completed or rejected when
+ *   any of the steps return boolean false or a promise which gets rejected; upon stopping the
+ *   process, the remaining steps will not be taken
+ */
+OO.ui.Process.prototype.execute = function () {
+       var i, len, promise;
+
+       /**
+        * Continue execution.
+        *
+        * @ignore
+        * @param {Array} step A function and the context it should be called in
+        * @return {Function} Function that continues the process
+        */
+       function proceed( step ) {
+               return function () {
+                       // Execute step in the correct context
+                       var result = step[0].call( step[1] );
+
+                       if ( result === false ) {
+                               // Use rejected promise for boolean false results
+                               return $.Deferred().reject().promise();
+                       }
+                       // Duck-type the object to see if it can produce a promise
+                       if ( result && $.isFunction( result.promise ) ) {
+                               // Use a promise generated from the result
+                               return result.promise();
+                       }
+                       // Use resolved promise for other results
+                       return $.Deferred().resolve().promise();
+               };
+       }
+
+       if ( this.steps.length ) {
+               // Generate a chain reaction of promises
+               promise = proceed( this.steps[0] )();
+               for ( i = 1, len = this.steps.length; i < len; i++ ) {
+                       promise = promise.then( proceed( this.steps[i] ) );
+               }
+       } else {
+               promise = $.Deferred().resolve().promise();
+       }
+
+       return promise;
+};
+
+/**
+ * Add step to the beginning of the process.
+ *
+ * @param {Function} step Function to execute; if it returns boolean false the process will stop; if
+ *   it returns an object with a `promise` method the process will use the promise to either
+ *   continue to the next step when the promise is resolved or stop when the promise is rejected
+ * @param {Object} [context=null] Context to call the step function in
+ * @chainable
+ */
+OO.ui.Process.prototype.first = function ( step, context ) {
+       this.steps.unshift( [ step, context || null ] );
+       return this;
+};
+
+/**
+ * Add step to the end of the process.
+ *
+ * @param {Function} step Function to execute; if it returns boolean false the process will stop; if
+ *   it returns an object with a `promise` method the process will use the promise to either
+ *   continue to the next step when the promise is resolved or stop when the promise is rejected
+ * @param {Object} [context=null] Context to call the step function in
+ * @chainable
+ */
+OO.ui.Process.prototype.next = function ( step, context ) {
+       this.steps.push( [ step, context || null ] );
+       return this;
+};
 /**
  * Dialog for showing a confirmation/warning message.
  *
@@ -1955,15 +2098,11 @@ OO.ui.ConfirmationDialog.prototype.initialize = function () {
 
        this.$promptContainer = this.$( '<div>' ).addClass( 'oo-ui-dialog-confirm-promptContainer' );
 
-       this.cancelButton = new OO.ui.ButtonWidget( {
-               'flags': [ 'destructive' ]
-       } );
-       this.cancelButton.connect( this, { 'click': [ 'emit', 'cancel' ] } );
+       this.cancelButton = new OO.ui.ButtonWidget();
+       this.cancelButton.connect( this, { 'click': [ 'emit', 'done', 'cancel' ] } );
 
-       this.okButton = new OO.ui.ButtonWidget( {
-               'flags': [ 'constructive' ]
-       } );
-       this.okButton.connect( this, { 'click': [ 'emit', 'ok' ] } );
+       this.okButton = new OO.ui.ButtonWidget();
+       this.okButton.connect( this, { 'click': [ 'emit', 'done', 'ok' ] } );
 
        // Make the buttons
        contentLayout.$element.append( this.$promptContainer );
@@ -1975,8 +2114,7 @@ OO.ui.ConfirmationDialog.prototype.initialize = function () {
        );
 
        this.connect( this, {
-               'ok': 'close',
-               'cancel': 'close',
+               'done': 'close',
                'close': [ 'emit', 'cancel' ]
        } );
 };
@@ -1984,10 +2122,12 @@ OO.ui.ConfirmationDialog.prototype.initialize = function () {
 /*
  * Open a confirmation dialog.
  *
- * @param {object} [data] Window opening data including text of the dialog and text for the buttons
- * @param {jQuery|string} [data.prompt] The text of the dialog.
- * @param {jQuery|string|Function|null} [data.okLabel] The text used on the OK button
- * @param {jQuery|string|Function|null} [data.cancelLabel] The text used on the cancel button
+ * @param {Object} [data] Window opening data including text of the dialog and text for the buttons
+ * @param {jQuery|string} [data.prompt] Text to display or list of nodes to use as content of the dialog.
+ * @param {jQuery|string|Function|null} [data.okLabel] Label of the OK button
+ * @param {jQuery|string|Function|null} [data.cancelLabel] Label of the cancel button
+ * @param {string|string[]} [data.okFlags="constructive"] Flags for the OK button
+ * @param {string|string[]} [data.cancelFlags="destructive"] Flags for the cancel button
  */
 OO.ui.ConfirmationDialog.prototype.setup = function ( data ) {
        // Parent method
@@ -1995,7 +2135,9 @@ OO.ui.ConfirmationDialog.prototype.setup = function ( data ) {
 
        var prompt = data.prompt || OO.ui.deferMsg( 'ooui-dialog-confirm-default-prompt' ),
                okLabel = data.okLabel || OO.ui.deferMsg( 'ooui-dialog-confirm-default-ok' ),
-               cancelLabel = data.cancelLabel || OO.ui.deferMsg( 'ooui-dialog-confirm-default-cancel' );
+               cancelLabel = data.cancelLabel || OO.ui.deferMsg( 'ooui-dialog-confirm-default-cancel' ),
+               okFlags = data.okFlags || 'constructive',
+               cancelFlags = data.cancelFlags || 'destructive';
 
        if ( typeof prompt === 'string' ) {
                this.$promptContainer.text( prompt );
@@ -2003,8 +2145,8 @@ OO.ui.ConfirmationDialog.prototype.setup = function ( data ) {
                this.$promptContainer.empty().append( prompt );
        }
 
-       this.okButton.setLabel( okLabel );
-       this.cancelButton.setLabel( cancelLabel );
+       this.okButton.setLabel( okLabel ).clearFlags().setFlags( okFlags );
+       this.cancelButton.setLabel( cancelLabel ).clearFlags().setFlags( cancelFlags );
 };
 /**
  * Element with a button.
@@ -2044,6 +2186,21 @@ OO.ui.ButtonedElement = function OoUiButtonedElement( $button, config ) {
        }
 };
 
+/* Setup */
+
+OO.initClass( OO.ui.ButtonedElement );
+
+/* Static Properties */
+
+/**
+ * Cancel mouse down events.
+ *
+ * @static
+ * @inheritable
+ * @property {boolean}
+ */
+OO.ui.ButtonedElement.static.cancelButtonMouseDownEvents = true;
+
 /* Methods */
 
 /**
@@ -2055,16 +2212,20 @@ OO.ui.ButtonedElement.prototype.onMouseDown = function ( e ) {
        if ( this.isDisabled() || e.which !== 1 ) {
                return false;
        }
-       // tabIndex should generally be interacted with via the property,
-       // but it's not possible to reliably unset a tabIndex via a property
-       // so we use the (lowercase) "tabindex" attribute instead.
+       // tabIndex should generally be interacted with via the property, but it's not possible to
+       // reliably unset a tabIndex via a property so we use the (lowercase) "tabindex" attribute
        this.tabIndex = this.$button.attr( 'tabindex' );
-       // Remove the tab-index while the button is down to prevent the button from stealing focus
        this.$button
+               // Remove the tab-index while the button is down to prevent the button from stealing focus
                .removeAttr( 'tabindex' )
                .addClass( 'oo-ui-buttonedElement-pressed' );
+       // Run the mouseup handler no matter where the mouse is when the button is let go, so we can
+       // reliably reapply the tabindex and remove the pressed class
        this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
-       return false;
+       // Prevent change of focus unless specifically configured otherwise
+       if ( this.constructor.static.cancelButtonMouseDownEvents ) {
+               return false;
+       }
 };
 
 /**
@@ -2076,10 +2237,11 @@ OO.ui.ButtonedElement.prototype.onMouseUp = function ( e ) {
        if ( this.isDisabled() || e.which !== 1 ) {
                return false;
        }
-       // Restore the tab-index after the button is up to restore the button's accesssibility
        this.$button
+               // Restore the tab-index after the button is up to restore the button's accesssibility
                .attr( 'tabindex', this.tabIndex )
                .removeClass( 'oo-ui-buttonedElement-pressed' );
+       // Stop listening for mouseup, since we only needed this once
        this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
 };
 
@@ -2297,15 +2459,19 @@ OO.ui.FlaggableElement.prototype.clearFlags = function () {
 /**
  * Add one or more flags.
  *
- * @param {string[]|Object.<string, boolean>} flags List of flags to add, or list of set/remove
- *  values, keyed by flag name
+ * @param {string|string[]|Object.<string, boolean>} flags One or more flags to add, or an object
+ *  keyed by flag name containing boolean set/remove instructions.
  * @chainable
  */
 OO.ui.FlaggableElement.prototype.setFlags = function ( flags ) {
        var i, len, flag,
                classPrefix = 'oo-ui-flaggableElement-';
 
-       if ( $.isArray( flags ) ) {
+       if ( typeof flags === 'string' ) {
+               // Set
+               this.flags[flags] = true;
+               this.$element.addClass( classPrefix + flags );
+       } else if ( $.isArray( flags ) ) {
                for ( i = 0, len = flags.length; i < len; i++ ) {
                        flag = flags[i];
                        // Set
@@ -4066,14 +4232,14 @@ OO.ui.GridLayout = function OoUiGridLayout( panels, config ) {
                this.$element.append( panels[i].$element );
        }
        if ( config.widths || config.heights ) {
-               this.layout( config.widths || [1], config.heights || [1] );
+               this.layout( config.widths || [ 1 ], config.heights || [ 1 ] );
        } else {
                // Arrange in columns by default
                widths = [];
                for ( i = 0, len = this.panels.length; i < len; i++ ) {
                        widths[i] = 1;
                }
-               this.layout( widths, [1] );
+               this.layout( widths, [ 1 ] );
        }
 };
 
@@ -4229,7 +4395,8 @@ OO.ui.BookletLayout = function OoUiBookletLayout( config ) {
                this.outlineWidget = new OO.ui.OutlineWidget( { '$': this.$ } );
                this.outlinePanel = new OO.ui.PanelLayout( { '$': this.$, 'scrollable': true } );
                this.gridLayout = new OO.ui.GridLayout(
-                       [this.outlinePanel, this.stackLayout], { '$': this.$, 'widths': [1, 2] }
+                       [ this.outlinePanel, this.stackLayout ],
+                       { '$': this.$, 'widths': [ 1, 2 ] }
                );
                this.outlineVisible = true;
                if ( this.editable ) {
@@ -4784,7 +4951,7 @@ OO.mixinClass( OO.ui.StackLayout, OO.ui.GroupElement );
 
 /**
  * @event set
- * @param {OO.ui.Layout|null} [item] Current item
+ * @param {OO.ui.Layout|null} item Current item or null if there is no longer a layout shown
  */
 
 /* Methods */
@@ -4792,12 +4959,29 @@ OO.mixinClass( OO.ui.StackLayout, OO.ui.GroupElement );
 /**
  * Get the current item.
  *
- * @return {OO.ui.Layout|null} [description]
+ * @return {OO.ui.Layout|null}
  */
 OO.ui.StackLayout.prototype.getCurrentItem = function () {
        return this.currentItem;
 };
 
+/**
+ * Unset the current item.
+ *
+ * @private
+ * @param {OO.ui.StackLayout} layout
+ * @fires set
+ */
+OO.ui.StackLayout.prototype.unsetCurrentItem = function () {
+       var prevItem = this.currentItem;
+       if ( prevItem === null ) {
+               return;
+       }
+
+       this.currentItem = null;
+       this.emit( 'set', null );
+};
+
 /**
  * Add items.
  *
@@ -4808,6 +4992,7 @@ OO.ui.StackLayout.prototype.getCurrentItem = function () {
  * @chainable
  */
 OO.ui.StackLayout.prototype.addItems = function ( items, index ) {
+       // Mixin method
        OO.ui.GroupElement.prototype.addItems.call( this, items, index );
 
        if ( !this.currentItem && items.length ) {
@@ -4824,13 +5009,17 @@ OO.ui.StackLayout.prototype.addItems = function ( items, index ) {
  *
  * @param {OO.ui.Layout[]} items Items to remove
  * @chainable
+ * @fires set
  */
 OO.ui.StackLayout.prototype.removeItems = function ( items ) {
+       // Mixin method
        OO.ui.GroupElement.prototype.removeItems.call( this, items );
+
        if ( $.inArray( this.currentItem, items  ) !== -1 ) {
-               this.currentItem = null;
-               if ( !this.currentItem && this.items.length ) {
+               if ( this.items.length ) {
                        this.setItem( this.items[0] );
+               } else {
+                       this.unsetCurrentItem();
                }
        }
 
@@ -4843,9 +5032,10 @@ OO.ui.StackLayout.prototype.removeItems = function ( items ) {
  * Items will be detached, not removed, so they can be used later.
  *
  * @chainable
+ * @fires set
  */
 OO.ui.StackLayout.prototype.clearItems = function () {
-       this.currentItem = null;
+       this.unsetCurrentItem();
        OO.ui.GroupElement.prototype.clearItems.call( this );
 
        return this;
@@ -4856,8 +5046,12 @@ OO.ui.StackLayout.prototype.clearItems = function () {
  *
  * Any currently shown item will be hidden.
  *
+ * FIXME: If the passed item to show has not been added in the items list, then
+ * this method drops it and unsets the current item.
+ *
  * @param {OO.ui.Layout} item Item to show
  * @chainable
+ * @fires set
  */
 OO.ui.StackLayout.prototype.setItem = function ( item ) {
        if ( item !== this.currentItem ) {
@@ -4868,11 +5062,11 @@ OO.ui.StackLayout.prototype.setItem = function ( item ) {
                        if ( !this.continuous ) {
                                item.$element.css( 'display', 'block' );
                        }
+                       this.currentItem = item;
+                       this.emit( 'set', item );
                } else {
-                       item = null;
+                       this.unsetCurrentItem();
                }
-               this.currentItem = item;
-               this.emit( 'set', item );
        }
 
        return this;
@@ -4921,6 +5115,7 @@ OO.ui.BarToolGroup.static.name = 'bar';
  * @constructor
  * @param {OO.ui.Toolbar} toolbar
  * @param {Object} [config] Configuration options
+ * @cfg {string} [header] Text to display at the top of the pop-up
  */
 OO.ui.PopupToolGroup = function OoUiPopupToolGroup( toolbar, config ) {
        // Configuration initialization
@@ -4952,6 +5147,16 @@ OO.ui.PopupToolGroup = function OoUiPopupToolGroup( toolbar, config ) {
        this.$handle
                .addClass( 'oo-ui-popupToolGroup-handle' )
                .append( this.$icon, this.$label, this.$indicator );
+       // If the pop-up should have a header, add it to the top of the toolGroup.
+       // Note: If this feature is useful for other widgets, we could abstract it into an
+       // OO.ui.HeaderedElement mixin constructor.
+       if ( config.header !== undefined ) {
+               this.$group
+                       .prepend( this.$( '<span>' )
+                               .addClass( 'oo-ui-popupToolGroup-header' )
+                               .text( config.header )
+                       );
+       }
        this.$element
                .addClass( 'oo-ui-popupToolGroup' )
                .prepend( this.$handle );
@@ -5003,7 +5208,7 @@ OO.ui.PopupToolGroup.prototype.onMouseUp = function ( e ) {
        if ( !this.isDisabled() && e.which === 1 ) {
                this.setActive( false );
        }
-       return OO.ui.ToolGroup.prototype.onMouseUp.call( this, e );
+       return OO.ui.PopupToolGroup.super.prototype.onMouseUp.call( this, e );
 };
 
 /**
@@ -5223,8 +5428,7 @@ OO.ui.GroupWidget.prototype.setDisabled = function ( disabled ) {
        var i, len;
 
        // Parent method
-       // Note this is calling OO.ui.Widget; we're assuming the class this is mixed into
-       // is a subclass of OO.ui.Widget.
+       // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
        OO.ui.Widget.prototype.setDisabled.call( this, disabled );
 
        // During construction, #setDisabled is called before the OO.ui.GroupElement constructor
@@ -5272,6 +5476,7 @@ OO.ui.ItemWidget.prototype.isDisabled = function () {
  */
 OO.ui.ItemWidget.prototype.setElementGroup = function ( group ) {
        // Parent method
+       // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
        OO.ui.Element.prototype.setElementGroup.call( this, group );
 
        // Initialize item disabled states
@@ -5659,7 +5864,7 @@ OO.ui.InputWidget.prototype.setReadOnly = function ( state ) {
  * @inheritdoc
  */
 OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
-       OO.ui.Widget.prototype.setDisabled.call( this, state );
+       OO.ui.InputWidget.super.prototype.setDisabled.call( this, state );
        if ( this.$input ) {
                this.$input.prop( 'disabled', this.isDisabled() );
        }
@@ -6270,12 +6475,12 @@ OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
        this.pressed = false;
        this.selecting = null;
        this.hashes = {};
+       this.onMouseUpHandler = OO.ui.bind( this.onMouseUp, this );
+       this.onMouseMoveHandler = OO.ui.bind( this.onMouseMove, this );
 
        // Events
        this.$element.on( {
                'mousedown': OO.ui.bind( this.onMouseDown, this ),
-               'mouseup': OO.ui.bind( this.onMouseUp, this ),
-               'mousemove': OO.ui.bind( this.onMouseMove, this ),
                'mouseover': OO.ui.bind( this.onMouseOver, this ),
                'mouseleave': OO.ui.bind( this.onMouseLeave, this )
        } );
@@ -6349,7 +6554,12 @@ OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
                if ( item && item.isSelectable() ) {
                        this.pressItem( item );
                        this.selecting = item;
-                       this.$( this.$.context ).one( 'mouseup', OO.ui.bind( this.onMouseUp, this ) );
+                       this.getElementDocument().addEventListener(
+                               'mouseup', this.onMouseUpHandler, true
+                       );
+                       this.getElementDocument().addEventListener(
+                               'mousemove', this.onMouseMoveHandler, true
+                       );
                }
        }
        return false;
@@ -6377,6 +6587,13 @@ OO.ui.SelectWidget.prototype.onMouseUp = function ( e ) {
                this.selecting = null;
        }
 
+       this.getElementDocument().removeEventListener(
+               'mouseup', this.onMouseUpHandler, true
+       );
+       this.getElementDocument().removeEventListener(
+               'mousemove', this.onMouseMoveHandler, true
+       );
+
        return false;
 };
 
@@ -6680,7 +6897,8 @@ OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
                this.removeItems( remove );
        }
 
-       OO.ui.GroupElement.prototype.addItems.call( this, items, index );
+       // Mixin method
+       OO.ui.GroupWidget.prototype.addItems.call( this, items, index );
 
        // Always provide an index, even if it was omitted
        this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
@@ -6711,7 +6929,9 @@ OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
                        this.selectItem( null );
                }
        }
-       OO.ui.GroupElement.prototype.removeItems.call( this, items );
+
+       // Mixin method
+       OO.ui.GroupWidget.prototype.removeItems.call( this, items );
 
        this.emit( 'remove', items );
 
@@ -6731,7 +6951,8 @@ OO.ui.SelectWidget.prototype.clearItems = function () {
 
        // Clear all items
        this.hashes = {};
-       OO.ui.GroupElement.prototype.clearItems.call( this );
+       // Mixin method
+       OO.ui.GroupWidget.prototype.clearItems.call( this );
        this.selectItem( null );
 
        this.emit( 'remove', items );
@@ -6940,7 +7161,7 @@ OO.ui.MenuWidget.prototype.addItems = function ( items, index ) {
        var i, len, item;
 
        // Parent method
-       OO.ui.SelectWidget.prototype.addItems.call( this, items, index );
+       OO.ui.MenuWidget.super.prototype.addItems.call( this, items, index );
 
        // Auto-initialize
        if ( !this.newItems ) {
@@ -7232,9 +7453,9 @@ OO.ui.OutlineControlsWidget = function OoUiOutlineControlsWidget( outline, confi
                'add': 'onOutlineChange',
                'remove': 'onOutlineChange'
        } );
-       this.upButton.connect( this, { 'click': ['emit', 'move', -1] } );
-       this.downButton.connect( this, { 'click': ['emit', 'move', 1] } );
-       this.removeButton.connect( this, { 'click': ['emit', 'remove'] } );
+       this.upButton.connect( this, { 'click': [ 'emit', 'move', -1 ] } );
+       this.downButton.connect( this, { 'click': [ 'emit', 'move', 1 ] } );
+       this.removeButton.connect( this, { 'click': [ 'emit', 'remove' ] } );
 
        // Initialization
        this.$element.addClass( 'oo-ui-outlineControlsWidget' );
@@ -7455,13 +7676,18 @@ OO.inheritClass( OO.ui.ButtonOptionWidget, OO.ui.OptionWidget );
 OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.ButtonedElement );
 OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.FlaggableElement );
 
+/* Static Properties */
+
+// Allow button mouse down events to pass through so they can be handled by the parent select widget
+OO.ui.ButtonOptionWidget.static.cancelButtonMouseDownEvents = false;
+
 /* Methods */
 
 /**
  * @inheritdoc
  */
 OO.ui.ButtonOptionWidget.prototype.setSelected = function ( state ) {
-       OO.ui.OptionWidget.prototype.setSelected.call( this, state );
+       OO.ui.ButtonOptionWidget.super.prototype.setSelected.call( this, state );
 
        this.setActive( state );
 
@@ -7781,7 +8007,7 @@ OO.ui.PopupButtonWidget.prototype.onClick = function ( e ) {
                } else {
                        this.showPopup();
                }
-               OO.ui.ButtonWidget.prototype.onClick.call( this );
+               OO.ui.PopupButtonWidget.super.prototype.onClick.call( this );
        }
        return false;
 };
@@ -8030,7 +8256,7 @@ OO.ui.TextInputWidget.prototype.onEdit = function () {
        this.adjustSize();
 
        // Parent method
-       return OO.ui.InputWidget.prototype.onEdit.call( this );
+       return OO.ui.TextInputWidget.super.prototype.onEdit.call( this );
 };
 
 /**
@@ -8196,7 +8422,7 @@ OO.ui.TextInputMenuWidget.prototype.onWindowResize = function () {
  */
 OO.ui.TextInputMenuWidget.prototype.show = function () {
        // Parent method
-       OO.ui.MenuWidget.prototype.show.call( this );
+       OO.ui.TextInputMenuWidget.super.prototype.show.call( this );
 
        this.position();
        this.$( this.getElementWindow() ).on( 'resize', this.onWindowResizeHandler );
@@ -8210,7 +8436,7 @@ OO.ui.TextInputMenuWidget.prototype.show = function () {
  */
 OO.ui.TextInputMenuWidget.prototype.hide = function () {
        // Parent method
-       OO.ui.MenuWidget.prototype.hide.call( this );
+       OO.ui.TextInputMenuWidget.super.prototype.hide.call( this );
 
        this.$( this.getElementWindow() ).off( 'resize', this.onWindowResizeHandler );
        return this;
@@ -8350,7 +8576,7 @@ OO.ui.ToggleButtonWidget.prototype.onClick = function () {
        }
 
        // Parent method
-       return OO.ui.ButtonWidget.prototype.onClick.call( this );
+       return OO.ui.ToggleButtonWidget.super.prototype.onClick.call( this );
 };
 
 /**
@@ -8362,7 +8588,7 @@ OO.ui.ToggleButtonWidget.prototype.setValue = function ( value ) {
                this.setActive( value );
        }
 
-       // Parent method
+       // Parent method (from mixin)
        OO.ui.ToggleWidget.prototype.setValue.call( this, value );
 
        return this;