/** * PopupWidget is a container for content. The popup is overlaid and positioned absolutely. * By default, each popup has an anchor that points toward its origin. * Please see the [OOjs UI documentation on Mediawiki] [1] for more information and examples. * * @example * // A popup widget. * var popup = new OO.ui.PopupWidget( { * $content: $( '

Hi there!

' ), * padded: true, * width: 300 * } ); * * $( 'body' ).append( popup.$element ); * // To display the popup, toggle the visibility to 'true'. * popup.toggle( true ); * * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups * * @class * @extends OO.ui.Widget * @mixins OO.ui.LabelElement * * @constructor * @param {Object} [config] Configuration options * @cfg {number} [width=320] Width of popup in pixels * @cfg {number} [height] Height of popup in pixels. Omit to use the automatic height. * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup * @cfg {string} [align='center'] Alignment of the popup: `center`, `force-left`, `force-right`, `backwards` or `forwards`. * If the popup is forced-left the popup body is leaning towards the left. For force-right alignment, the body of the * popup is leaning towards the right of the screen. * Using 'backwards' is a logical direction which will result in the popup leaning towards the beginning of the sentence * in the given language, which means it will flip to the correct positioning in right-to-left languages. * Using 'forward' will also result in a logical alignment where the body of the popup leans towards the end of the * sentence in the given language. * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container. * See the [OOjs UI docs on MediaWiki][3] for an example. * [3]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#containerExample * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a number of pixels. * @cfg {jQuery} [$content] Content to append to the popup's body * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus. * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked. * This config option is only relevant if #autoClose is set to `true`. See the [OOjs UI docs on MediaWiki][2] * for an example. * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#autocloseExample * @cfg {boolean} [head] Show a popup header that contains a #label (if specified) and close * button. * @cfg {boolean} [padded] Add padding to the popup's body */ OO.ui.PopupWidget = function OoUiPopupWidget( config ) { // Configuration initialization config = config || {}; // Parent constructor OO.ui.PopupWidget.super.call( this, config ); // Properties (must be set before ClippableElement constructor call) this.$body = $( '
' ); // Mixin constructors OO.ui.LabelElement.call( this, config ); OO.ui.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$body } ) ); // Properties this.$popup = $( '
' ); this.$head = $( '
' ); this.$anchor = $( '
' ); // If undefined, will be computed lazily in updateDimensions() this.$container = config.$container; this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10; this.autoClose = !!config.autoClose; this.$autoCloseIgnore = config.$autoCloseIgnore; this.transitionTimeout = null; this.anchor = null; this.width = config.width !== undefined ? config.width : 320; this.height = config.height !== undefined ? config.height : null; this.setAlignment( config.align ); this.closeButton = new OO.ui.ButtonWidget( { framed: false, icon: 'close' } ); this.onMouseDownHandler = this.onMouseDown.bind( this ); this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this ); // Events this.closeButton.connect( this, { click: 'onCloseButtonClick' } ); // Initialization this.toggleAnchor( config.anchor === undefined || config.anchor ); this.$body.addClass( 'oo-ui-popupWidget-body' ); this.$anchor.addClass( 'oo-ui-popupWidget-anchor' ); this.$head .addClass( 'oo-ui-popupWidget-head' ) .append( this.$label, this.closeButton.$element ); if ( !config.head ) { this.$head.addClass( 'oo-ui-element-hidden' ); } this.$popup .addClass( 'oo-ui-popupWidget-popup' ) .append( this.$head, this.$body ); this.$element .addClass( 'oo-ui-popupWidget' ) .append( this.$popup, this.$anchor ); // Move content, which was added to #$element by OO.ui.Widget, to the body if ( config.$content instanceof jQuery ) { this.$body.append( config.$content ); } if ( config.padded ) { this.$body.addClass( 'oo-ui-popupWidget-body-padded' ); } // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods // that reference properties not initialized at that time of parent class construction // TODO: Find a better way to handle post-constructor setup this.visible = false; this.$element.addClass( 'oo-ui-element-hidden' ); }; /* Setup */ OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget ); OO.mixinClass( OO.ui.PopupWidget, OO.ui.LabelElement ); OO.mixinClass( OO.ui.PopupWidget, OO.ui.ClippableElement ); /* Methods */ /** * Handles mouse down events. * * @private * @param {MouseEvent} e Mouse down event */ OO.ui.PopupWidget.prototype.onMouseDown = function ( e ) { if ( this.isVisible() && !$.contains( this.$element[ 0 ], e.target ) && ( !this.$autoCloseIgnore || !this.$autoCloseIgnore.has( e.target ).length ) ) { this.toggle( false ); } }; /** * Bind mouse down listener. * * @private */ OO.ui.PopupWidget.prototype.bindMouseDownListener = function () { // Capture clicks outside popup this.getElementWindow().addEventListener( 'mousedown', this.onMouseDownHandler, true ); }; /** * Handles close button click events. * * @private */ OO.ui.PopupWidget.prototype.onCloseButtonClick = function () { if ( this.isVisible() ) { this.toggle( false ); } }; /** * Unbind mouse down listener. * * @private */ OO.ui.PopupWidget.prototype.unbindMouseDownListener = function () { this.getElementWindow().removeEventListener( 'mousedown', this.onMouseDownHandler, true ); }; /** * Handles key down events. * * @private * @param {KeyboardEvent} e Key down event */ OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) { if ( e.which === OO.ui.Keys.ESCAPE && this.isVisible() ) { this.toggle( false ); e.preventDefault(); e.stopPropagation(); } }; /** * Bind key down listener. * * @private */ OO.ui.PopupWidget.prototype.bindKeyDownListener = function () { this.getElementWindow().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true ); }; /** * Unbind key down listener. * * @private */ OO.ui.PopupWidget.prototype.unbindKeyDownListener = function () { this.getElementWindow().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true ); }; /** * Show, hide, or toggle the visibility of the anchor. * * @param {boolean} [show] Show anchor, omit to toggle */ OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) { show = show === undefined ? !this.anchored : !!show; if ( this.anchored !== show ) { if ( show ) { this.$element.addClass( 'oo-ui-popupWidget-anchored' ); } else { this.$element.removeClass( 'oo-ui-popupWidget-anchored' ); } this.anchored = show; } }; /** * Check if the anchor is visible. * * @return {boolean} Anchor is visible */ OO.ui.PopupWidget.prototype.hasAnchor = function () { return this.anchor; }; /** * @inheritdoc */ OO.ui.PopupWidget.prototype.toggle = function ( show ) { show = show === undefined ? !this.isVisible() : !!show; var change = show !== this.isVisible(); // Parent method OO.ui.PopupWidget.super.prototype.toggle.call( this, show ); if ( change ) { if ( show ) { if ( this.autoClose ) { this.bindMouseDownListener(); this.bindKeyDownListener(); } this.updateDimensions(); this.toggleClipping( true ); } else { this.toggleClipping( false ); if ( this.autoClose ) { this.unbindMouseDownListener(); this.unbindKeyDownListener(); } } } return this; }; /** * Set the size of the popup. * * Changing the size may also change the popup's position depending on the alignment. * * @param {number} width Width in pixels * @param {number} height Height in pixels * @param {boolean} [transition=false] Use a smooth transition * @chainable */ OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) { this.width = width; this.height = height !== undefined ? height : null; if ( this.isVisible() ) { this.updateDimensions( transition ); } }; /** * Update the size and position. * * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will * be called automatically. * * @param {boolean} [transition=false] Use a smooth transition * @chainable */ OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) { var popupOffset, originOffset, containerLeft, containerWidth, containerRight, popupLeft, popupRight, overlapLeft, overlapRight, anchorWidth, align = this.align, widget = this; if ( !this.$container ) { // Lazy-initialize $container if not specified in constructor this.$container = $( this.getClosestScrollableElementContainer() ); } // Set height and width before measuring things, since it might cause our measurements // to change (e.g. due to scrollbars appearing or disappearing) this.$popup.css( { width: this.width, height: this.height !== null ? this.height : 'auto' } ); // If we are in RTL, we need to flip the alignment, unless it is center if ( align === 'forwards' || align === 'backwards' ) { if ( this.$container.css( 'direction' ) === 'rtl' ) { align = ( { forwards: 'force-left', backwards: 'force-right' } )[ this.align ]; } else { align = ( { forwards: 'force-right', backwards: 'force-left' } )[ this.align ]; } } // Compute initial popupOffset based on alignment popupOffset = this.width * ( { 'force-left': -1, center: -0.5, 'force-right': 0 } )[ align ]; // Figure out if this will cause the popup to go beyond the edge of the container originOffset = this.$element.offset().left; containerLeft = this.$container.offset().left; containerWidth = this.$container.innerWidth(); containerRight = containerLeft + containerWidth; popupLeft = popupOffset - this.containerPadding; popupRight = popupOffset + this.containerPadding + this.width + this.containerPadding; overlapLeft = ( originOffset + popupLeft ) - containerLeft; overlapRight = containerRight - ( originOffset + popupRight ); // Adjust offset to make the popup not go beyond the edge, if needed if ( overlapRight < 0 ) { popupOffset += overlapRight; } else if ( overlapLeft < 0 ) { popupOffset -= overlapLeft; } // Adjust offset to avoid anchor being rendered too close to the edge // $anchor.width() doesn't work with the pure CSS anchor (returns 0) // TODO: Find a measurement that works for CSS anchors and image anchors anchorWidth = this.$anchor[ 0 ].scrollWidth * 2; if ( popupOffset + this.width < anchorWidth ) { popupOffset = anchorWidth - this.width; } else if ( -popupOffset < anchorWidth ) { popupOffset = -anchorWidth; } // Prevent transition from being interrupted clearTimeout( this.transitionTimeout ); if ( transition ) { // Enable transition this.$element.addClass( 'oo-ui-popupWidget-transitioning' ); } // Position body relative to anchor this.$popup.css( 'margin-left', popupOffset ); if ( transition ) { // Prevent transitioning after transition is complete this.transitionTimeout = setTimeout( function () { widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' ); }, 200 ); } else { // Prevent transitioning immediately this.$element.removeClass( 'oo-ui-popupWidget-transitioning' ); } // Reevaluate clipping state since we've relocated and resized the popup this.clip(); return this; }; /** * Set popup alignment * @param {string} align Alignment of the popup, `center`, `force-left`, `force-right`, * `backwards` or `forwards`. */ OO.ui.PopupWidget.prototype.setAlignment = function ( align ) { // Validate alignment and transform deprecated values if ( [ 'left', 'right', 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) { this.align = { left: 'force-right', right: 'force-left' }[ align ] || align; } else { this.align = 'center'; } }; /** * Get popup alignment * @return {string} align Alignment of the popup, `center`, `force-left`, `force-right`, * `backwards` or `forwards`. */ OO.ui.PopupWidget.prototype.getAlignment = function () { return this.align; };