driver.js / src /core /element.js
kamrify's picture
Refactor and update dependencies
e6213af
raw
history blame
9.98 kB
import {
ANIMATION_DURATION_MS,
CLASS_DRIVER_HIGHLIGHTED_ELEMENT,
CLASS_FIX_STACKING_CONTEXT,
CLASS_POSITION_RELATIVE,
} from '../common/constants';
import { getStyleProperty } from '../common/utils';
import Position from './position';
/**
* Wrapper around DOMElements to enrich them
* with the functionality necessary
*/
export default class Element {
/**
* DOM element object
* @param {Node|HTMLElement} node
* @param {Object} options
* @param {Popover} popover
* @param {Stage} stage
* @param {Overlay} overlay
* @param {Window} window
* @param {Document} document
*/
constructor({
node,
options,
popover,
stage,
overlay,
window,
document,
} = {}) {
this.node = node;
this.document = document;
this.window = window;
this.options = options;
this.overlay = overlay;
this.popover = popover;
this.stage = stage;
this.animationTimeout = null;
}
/**
* Checks if the current element is visible in viewport
* @returns {boolean}
* @private
*/
isInView() {
let top = this.node.offsetTop;
let left = this.node.offsetLeft;
const width = this.node.offsetWidth;
const height = this.node.offsetHeight;
let el = this.node;
while (el.offsetParent) {
el = el.offsetParent;
top += el.offsetTop;
left += el.offsetLeft;
}
return (
top >= this.window.pageYOffset
&& left >= this.window.pageXOffset
&& (top + height) <= (this.window.pageYOffset + this.window.innerHeight)
&& (left + width) <= (this.window.pageXOffset + this.window.innerWidth)
);
}
/**
* Manually scrolls to the position of element if `scrollIntoView` fails
* @private
*/
scrollManually() {
const elementRect = this.node.getBoundingClientRect();
const absoluteElementTop = elementRect.top + this.window.pageYOffset;
const middle = absoluteElementTop - (this.window.innerHeight / 2);
this.window.scrollTo(0, middle);
}
/**
* Brings the element to middle of the view port if not in view
* @private
*/
bringInView() {
if (this.isInView()) {
return;
}
// If browser does not support scrollIntoView
if (!this.node.scrollIntoView) {
this.scrollManually();
return;
}
try {
this.node.scrollIntoView(this.options.scrollIntoViewOptions || {
behavior: 'instant',
block: 'center',
});
} catch (e) {
// `block` option is not allowed in older versions of firefox, scroll manually
this.scrollManually();
}
}
/**
* Gets the calculated position on screen, around which
* we need to draw
* @public
* @return {Position}
*/
getCalculatedPosition() {
const body = this.document.body;
const documentElement = this.document.documentElement;
const window = this.window;
const scrollTop = this.window.pageYOffset || documentElement.scrollTop || body.scrollTop;
const scrollLeft = window.pageXOffset || documentElement.scrollLeft || body.scrollLeft;
const elementRect = this.node.getBoundingClientRect();
return new Position({
top: elementRect.top + scrollTop,
left: elementRect.left + scrollLeft,
right: elementRect.left + scrollLeft + elementRect.width,
bottom: elementRect.top + scrollTop + elementRect.height,
});
}
/**
* Gets the popover for the current element if any
* @returns {Popover|*}
* @public
*/
getPopover() {
return this.popover;
}
/**
* Is called when element is about to be deselected
* i.e. when moving the focus to next element of closing
* @public
*/
onDeselected(hideStage = false) {
this.hidePopover();
if (hideStage) {
this.hideStage();
}
this.removeHighlightClasses();
// If there was any animation in progress, cancel that
this.window.clearTimeout(this.animationTimeout);
if (this.options.onDeselected) {
this.options.onDeselected(this);
}
}
/**
* Checks if the given element is same as the current element
* @param {Element} element
* @returns {boolean}
* @public
*/
isSame(element) {
if (!element || !element.node) {
return false;
}
return element.node === this.node;
}
/**
* Is called when the element is about to be highlighted
* @public
*/
onHighlightStarted() {
if (this.options.onHighlightStarted) {
this.options.onHighlightStarted(this);
}
}
/**
* Is called when the element has been successfully highlighted
* @public
*/
onHighlighted() {
this.showPopover();
this.showStage();
this.addHighlightClasses();
const highlightedElement = this;
const popoverElement = this.popover;
if (popoverElement && !popoverElement.isInView()) {
popoverElement.bringInView();
}
if (!highlightedElement.isInView()) {
highlightedElement.bringInView();
}
if (this.options.onHighlighted) {
this.options.onHighlighted(this);
}
}
/**
* Removes the stacking context fix classes and the highlighter classes
* @private
*/
removeHighlightClasses() {
this.node.classList.remove(CLASS_DRIVER_HIGHLIGHTED_ELEMENT);
this.node.classList.remove(CLASS_POSITION_RELATIVE);
const stackFixes = this.document.querySelectorAll(`.${CLASS_FIX_STACKING_CONTEXT}`);
for (let counter = 0; counter < stackFixes.length; counter++) {
stackFixes[counter].classList.remove(CLASS_FIX_STACKING_CONTEXT);
}
}
/**
* Adds the highlight class on the current element and "fixes"
* the parent nodes if they
* @private
*/
addHighlightClasses() {
this.node.classList.add(CLASS_DRIVER_HIGHLIGHTED_ELEMENT);
// Don't make relative if element already has some position set
if (this.canMakeRelative()) {
this.node.classList.add(CLASS_POSITION_RELATIVE);
}
// Check and re-define the stacking context
this.fixStackingContext();
}
/**
* Walks through the parents of the current element and fixes
* the stacking context
* @private
*/
fixStackingContext() {
let parentNode = this.node.parentNode;
while (parentNode) {
if (!parentNode.tagName || parentNode.tagName.toLowerCase() === 'body') {
break;
}
const zIndex = getStyleProperty(parentNode, 'z-index');
const opacity = parseFloat(getStyleProperty(parentNode, 'opacity'));
const transform = getStyleProperty(parentNode, 'transform', true);
const transformStyle = getStyleProperty(parentNode, 'transform-style', true);
const transformBox = getStyleProperty(parentNode, 'transform-box', true);
const filter = getStyleProperty(parentNode, 'filter', true);
const perspective = getStyleProperty(parentNode, 'perspective', true);
// Stacking context gets disturbed if
// - Parent has z-index
// - Opacity is below 0
// - Filter/transform or perspective is applied
if (
/[0-9]+/.test(zIndex)
|| opacity < 1
|| (transform && transform !== 'none')
|| (transformStyle && transformStyle !== 'flat')
|| (transformBox && transformBox !== 'border-box')
|| (filter && filter !== 'none')
|| (perspective && perspective !== 'none')
) {
parentNode.classList.add(CLASS_FIX_STACKING_CONTEXT);
}
parentNode = parentNode.parentNode;
}
}
/**
* Checks if we can make the current element relative or not
* @return {boolean}
* @private
*/
canMakeRelative() {
const currentPosition = this.getStyleProperty('position');
const avoidPositionsList = ['absolute', 'fixed', 'relative'];
// Because if the element has any of these positions, making it
// relative will break the UI
return avoidPositionsList.indexOf(currentPosition) === -1;
}
/**
* Get current element's CSS attribute value
* @param {string} property
* @returns string
* @private
*/
getStyleProperty(property) {
return getStyleProperty(this.node, property);
}
/**
* Shows the stage behind the element
* @public
*/
showStage() {
this.stage.show(this.getCalculatedPosition());
}
/**
* Gets the DOM Element behind this element
* @returns {Node|HTMLElement|*}
* @public
*/
getNode() {
return this.node;
}
/**
* Hides the stage
* @public
*/
hideStage() {
this.stage.hide();
}
/**
* Hides the popover if possible
* @public
*/
hidePopover() {
if (!this.popover) {
return;
}
this.popover.hide();
}
/**
* Shows the popover on the current element
* @public
*/
showPopover() {
if (!this.popover) {
return;
}
const showAtPosition = this.getCalculatedPosition();
// For first highlight, show it immediately because there won't be any animation
let showAfterMs = ANIMATION_DURATION_MS;
// If animation is disabled or if it is the first display, show it immediately
if (!this.options.animate || !this.overlay.lastHighlightedElement) {
showAfterMs = 0;
}
this.animationTimeout = this.window.setTimeout(() => {
this.popover.show(showAtPosition);
}, showAfterMs);
}
/**
* @returns {{height: number, width: number}}
* @public
*/
getFullPageSize() {
// eslint-disable-next-line prefer-destructuring
const body = this.document.body;
const html = this.document.documentElement;
return {
height: Math.max(body.scrollHeight, body.offsetHeight, html.scrollHeight, html.offsetHeight),
width: Math.max(body.scrollWidth, body.offsetWidth, html.scrollWidth, html.offsetWidth),
};
}
/**
* Gets the size for popover
* @returns {{height: number, width: number}}
* @public
*/
getSize() {
return {
height: Math.max(this.node.scrollHeight, this.node.offsetHeight),
width: Math.max(this.node.scrollWidth, this.node.offsetWidth),
};
}
}