// getDocument( element ) is preferrable to window.document
/* global document:off */

/**
 * 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
 * @param {string[]} [config.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
 *  [OOUI documentation on MediaWiki][2] for an example.
 *  [2]: https://www.mediawiki.org/wiki/OOUI/Widgets/Buttons_and_Switches#cssExample
 * @param {string} [config.id] The HTML id attribute used in the rendered tag.
 * @param {string} [config.text] Text to insert
 * @param {Array} [config.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.
 * @param {jQuery} [config.$content] Content elements to append (after #text).
 * @param {jQuery} [config.$element] Wrapper element. Defaults to a new element with #getTagName.
 * @param {any} [config.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 ) {
	if ( OO.ui.isDemo ) {
		this.initialConfig = config;
	}
	// Configuration initialization
	config = config || {};

	// Properties
	this.elementId = null;
	this.visible = true;
	this.data = config.data;
	this.$element = config.$element ||
		$( window.document.createElement( this.getTagName() ) );
	this.elementGroup = null;

	// Initialization
	const doc = OO.ui.Element.static.getDocument( this.$element );
	if ( Array.isArray( config.classes ) ) {
		this.$element.addClass( config.classes );
	}
	if ( config.id ) {
		this.setElementId( 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( ( v ) => {
			if ( typeof v === 'string' ) {
				// Escape string so it is properly represented in HTML.
				// Don't create empty text nodes for empty strings.
				return v ? doc.createTextNode( v ) : undefined;
			} 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
 * @property {string}
 */
OO.ui.Element.static.tagName = 'div';

/* Static Methods */

/**
 * Reconstitute a JavaScript object corresponding to a widget created
 * by the PHP implementation.
 *
 * @param {HTMLElement|jQuery} node
 *   A single node for the widget to infuse.
 * @param {Object} [config] Configuration options
 * @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 ( node, config ) {
	const obj = OO.ui.Element.static.unsafeInfuse( node, config, false );

	// 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 {HTMLElement|jQuery} elem
 * @param {Object} [config] Configuration options
 * @param {jQuery.Promise} [domPromise] A promise that will be resolved
 *     when the top-level widget of this infusion is inserted into DOM,
 *     replacing the original element; only used internally.
 * @return {OO.ui.Element}
 */
OO.ui.Element.static.unsafeInfuse = function ( elem, config, domPromise ) {
	// look for a cached result of a previous infusion.
	let $elem = $( elem );

	if ( $elem.length > 1 ) {
		throw new Error( 'Collection contains more than one element' );
	}
	if ( !$elem.length ) {
		throw new Error( 'Widget not found' );
	}
	if ( $elem[ 0 ].$oouiInfused ) {
		$elem = $elem[ 0 ].$oouiInfused;
	}

	const id = $elem.attr( 'id' );
	const doc = this.getDocument( $elem );
	let data = $elem.data( 'ooui-infused' );
	if ( data ) {
		// cached!
		if ( data === true ) {
			throw new Error( 'Circular dependency! ' + id );
		}
		if ( domPromise ) {
			// Pick up dynamic state, like focus, value of form inputs, scroll position, etc.
			const stateCache = data.constructor.static.gatherPreInfuseState( $elem, data );
			// Restore dynamic state after the new element is re-inserted into DOM under
			// infused parent.
			domPromise.done( data.restorePreInfuseState.bind( data, stateCache ) );
			const infusedChildrenCache = $elem.data( 'ooui-infused-children' );
			if ( infusedChildrenCache && infusedChildrenCache.length ) {
				infusedChildrenCache.forEach( ( childData ) => {
					const childState = childData.constructor.static.gatherPreInfuseState(
						$elem,
						childData
					);
					domPromise.done(
						childData.restorePreInfuseState.bind( childData, childState )
					);
				} );
			}
		}
		return data;
	}
	data = $elem.attr( 'data-ooui' );
	if ( !data ) {
		throw new Error( 'No infusion data found: ' + id );
	}
	try {
		data = JSON.parse( 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( $.extend( {}, config, { $element: $elem } ) );
	}
	const parts = data._.split( '.' );
	const cls = OO.getProp.apply( OO, [ window ].concat( parts ) );

	if ( !( cls && ( cls === OO.ui.Element || cls.prototype instanceof OO.ui.Element ) ) ) {
		throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
	}

	let top;
	if ( !domPromise ) {
		top = $.Deferred();
		domPromise = top.promise();
	}
	$elem.data( 'ooui-infused', true ); // prevent loops
	data.id = id; // implicit
	const infusedChildren = [];
	data = OO.copy( data, null, ( value ) => {
		let infused;
		if ( OO.isPlainObject( value ) ) {
			if ( value.tag && doc.getElementById( value.tag ) ) {
				infused = OO.ui.Element.static.unsafeInfuse(
					doc.getElementById( value.tag ), config, domPromise
				);
				infusedChildren.push( infused );
				// Flatten the structure
				infusedChildren.push.apply(
					infusedChildren,
					infused.$element.data( 'ooui-infused-children' ) || []
				);
				infused.$element.removeData( 'ooui-infused-children' );
				return infused;
			}
			if ( value.html !== undefined ) {
				return new OO.ui.HtmlSnippet( value.html );
			}
		}
	} );
	// allow widgets to reuse parts of the DOM
	data = cls.static.reusePreInfuseDOM( $elem[ 0 ], data );
	// pick up dynamic state, like focus, value of form inputs, scroll position, etc.
	const state = cls.static.gatherPreInfuseState( $elem[ 0 ], data );
	// rebuild widget
	// eslint-disable-next-line new-cap
	const obj = new cls( $.extend( {}, config, data ) );
	// If anyone is holding a reference to the old DOM element,
	// let's allow them to OO.ui.infuse() it and do what they expect, see T105828.
	// Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design.
	$elem[ 0 ].$oouiInfused = obj.$element;
	// now replace old DOM with this new DOM.
	if ( top ) {
		// An efficient constructor might be able to reuse the entire DOM tree of the original
		// element, so only mutate the DOM if we need to.
		if ( $elem[ 0 ] !== obj.$element[ 0 ] ) {
			$elem.replaceWith( obj.$element );
		}
		top.resolve();
	}
	obj.$element
		.data( {
			'ooui-infused': obj,
			'ooui-infused-children': infusedChildren
		} )
		// set the 'data-ooui' attribute so we can identify infused widgets
		.attr( 'data-ooui', '' );
	// restore dynamic state after the new element is inserted into DOM
	domPromise.done( obj.restorePreInfuseState.bind( obj, state ) );
	return obj;
};

/**
 * Pick out parts of `node`'s DOM to be reused when infusing a widget.
 *
 * This method **must not** make any changes to the DOM, only find interesting pieces and add them
 * to `config` (which should then be returned). Actual DOM juggling should then be done by the
 * constructor, which will be given the enhanced config.
 *
 * @protected
 * @param {HTMLElement} node
 * @param {Object} config
 * @return {Object}
 */
OO.ui.Element.static.reusePreInfuseDOM = function ( node, config ) {
	return config;
};

/**
 * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of an HTML DOM
 * node (and its children) that represent an Element of the same class and the given configuration,
 * generated by the PHP implementation.
 *
 * This method is called just before `node` is detached from the DOM. The return value of this
 * function will be passed to #restorePreInfuseState after the newly created widget's #$element
 * is inserted into DOM to replace `node`.
 *
 * @protected
 * @param {HTMLElement} node
 * @param {Object} config
 * @return {Object}
 */
OO.ui.Element.static.gatherPreInfuseState = function () {
	return {};
};

/**
 * 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 ) {
	// HTMLElement
	return obj.ownerDocument ||
		// Window
		obj.document ||
		// HTMLDocument
		( obj.nodeType === Node.DOCUMENT_NODE && obj ) ||
		// jQuery - selections created "offscreen" won't have a context, so .context isn't reliable
		( obj[ 0 ] && obj[ 0 ].ownerDocument ) ||
		// Empty jQuery selections might have a context
		obj.context ||
		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 ) {
	const doc = this.getDocument( obj );
	return 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 ) {
	if ( obj instanceof $ ) {
		obj = obj[ 0 ];
	}
	const isDoc = obj.nodeType === Node.DOCUMENT_NODE;
	const 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 ) {
	if ( !to ) {
		to = window;
	}
	if ( !offset ) {
		offset = { top: 0, left: 0 };
	}
	if ( from.parent === from ) {
		return offset;
	}

	// Get iframe element
	let frame;
	const frames = from.parent.document.getElementsByTagName( 'iframe' );
	for ( let i = 0, len = frames.length; i < len; i++ ) {
		if ( frames[ i ].contentWindow === from ) {
			frame = frames[ i ];
			break;
		}
	}

	// Recursively accumulate offset values
	if ( frame ) {
		const 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 ) {
	const pos = $element.offset();
	const anchorPos = $anchor.offset();
	const anchorDocument = this.getDocument( $anchor );

	let elementDocument = this.getDocument( $element );

	// If $element isn't in the same document as $anchor, traverse up
	while ( elementDocument !== anchorDocument ) {
		const iframe = elementDocument.defaultView.frameElement;
		if ( !iframe ) {
			throw new Error( '$element frame is not contained in $anchor frame' );
		}
		const iframePos = $( iframe ).offset();
		pos.left += iframePos.left;
		pos.top += iframePos.top;
		elementDocument = this.getDocument( iframe );
	}
	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 ) {
	const doc = this.getDocument( el ),
		win = doc.defaultView,
		style = win.getComputedStyle( el, null ),
		$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 ) {
	const doc = this.getDocument( el ),
		win = doc.defaultView;

	if ( win === el || el === doc.documentElement ) {
		const $win = $( win );
		return {
			borders: { top: 0, left: 0, bottom: 0, right: 0 },
			scroll: {
				top: $win.scrollTop(),
				left: OO.ui.Element.static.getScrollLeft( win )
			},
			scrollbar: { right: 0, bottom: 0 },
			rect: {
				top: 0,
				left: 0,
				bottom: $win.innerHeight(),
				right: $win.innerWidth()
			}
		};
	} else {
		const $el = $( el );
		return {
			borders: this.getBorders( el ),
			scroll: {
				top: $el.scrollTop(),
				left: OO.ui.Element.static.getScrollLeft( el )
			},
			scrollbar: {
				right: $el.innerWidth() - el.clientWidth,
				bottom: $el.innerHeight() - el.clientHeight
			},
			rect: el.getBoundingClientRect()
		};
	}
};

( function () {
	let rtlScrollType = null;

	// Adapted from <https://github.com/othree/jquery.rtl-scroll-type>.
	// Original code copyright 2012 Wei-Ko Kao, licensed under the MIT License.
	function rtlScrollTypeTest() {
		const $definer = $( '<div>' ).attr( {
				dir: 'rtl',
				style: 'font-size: 14px; width: 4px; height: 1px; position: absolute; top: -1000px; overflow: scroll;'
			} ).text( 'ABCD' ),
			definer = $definer[ 0 ];

		$definer.appendTo( 'body' );
		if ( definer.scrollLeft > 0 ) {
			// Safari, Chrome
			rtlScrollType = 'default';
		} else {
			definer.scrollLeft = 1;
			if ( definer.scrollLeft === 0 ) {
				// Firefox, old Opera
				rtlScrollType = 'negative';
			} else {
				// Internet Explorer, Edge
				rtlScrollType = 'reverse';
			}
		}
		$definer.remove();
	}

	function isRoot( el ) {
		return el === el.window ||
			el === el.ownerDocument.body ||
			el === el.ownerDocument.documentElement;
	}

	/**
	 * Convert native `scrollLeft` value to a value consistent between browsers. See #getScrollLeft.
	 *
	 * @param {number} nativeOffset Native `scrollLeft` value
	 * @param {HTMLElement|Window} el Element from which the value was obtained
	 * @return {number}
	 */
	OO.ui.Element.static.computeNormalizedScrollLeft = function ( nativeOffset, el ) {
		// All browsers use the correct scroll type ('negative') on the root, so don't
		// do any fixups when looking at the root element
		const direction = isRoot( el ) ? 'ltr' : $( el ).css( 'direction' );

		if ( direction === 'rtl' ) {
			if ( rtlScrollType === null ) {
				rtlScrollTypeTest();
			}
			if ( rtlScrollType === 'reverse' ) {
				return -nativeOffset;
			} else if ( rtlScrollType === 'default' ) {
				return nativeOffset - el.scrollWidth + el.clientWidth;
			}
		}

		return nativeOffset;
	};

	/**
	 * Convert our normalized `scrollLeft` value to a value for current browser. See #getScrollLeft.
	 *
	 * @param {number} normalizedOffset Normalized `scrollLeft` value
	 * @param {HTMLElement|Window} el Element on which the value will be set
	 * @return {number}
	 */
	OO.ui.Element.static.computeNativeScrollLeft = function ( normalizedOffset, el ) {
		// All browsers use the correct scroll type ('negative') on the root, so don't
		// do any fixups when looking at the root element
		const direction = isRoot( el ) ? 'ltr' : $( el ).css( 'direction' );

		if ( direction === 'rtl' ) {
			if ( rtlScrollType === null ) {
				rtlScrollTypeTest();
			}
			if ( rtlScrollType === 'reverse' ) {
				return -normalizedOffset;
			} else if ( rtlScrollType === 'default' ) {
				return normalizedOffset + el.scrollWidth - el.clientWidth;
			}
		}

		return normalizedOffset;
	};

	/**
	 * Get the number of pixels that an element's content is scrolled to the left.
	 *
	 * This function smooths out browser inconsistencies (nicely described in the README at
	 * <https://github.com/othree/jquery.rtl-scroll-type>) and produces a result consistent
	 * with Firefox's 'scrollLeft', which seems the most sensible.
	 *
	 * (Firefox's scrollLeft handling is nice because it increases from left to right, consistently
	 * with `getBoundingClientRect().left` and related APIs; because initial value is zero, so
	 * resetting it is easy; because adapting a hardcoded scroll position to a symmetrical RTL
	 * interface requires just negating it, rather than involving `clientWidth` and `scrollWidth`;
	 * and because if you mess up and don't adapt your code to RTL, it will scroll to the beginning
	 * rather than somewhere randomly in the middle but not where you wanted.)
	 *
	 * @static
	 * @method
	 * @param {HTMLElement|Window} el Element to measure
	 * @return {number} Scroll position from the left.
	 *  If the element's direction is LTR, this is a positive number between `0` (initial scroll
	 *  position) and `el.scrollWidth - el.clientWidth` (furthest possible scroll position).
	 *  If the element's direction is RTL, this is a negative number between `0` (initial scroll
	 *  position) and `-el.scrollWidth + el.clientWidth` (furthest possible scroll position).
	 */
	OO.ui.Element.static.getScrollLeft = function ( el ) {
		let scrollLeft = isRoot( el ) ? $( window ).scrollLeft() : el.scrollLeft;
		scrollLeft = OO.ui.Element.static.computeNormalizedScrollLeft( scrollLeft, el );
		return scrollLeft;
	};

	/**
	 * Set the number of pixels that an element's content is scrolled to the left.
	 *
	 * See #getScrollLeft.
	 *
	 * @static
	 * @method
	 * @param {HTMLElement|Window} el Element to scroll (and to use in calculations)
	 * @param {number} scrollLeft Scroll position from the left.
	 *  If the element's direction is LTR, this must be a positive number between
	 *  `0` (initial scroll position) and `el.scrollWidth - el.clientWidth`
	 *  (furthest possible scroll position).
	 *  If the element's direction is RTL, this must be a negative number between
	 *  `0` (initial scroll position) and `-el.scrollWidth + el.clientWidth`
	 *  (furthest possible scroll position).
	 */
	OO.ui.Element.static.setScrollLeft = function ( el, scrollLeft ) {
		scrollLeft = OO.ui.Element.static.computeNativeScrollLeft( scrollLeft, el );
		if ( isRoot( el ) ) {
			$( window ).scrollLeft( scrollLeft );
		} else {
			el.scrollLeft = scrollLeft;
		}
	};
}() );

/**
 * Get the root scrollable element of given element's document.
 *
 * Support: Chrome <= 60
 * On older versions of Blink, `document.documentElement` can't be used to get or set
 * the scrollTop property; instead we have to use `document.body`. Changing and testing the 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 root scrollable parent for
 * @return {HTMLBodyElement|HTMLHtmlElement} Scrollable parent, `<body>` or `<html>`
 */
OO.ui.Element.static.getRootScrollableElement = function ( el ) {
	const doc = this.getDocument( el );

	if ( OO.ui.scrollableElement === undefined ) {
		const body = doc.body;
		const scrollTop = body.scrollTop;
		body.scrollTop = 1;

		// In some browsers (observed in Chrome 56 on Linux Mint 18.1),
		// body.scrollTop doesn't become exactly 1, but a fractional value like 0.76
		if ( Math.round( body.scrollTop ) === 1 ) {
			body.scrollTop = scrollTop;
			OO.ui.scrollableElement = 'body';
		} else {
			OO.ui.scrollableElement = 'documentElement';
		}
	}

	return doc[ OO.ui.scrollableElement ];
};

/**
 * Get closest scrollable container.
 *
 * Traverses up until either a scrollable element or the root is reached, in which case the root
 * scrollable element will be returned (see #getRootScrollableElement).
 *
 * @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 ) {
	const doc = this.getDocument( el );
	const rootScrollableElement = this.getRootScrollableElement( el );
	// Browsers do not correctly return the computed value of 'overflow' when 'overflow-x' and
	// 'overflow-y' have different values, so we need to check the separate properties.
	let props = [ 'overflow-x', 'overflow-y' ];
	let $parent = $( el ).parent();

	if ( el === doc.documentElement ) {
		return rootScrollableElement;
	}

	if ( dimension === 'x' || dimension === 'y' ) {
		props = [ 'overflow-' + dimension ];
	}

	// The parent of <html> is the document, so check we haven't traversed that far
	while ( $parent.length && $parent[ 0 ] !== doc ) {
		if ( $parent[ 0 ] === rootScrollableElement ) {
			return $parent[ 0 ];
		}
		let i = props.length;
		while ( i-- ) {
			const val = $parent.css( props[ i ] );
			// We assume that elements with 'overflow' (in any direction) set to 'hidden' will
			// never be scrolled in that direction, but they can actually be scrolled
			// programatically. The user can unintentionally perform a scroll in such case even if
			// the application doesn't scroll programatically, e.g. when jumping to an anchor, or
			// when using built-in find functionality.
			// This could cause funny issues...
			if ( val === 'auto' || val === 'scroll' ) {
				if ( $parent[ 0 ] === doc.body ) {
					// If overflow is set on <body>, return the rootScrollableElement
					// (<body> or <html>) as <body> may not be scrollable.
					return rootScrollableElement;
				} else {
					return $parent[ 0 ];
				}
			}
		}
		$parent = $parent.parent();
	}
	// The element is unattached… return something moderately sensible.
	return rootScrollableElement;
};

/**
 * Scroll element into view.
 *
 * @static
 * @param {HTMLElement|Object} elOrPosition Element to scroll into view
 * @param {Object} [config] Configuration options
 * @param {string} [config.animate=true] Animate to the new scroll offset.
 * @param {string} [config.duration='fast'] 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 {Object} [config.alignToTop=false] Aligns the top of the element to the top of the visible
 *  area of the scrollable ancestor.
 * @param {Object} [config.padding] Additional padding on the container to scroll past.
 *  Object containing any of 'top', 'bottom', 'left', or 'right' as numbers.
 * @param {Object} [config.scrollContainer] Scroll container. Defaults to
 *  getClosestScrollableContainer of the element.
 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
 */
OO.ui.Element.static.scrollIntoView = function ( elOrPosition, config ) {
	const deferred = $.Deferred();

	// Configuration initialization
	config = config || {};

	const padding = $.extend( {
		top: 0,
		bottom: 0,
		left: 0,
		right: 0
	}, config.padding );

	let animate = config.animate !== false;
	if ( window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches ) {
		// Respect 'prefers-reduced-motion' user preference
		animate = false;
	}

	const animations = {};
	const elementPosition = elOrPosition instanceof HTMLElement ?
		this.getDimensions( elOrPosition ).rect :
		elOrPosition;
	const container = config.scrollContainer || (
		elOrPosition instanceof HTMLElement ?
			this.getClosestScrollableContainer( elOrPosition, config.direction ) :
			// No scrollContainer or element, use global document
			this.getClosestScrollableContainer( window.document.body )
	);
	const $container = $( container );
	const containerDimensions = this.getDimensions( container );
	const $window = $( this.getWindow( container ) );

	// Compute the element's position relative to the container
	let position;
	if ( $container.is( 'html, body' ) ) {
		// If the scrollable container is the root, this is easy
		position = {
			top: elementPosition.top,
			bottom: $window.innerHeight() - elementPosition.bottom,
			left: elementPosition.left,
			right: $window.innerWidth() - elementPosition.right
		};
	} else {
		// Otherwise, we have to subtract el's coordinates from container's coordinates
		position = {
			top: elementPosition.top -
				( containerDimensions.rect.top + containerDimensions.borders.top ),
			bottom: containerDimensions.rect.bottom - containerDimensions.borders.bottom -
				containerDimensions.scrollbar.bottom - elementPosition.bottom,
			left: elementPosition.left -
				( containerDimensions.rect.left + containerDimensions.borders.left ),
			right: containerDimensions.rect.right - containerDimensions.borders.right -
				containerDimensions.scrollbar.right - elementPosition.right
		};
	}

	if ( !config.direction || config.direction === 'y' ) {
		if ( position.top < padding.top || config.alignToTop ) {
			animations.scrollTop = containerDimensions.scroll.top + position.top - padding.top;
		} else if ( position.bottom < padding.bottom ) {
			animations.scrollTop = containerDimensions.scroll.top +
				// Scroll the bottom into view, but not at the expense
				// of scrolling the top out of view
				Math.min( position.top - padding.top, -position.bottom + padding.bottom );
		}
	}
	if ( !config.direction || config.direction === 'x' ) {
		if ( position.left < padding.left ) {
			animations.scrollLeft = containerDimensions.scroll.left + position.left - padding.left;
		} else if ( position.right < padding.right ) {
			animations.scrollLeft = containerDimensions.scroll.left +
				// Scroll the right into view, but not at the expense
				// of scrolling the left out of view
				Math.min( position.left - padding.left, -position.right + padding.right );
		}
		if ( animations.scrollLeft !== undefined ) {
			animations.scrollLeft =
				OO.ui.Element.static.computeNativeScrollLeft( animations.scrollLeft, container );
		}
	}
	if ( !$.isEmptyObject( animations ) ) {
		if ( animate ) {
			// eslint-disable-next-line no-jquery/no-animate
			$container.stop( true ).animate( animations, {
				duration: config.duration === undefined ? 'fast' : config.duration,
				always: deferred.resolve
			} );
		} else {
			$container.stop( true );
			for ( const method in animations ) {
				$container[ method ]( animations[ method ] );
			}
			deferred.resolve();
		}
	} else {
		deferred.resolve();
	}
	return deferred.promise();
};

/**
 * 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 <https://code.google.com/p/chromium/issues/detail?id=387290>, 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 ) {
	// Save scroll position
	const scrollLeft = el.scrollLeft;
	const scrollTop = el.scrollTop;
	const nodes = [];
	// Detach all children
	while ( el.firstChild ) {
		nodes.push( el.firstChild );
		el.removeChild( el.firstChild );
	}
	// Force reflow
	// eslint-disable-next-line no-unused-expressions
	el.offsetHeight;
	// Reattach all children
	for ( let i = 0, len = nodes.length; i < len; i++ ) {
		el.appendChild( nodes[ i ] );
	}
	// Restore scroll position (no-op if scrollbars disappeared)
	el.scrollLeft = scrollLeft;
	el.scrollTop = scrollTop;
};

/* Methods */

/**
 * Toggle visibility of an element.
 *
 * @param {boolean} [show] Make element visible, omit to toggle visibility
 * @fires OO.ui.Widget#toggle
 * @chainable
 * @return {OO.ui.Element} The element, for chaining
 */
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 {any} Element data
 */
OO.ui.Element.prototype.getData = function () {
	return this.data;
};

/**
 * Set element data.
 *
 * @param {any} data Element data
 * @chainable
 * @return {OO.ui.Element} The element, for chaining
 */
OO.ui.Element.prototype.setData = function ( data ) {
	this.data = data;
	return this;
};

/**
 * Set the element has an 'id' attribute.
 *
 * @param {string} id
 * @chainable
 * @return {OO.ui.Element} The element, for chaining
 */
OO.ui.Element.prototype.setElementId = function ( id ) {
	this.elementId = id;
	this.$element.attr( 'id', id );
	return this;
};

/**
 * Ensure that the element has an 'id' attribute, setting it to an unique value if it's missing,
 * and return its value.
 *
 * @return {string}
 */
OO.ui.Element.prototype.getElementId = function () {
	if ( this.elementId === null ) {
		this.setElementId( OO.ui.generateElementId() );
	}
	return this.elementId;
};

/**
 * 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 ) {
	if ( !Array.isArray( methods ) ) {
		return typeof this[ methods ] === 'function';
	}

	return methods.every( ( method ) => typeof this[ method ] === 'function' );
};

/**
 * Update the theme-provided classes.
 *
 * 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 () {
	OO.ui.theme.queueUpdateElementClasses( this );
};

/**
 * 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 this.$element[ 0 ].isConnected;
};

/**
 * 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.
 *
 * @return {HTMLElement} 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.mixin.GroupElement|null} Group element, null if none
 */
OO.ui.Element.prototype.getElementGroup = function () {
	return this.elementGroup;
};

/**
 * Set group element is in.
 *
 * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
 * @chainable
 * @return {OO.ui.Element} The element, for chaining
 */
OO.ui.Element.prototype.setElementGroup = function ( group ) {
	this.elementGroup = group;
	return this;
};

/**
 * Scroll element into view.
 *
 * @param {Object} [config] Configuration options
 * @return {jQuery.Promise} Promise which resolves when the scroll is complete
 */
OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
	if (
		!this.isElementAttached() ||
		!this.isVisible() ||
		( this.getElementGroup() && !this.getElementGroup().isVisible() )
	) {
		return $.Deferred().resolve();
	}
	return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config );
};

/**
 * Restore the pre-infusion dynamic state for this widget.
 *
 * This method is called after #$element has been inserted into DOM. The parameter is the return
 * value of #gatherPreInfuseState.
 *
 * @protected
 * @param {Object} state
 */
OO.ui.Element.prototype.restorePreInfuseState = function () {
};