blob: 71621fab9e0bd5c0d5dd28d2ee9d3c3f9656727f [file] [log] [blame]
/**
* @license
* Copyright (C) 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function() {
'use strict';
const HOVER_CLASS = 'hovered';
/**
* When the hovercard is positioned diagonally (bottom-left, bottom-right,
* top-left, or top-right), we add additional (invisible) padding so that the
* area that a user can hover over to access the hovercard is larger.
*/
const DIAGONAL_OVERFLOW = 15;
Polymer({
is: 'gr-hovercard',
properties: {
/**
* @type {?}
*/
_target: Object,
/**
* Determines whether or not the hovercard is visible.
*
* @type {boolean}
*/
_isShowing: {
type: Boolean,
value: false,
},
/**
* The `id` of the element that the hovercard is anchored to.
*
* @type {string}
*/
for: {
type: String,
observer: '_forChanged',
},
/**
* The spacing between the top of the hovercard and the element it is
* anchored to.
*
* @type {number}
*/
offset: {
type: Number,
value: 14,
},
/**
* Positions the hovercard to the top, right, bottom, left, bottom-left,
* bottom-right, top-left, or top-right of its content.
*
* @type {string}
*/
position: {
type: String,
value: 'bottom',
},
container: Object,
/**
* ID for the container element.
*
* @type {string}
*/
containerId: {
type: String,
value: 'gr-hovercard-container',
},
},
listeners: {
mouseleave: 'hide',
},
attached() {
if (!this._target) { this._target = this.target; }
this.listen(this._target, 'mouseenter', 'show');
this.listen(this._target, 'focus', 'show');
this.listen(this._target, 'mouseleave', 'hide');
this.listen(this._target, 'blur', 'hide');
this.listen(this._target, 'tap', 'hide');
},
ready() {
// First, check to see if the container has already been created.
this.container = Gerrit.getRootElement()
.querySelector('#' + this.containerId);
if (this.container) { return; }
// If it does not exist, create and initialize the hovercard container.
this.container = document.createElement('div');
this.container.setAttribute('id', this.containerId);
Gerrit.getRootElement().appendChild(this.container);
},
removeListeners() {
this.unlisten(this._target, 'mouseenter', 'show');
this.unlisten(this._target, 'focus', 'show');
this.unlisten(this._target, 'mouseleave', 'hide');
this.unlisten(this._target, 'blur', 'hide');
this.unlisten(this._target, 'tap', 'hide');
},
/**
* Returns the target element that the hovercard is anchored to (the `id` of
* the `for` property).
*
* @type {HTMLElement}
*/
get target() {
const parentNode = Polymer.dom(this).parentNode;
// If the parentNode is a document fragment, then we need to use the host.
const ownerRoot = Polymer.dom(this).getOwnerRoot();
let target;
if (this.for) {
target = Polymer.dom(ownerRoot).querySelector('#' + this.for);
} else {
target = parentNode.nodeType == Node.DOCUMENT_FRAGMENT_NODE ?
ownerRoot.host :
parentNode;
}
return target;
},
/**
* Hides/closes the hovercard. This occurs when the user triggers the
* `mouseleave` event on the hovercard's `target` element (as long as the
* user is not hovering over the hovercard).
*
* @param {Event} e DOM Event (e.g. `mouseleave` event)
*/
hide(e) {
const targetRect = this._target.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x > targetRect.left && x < targetRect.right && y > targetRect.top &&
y < targetRect.bottom) {
// Sometimes the hovercard itself obscures the mouse pointer, and
// that generates a mouseleave event. We don't want to hide the hovercard
// in that situation.
return;
}
// If the hovercard is already hidden or the user is now hovering over the
// hovercard or the user is returning from the hovercard but now hovering
// over the target (to stop an annoying flicker effect), just return.
if (!this._isShowing || e.toElement === this ||
(e.fromElement === this && e.toElement === this._target)) {
return;
}
// Mark that the hovercard is not visible and do not allow focusing
this._isShowing = false;
// Clear styles in preparation for the next time we need to show the card
this.classList.remove(HOVER_CLASS);
// Reset and remove the hovercard from the DOM
this.style.cssText = '';
this.$.hovercard.setAttribute('tabindex', -1);
// Remove the hovercard from the container, given that it is still a child
// of the container.
if (this.container.contains(this)) {
this.container.removeChild(this);
}
},
/**
* Shows/opens the hovercard. This occurs when the user triggers the
* `mousenter` event on the hovercard's `target` element.
*
* @param {Event} e DOM Event (e.g., `mouseenter` event)
*/
show(e) {
if (this._isShowing) {
return;
}
// Mark that the hovercard is now visible
this._isShowing = true;
this.setAttribute('tabindex', 0);
// Add it to the DOM and calculate its position
this.container.appendChild(this);
this.updatePosition();
// Trigger the transition
this.classList.add(HOVER_CLASS);
},
/**
* Updates the hovercard's position based on the `position` attribute
* and the current position of the `target` element.
*
* The hovercard is supposed to stay open if the user hovers over it.
* To keep it open when the user moves away from the target, the bounding
* rects of the target and hovercard must touch or overlap.
*
* NOTE: You do not need to directly call this method unless you need to
* update the position of the tooltip while it is already visible (the
* target element has moved and the tooltip is still open).
*/
updatePosition() {
if (!this._target) { return; }
// Calculate the necessary measurements and positions
const parentRect = document.documentElement.getBoundingClientRect();
const targetRect = this._target.getBoundingClientRect();
const thisRect = this.getBoundingClientRect();
const targetLeft = targetRect.left - parentRect.left;
const targetTop = targetRect.top - parentRect.top;
let hovercardLeft;
let hovercardTop;
const diagonalPadding = this.offset + DIAGONAL_OVERFLOW;
let cssText = '';
// Find the top and left position values based on the position attribute
// of the hovercard.
switch (this.position) {
case 'top':
hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
hovercardTop = targetTop - thisRect.height - this.offset;
cssText += `padding-bottom:${this.offset
}px; margin-bottom:-${this.offset}px;`;
break;
case 'bottom':
hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
hovercardTop = targetTop + targetRect.height + this.offset;
cssText +=
`padding-top:${this.offset}px; margin-top:-${this.offset}px;`;
break;
case 'left':
hovercardLeft = targetLeft - thisRect.width - this.offset;
hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
cssText +=
`padding-right:${this.offset}px; margin-right:-${this.offset}px;`;
break;
case 'right':
hovercardLeft = targetRect.right + this.offset;
hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
cssText +=
`padding-left:${this.offset}px; margin-left:-${this.offset}px;`;
break;
case 'bottom-right':
hovercardLeft = targetRect.left + targetRect.width + this.offset;
hovercardTop = targetRect.top + targetRect.height + this.offset;
cssText += `padding-top:${diagonalPadding}px;`;
cssText += `padding-left:${diagonalPadding}px;`;
cssText += `margin-left:-${diagonalPadding}px;`;
cssText += `margin-top:-${diagonalPadding}px;`;
break;
case 'bottom-left':
hovercardLeft = targetRect.left - thisRect.width - this.offset;
hovercardTop = targetRect.top + targetRect.height + this.offset;
cssText += `padding-top:${diagonalPadding}px;`;
cssText += `padding-right:${diagonalPadding}px;`;
cssText += `margin-right:-${diagonalPadding}px;`;
cssText += `margin-top:-${diagonalPadding}px;`;
break;
case 'top-left':
hovercardLeft = targetRect.left - thisRect.width - this.offset;
hovercardTop = targetRect.top - thisRect.height - this.offset;
cssText += `padding-bottom:${diagonalPadding}px;`;
cssText += `padding-right:${diagonalPadding}px;`;
cssText += `margin-bottom:-${diagonalPadding}px;`;
cssText += `margin-right:-${diagonalPadding}px;`;
break;
case 'top-right':
hovercardLeft = targetRect.left + targetRect.width + this.offset;
hovercardTop = targetRect.top - thisRect.height - this.offset;
cssText += `padding-bottom:${diagonalPadding}px;`;
cssText += `padding-left:${diagonalPadding}px;`;
cssText += `margin-bottom:-${diagonalPadding}px;`;
cssText += `margin-left:-${diagonalPadding}px;`;
break;
}
// Prevent hovercard from appearing outside the viewport.
// TODO(kaspern): fix hovercard appearing outside viewport on bottom and
// right.
if (hovercardLeft < 0) { hovercardLeft = 0; }
if (hovercardTop < 0) { hovercardTop = 0; }
// Set the hovercard's position
cssText += `left:${hovercardLeft}px; top:${hovercardTop}px;`;
this.style.cssText = cssText;
},
/**
* Responds to a change in the `for` value and gets the updated `target`
* element for the hovercard.
*
* @private
*/
_forChanged() {
this._target = this.target;
},
});
})();