/** * Each Element represents a rendering in the DOM—a button or an icon, for example, or anything * that is visible to a user. Unlike {@link OO.ui.Widget widgets}, plain elements usually do not have events * connected to them and can't be interacted with. * * @abstract * @class * * @constructor * @param {Object} [config] Configuration options * @cfg {string[]} [classes] The names of the CSS classes to apply to the element. CSS styles are added * to the top level (e.g., the outermost div) of the element. See the [OOjs UI documentation on MediaWiki][2] * for an example. * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches#cssExample * @cfg {string} [id] The HTML id attribute used in the rendered tag. * @cfg {string} [text] Text to insert * @cfg {Array} [content] An array of content elements to append (after #text). * Strings will be html-escaped; use an OO.ui.HtmlSnippet to append raw HTML. * Instances of OO.ui.Element will have their $element appended. * @cfg {jQuery} [$content] Content elements to append (after #text) * @cfg {Mixed} [data] Custom data of any type or combination of types (e.g., string, number, array, object). * Data can also be specified with the #setData method. */ OO.ui.Element = function OoUiElement( config ) { // Configuration initialization config = config || {}; // Properties this.$ = $; this.visible = true; this.data = config.data; this.$element = config.$element || $( document.createElement( this.getTagName() ) ); this.elementGroup = null; this.debouncedUpdateThemeClassesHandler = this.debouncedUpdateThemeClasses.bind( this ); this.updateThemeClassesPending = false; // Initialization if ( Array.isArray( config.classes ) ) { this.$element.addClass( config.classes.join( ' ' ) ); } if ( config.id ) { this.$element.attr( 'id', config.id ); } if ( config.text ) { this.$element.text( config.text ); } if ( config.content ) { // The `content` property treats plain strings as text; use an // HtmlSnippet to append HTML content. `OO.ui.Element`s get their // appropriate $element appended. this.$element.append( config.content.map( function ( v ) { if ( typeof v === 'string' ) { // Escape string so it is properly represented in HTML. return document.createTextNode( v ); } else if ( v instanceof OO.ui.HtmlSnippet ) { // Bypass escaping. return v.toString(); } else if ( v instanceof OO.ui.Element ) { return v.$element; } return v; } ) ); } if ( config.$content ) { // The `$content` property treats plain strings as HTML. this.$element.append( config.$content ); } }; /* Setup */ OO.initClass( OO.ui.Element ); /* Static Properties */ /** * The name of the HTML tag used by the element. * * The static value may be ignored if the #getTagName method is overridden. * * @static * @inheritable * @property {string} */ OO.ui.Element.static.tagName = 'div'; /* Static Methods */ /** * Reconstitute a JavaScript object corresponding to a widget created * by the PHP implementation. * * @param {string|HTMLElement|jQuery} idOrNode * A DOM id (if a string) or node for the widget to infuse. * @return {OO.ui.Element} * The `OO.ui.Element` corresponding to this (infusable) document node. * For `Tag` objects emitted on the HTML side (used occasionally for content) * the value returned is a newly-created Element wrapping around the existing * DOM node. */ OO.ui.Element.static.infuse = function ( idOrNode ) { var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, true ); // Verify that the type matches up. // FIXME: uncomment after T89721 is fixed (see T90929) /* if ( !( obj instanceof this['class'] ) ) { throw new Error( 'Infusion type mismatch!' ); } */ return obj; }; /** * Implementation helper for `infuse`; skips the type check and has an * extra property so that only the top-level invocation touches the DOM. * @private * @param {string|HTMLElement|jQuery} idOrNode * @param {boolean} top True only for top-level invocation. * @return {OO.ui.Element} */ OO.ui.Element.static.unsafeInfuse = function ( idOrNode, top ) { // look for a cached result of a previous infusion. var id, $elem, data, cls, obj; if ( typeof idOrNode === 'string' ) { id = idOrNode; $elem = $( document.getElementById( id ) ); } else { $elem = $( idOrNode ); id = $elem.attr( 'id' ); } data = $elem.data( 'ooui-infused' ); if ( data ) { // cached! if ( data === true ) { throw new Error( 'Circular dependency! ' + id ); } return data; } if ( !$elem.length ) { throw new Error( 'Widget not found: ' + id ); } data = $elem.attr( 'data-ooui' ); if ( !data ) { throw new Error( 'No infusion data found: ' + id ); } try { data = $.parseJSON( data ); } catch ( _ ) { data = null; } if ( !( data && data._ ) ) { throw new Error( 'No valid infusion data found: ' + id ); } if ( data._ === 'Tag' ) { // Special case: this is a raw Tag; wrap existing node, don't rebuild. return new OO.ui.Element( { $element: $elem } ); } cls = OO.ui[data._]; if ( !cls ) { throw new Error( 'Unknown widget type: ' + id ); } $elem.data( 'ooui-infused', true ); // prevent loops data.id = id; // implicit data = OO.copy( data, null, function deserialize( value ) { if ( OO.isPlainObject( value ) ) { if ( value.tag ) { return OO.ui.Element.static.unsafeInfuse( value.tag, false ); } if ( value.html ) { return new OO.ui.HtmlSnippet( value.html ); } } } ); // jscs:disable requireCapitalizedConstructors obj = new cls( data ); // rebuild widget // now replace old DOM with this new DOM. if ( top ) { $elem.replaceWith( obj.$element ); } obj.$element.data( 'ooui-infused', obj ); // set the 'data-ooui' attribute so we can identify infused widgets obj.$element.attr( 'data-ooui', '' ); return obj; }; /** * Get a jQuery function within a specific document. * * @static * @param {jQuery|HTMLElement|HTMLDocument|Window} context Context to bind the function to * @param {jQuery} [$iframe] HTML iframe element that contains the document, omit if document is * not in an iframe * @return {Function} Bound jQuery function */ OO.ui.Element.static.getJQuery = function ( context, $iframe ) { function wrapper( selector ) { return $( selector, wrapper.context ); } wrapper.context = this.getDocument( context ); if ( $iframe ) { wrapper.$iframe = $iframe; } return wrapper; }; /** * Get the document of an element. * * @static * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Object to get the document for * @return {HTMLDocument|null} Document object */ OO.ui.Element.static.getDocument = function ( obj ) { // jQuery - selections created "offscreen" won't have a context, so .context isn't reliable return ( obj[ 0 ] && obj[ 0 ].ownerDocument ) || // Empty jQuery selections might have a context obj.context || // HTMLElement obj.ownerDocument || // Window obj.document || // HTMLDocument ( obj.nodeType === 9 && obj ) || null; }; /** * Get the window of an element or document. * * @static * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the window for * @return {Window} Window object */ OO.ui.Element.static.getWindow = function ( obj ) { var doc = this.getDocument( obj ); return doc.parentWindow || doc.defaultView; }; /** * Get the direction of an element or document. * * @static * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the direction for * @return {string} Text direction, either 'ltr' or 'rtl' */ OO.ui.Element.static.getDir = function ( obj ) { var isDoc, isWin; if ( obj instanceof jQuery ) { obj = obj[ 0 ]; } isDoc = obj.nodeType === 9; isWin = obj.document !== undefined; if ( isDoc || isWin ) { if ( isWin ) { obj = obj.document; } obj = obj.body; } return $( obj ).css( 'direction' ); }; /** * Get the offset between two frames. * * TODO: Make this function not use recursion. * * @static * @param {Window} from Window of the child frame * @param {Window} [to=window] Window of the parent frame * @param {Object} [offset] Offset to start with, used internally * @return {Object} Offset object, containing left and top properties */ OO.ui.Element.static.getFrameOffset = function ( from, to, offset ) { var i, len, frames, frame, rect; if ( !to ) { to = window; } if ( !offset ) { offset = { top: 0, left: 0 }; } if ( from.parent === from ) { return offset; } // Get iframe element frames = from.parent.document.getElementsByTagName( 'iframe' ); for ( i = 0, len = frames.length; i < len; i++ ) { if ( frames[ i ].contentWindow === from ) { frame = frames[ i ]; break; } } // Recursively accumulate offset values if ( frame ) { rect = frame.getBoundingClientRect(); offset.left += rect.left; offset.top += rect.top; if ( from !== to ) { this.getFrameOffset( from.parent, offset ); } } return offset; }; /** * Get the offset between two elements. * * The two elements may be in a different frame, but in that case the frame $element is in must * be contained in the frame $anchor is in. * * @static * @param {jQuery} $element Element whose position to get * @param {jQuery} $anchor Element to get $element's position relative to * @return {Object} Translated position coordinates, containing top and left properties */ OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) { var iframe, iframePos, pos = $element.offset(), anchorPos = $anchor.offset(), elementDocument = this.getDocument( $element ), anchorDocument = this.getDocument( $anchor ); // If $element isn't in the same document as $anchor, traverse up while ( elementDocument !== anchorDocument ) { iframe = elementDocument.defaultView.frameElement; if ( !iframe ) { throw new Error( '$element frame is not contained in $anchor frame' ); } iframePos = $( iframe ).offset(); pos.left += iframePos.left; pos.top += iframePos.top; elementDocument = iframe.ownerDocument; } pos.left -= anchorPos.left; pos.top -= anchorPos.top; return pos; }; /** * Get element border sizes. * * @static * @param {HTMLElement} el Element to measure * @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties */ OO.ui.Element.static.getBorders = function ( el ) { var doc = el.ownerDocument, win = doc.parentWindow || doc.defaultView, style = win && win.getComputedStyle ? win.getComputedStyle( el, null ) : el.currentStyle, $el = $( el ), top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0, left = parseFloat( style ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0, bottom = parseFloat( style ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0, right = parseFloat( style ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0; return { top: top, left: left, bottom: bottom, right: right }; }; /** * Get dimensions of an element or window. * * @static * @param {HTMLElement|Window} el Element to measure * @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties */ OO.ui.Element.static.getDimensions = function ( el ) { var $el, $win, doc = el.ownerDocument || el.document, win = doc.parentWindow || doc.defaultView; if ( win === el || el === doc.documentElement ) { $win = $( win ); return { borders: { top: 0, left: 0, bottom: 0, right: 0 }, scroll: { top: $win.scrollTop(), left: $win.scrollLeft() }, scrollbar: { right: 0, bottom: 0 }, rect: { top: 0, left: 0, bottom: $win.innerHeight(), right: $win.innerWidth() } }; } else { $el = $( el ); return { borders: this.getBorders( el ), scroll: { top: $el.scrollTop(), left: $el.scrollLeft() }, scrollbar: { right: $el.innerWidth() - el.clientWidth, bottom: $el.innerHeight() - el.clientHeight }, rect: el.getBoundingClientRect() }; } }; /** * Get scrollable object parent * * documentElement can't be used to get or set the scrollTop * property on Blink. Changing and testing its value lets us * use 'body' or 'documentElement' based on what is working. * * https://code.google.com/p/chromium/issues/detail?id=303131 * * @static * @param {HTMLElement} el Element to find scrollable parent for * @return {HTMLElement} Scrollable parent */ OO.ui.Element.static.getRootScrollableElement = function ( el ) { var scrollTop, body; if ( OO.ui.scrollableElement === undefined ) { body = el.ownerDocument.body; scrollTop = body.scrollTop; body.scrollTop = 1; if ( body.scrollTop === 1 ) { body.scrollTop = scrollTop; OO.ui.scrollableElement = 'body'; } else { OO.ui.scrollableElement = 'documentElement'; } } return el.ownerDocument[ OO.ui.scrollableElement ]; }; /** * Get closest scrollable container. * * Traverses up until either a scrollable element or the root is reached, in which case the window * will be returned. * * @static * @param {HTMLElement} el Element to find scrollable container for * @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either * @return {HTMLElement} Closest scrollable container */ OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) { var i, val, props = [ 'overflow' ], $parent = $( el ).parent(); if ( dimension === 'x' || dimension === 'y' ) { props.push( 'overflow-' + dimension ); } while ( $parent.length ) { if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) { return $parent[ 0 ]; } i = props.length; while ( i-- ) { val = $parent.css( props[ i ] ); if ( val === 'auto' || val === 'scroll' ) { return $parent[ 0 ]; } } $parent = $parent.parent(); } return this.getDocument( el ).body; }; /** * Scroll element into view. * * @static * @param {HTMLElement} el Element to scroll into view * @param {Object} [config] Configuration options * @param {string} [config.duration] jQuery animation duration value * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit * to scroll in both directions * @param {Function} [config.complete] Function to call when scrolling completes */ OO.ui.Element.static.scrollIntoView = function ( el, config ) { // Configuration initialization config = config || {}; var rel, anim = {}, callback = typeof config.complete === 'function' && config.complete, sc = this.getClosestScrollableContainer( el, config.direction ), $sc = $( sc ), eld = this.getDimensions( el ), scd = this.getDimensions( sc ), $win = $( this.getWindow( el ) ); // Compute the distances between the edges of el and the edges of the scroll viewport if ( $sc.is( 'html, body' ) ) { // If the scrollable container is the root, this is easy rel = { top: eld.rect.top, bottom: $win.innerHeight() - eld.rect.bottom, left: eld.rect.left, right: $win.innerWidth() - eld.rect.right }; } else { // Otherwise, we have to subtract el's coordinates from sc's coordinates rel = { top: eld.rect.top - ( scd.rect.top + scd.borders.top ), bottom: scd.rect.bottom - scd.borders.bottom - scd.scrollbar.bottom - eld.rect.bottom, left: eld.rect.left - ( scd.rect.left + scd.borders.left ), right: scd.rect.right - scd.borders.right - scd.scrollbar.right - eld.rect.right }; } if ( !config.direction || config.direction === 'y' ) { if ( rel.top < 0 ) { anim.scrollTop = scd.scroll.top + rel.top; } else if ( rel.top > 0 && rel.bottom < 0 ) { anim.scrollTop = scd.scroll.top + Math.min( rel.top, -rel.bottom ); } } if ( !config.direction || config.direction === 'x' ) { if ( rel.left < 0 ) { anim.scrollLeft = scd.scroll.left + rel.left; } else if ( rel.left > 0 && rel.right < 0 ) { anim.scrollLeft = scd.scroll.left + Math.min( rel.left, -rel.right ); } } if ( !$.isEmptyObject( anim ) ) { $sc.stop( true ).animate( anim, config.duration || 'fast' ); if ( callback ) { $sc.queue( function ( next ) { callback(); next(); } ); } } else { if ( callback ) { callback(); } } }; /** * Force the browser to reconsider whether it really needs to render scrollbars inside the element * and reserve space for them, because it probably doesn't. * * Workaround primarily for , but also * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need * to first actually detach (or hide, but detaching is simpler) all children, *then* force a reflow, * and then reattach (or show) them back. * * @static * @param {HTMLElement} el Element to reconsider the scrollbars on */ OO.ui.Element.static.reconsiderScrollbars = function ( el ) { var i, len, nodes = []; // Detach all children while ( el.firstChild ) { nodes.push( el.firstChild ); el.removeChild( el.firstChild ); } // Force reflow void el.offsetHeight; // Reattach all children for ( i = 0, len = nodes.length; i < len; i++ ) { el.appendChild( nodes[ i ] ); } }; /* Methods */ /** * Toggle visibility of an element. * * @param {boolean} [show] Make element visible, omit to toggle visibility * @fires visible * @chainable */ OO.ui.Element.prototype.toggle = function ( show ) { show = show === undefined ? !this.visible : !!show; if ( show !== this.isVisible() ) { this.visible = show; this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible ); this.emit( 'toggle', show ); } return this; }; /** * Check if element is visible. * * @return {boolean} element is visible */ OO.ui.Element.prototype.isVisible = function () { return this.visible; }; /** * Get element data. * * @return {Mixed} Element data */ OO.ui.Element.prototype.getData = function () { return this.data; }; /** * Set element data. * * @param {Mixed} Element data * @chainable */ OO.ui.Element.prototype.setData = function ( data ) { this.data = data; return this; }; /** * Check if element supports one or more methods. * * @param {string|string[]} methods Method or list of methods to check * @return {boolean} All methods are supported */ OO.ui.Element.prototype.supports = function ( methods ) { var i, len, support = 0; methods = Array.isArray( methods ) ? methods : [ methods ]; for ( i = 0, len = methods.length; i < len; i++ ) { if ( $.isFunction( this[ methods[ i ] ] ) ) { support++; } } return methods.length === support; }; /** * Update the theme-provided classes. * * @localdoc This is called in element mixins and widget classes any time state changes. * Updating is debounced, minimizing overhead of changing multiple attributes and * guaranteeing that theme updates do not occur within an element's constructor */ OO.ui.Element.prototype.updateThemeClasses = function () { if ( !this.updateThemeClassesPending ) { this.updateThemeClassesPending = true; setTimeout( this.debouncedUpdateThemeClassesHandler ); } }; /** * @private */ OO.ui.Element.prototype.debouncedUpdateThemeClasses = function () { OO.ui.theme.updateElementClasses( this ); this.updateThemeClassesPending = false; }; /** * Get the HTML tag name. * * Override this method to base the result on instance information. * * @return {string} HTML tag name */ OO.ui.Element.prototype.getTagName = function () { return this.constructor.static.tagName; }; /** * Check if the element is attached to the DOM * @return {boolean} The element is attached to the DOM */ OO.ui.Element.prototype.isElementAttached = function () { return $.contains( this.getElementDocument(), this.$element[ 0 ] ); }; /** * Get the DOM document. * * @return {HTMLDocument} Document object */ OO.ui.Element.prototype.getElementDocument = function () { // Don't cache this in other ways either because subclasses could can change this.$element return OO.ui.Element.static.getDocument( this.$element ); }; /** * Get the DOM window. * * @return {Window} Window object */ OO.ui.Element.prototype.getElementWindow = function () { return OO.ui.Element.static.getWindow( this.$element ); }; /** * Get closest scrollable container. */ OO.ui.Element.prototype.getClosestScrollableElementContainer = function () { return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] ); }; /** * Get group element is in. * * @return {OO.ui.GroupElement|null} Group element, null if none */ OO.ui.Element.prototype.getElementGroup = function () { return this.elementGroup; }; /** * Set group element is in. * * @param {OO.ui.GroupElement|null} group Group element, null if none * @chainable */ OO.ui.Element.prototype.setElementGroup = function ( group ) { this.elementGroup = group; return this; }; /** * Scroll element into view. * * @param {Object} [config] Configuration options */ OO.ui.Element.prototype.scrollElementIntoView = function ( config ) { return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config ); };