322 lines
11 KiB
JavaScript
322 lines
11 KiB
JavaScript
/**
|
|
* @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;
|
|
},
|
|
});
|
|
})();
|