diff options
Diffstat (limited to 'vendor/oojs/oojs-ui/src/widgets/PopupWidget.js')
-rw-r--r-- | vendor/oojs/oojs-ui/src/widgets/PopupWidget.js | 395 |
1 files changed, 395 insertions, 0 deletions
diff --git a/vendor/oojs/oojs-ui/src/widgets/PopupWidget.js b/vendor/oojs/oojs-ui/src/widgets/PopupWidget.js new file mode 100644 index 00000000..0b1f4ca6 --- /dev/null +++ b/vendor/oojs/oojs-ui/src/widgets/PopupWidget.js @@ -0,0 +1,395 @@ +/** + * 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: $( '<p>Hi there!</p>' ), + * 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 = $( '<div>' ); + + // Mixin constructors + OO.ui.LabelElement.call( this, config ); + OO.ui.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$body } ) ); + + // Properties + this.$popup = $( '<div>' ); + this.$head = $( '<div>' ); + this.$anchor = $( '<div>' ); + // 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; +}; |