@discoveryjs/discovery
Version:
Frontend framework for rapid data (JSON) analysis, shareable serverless reports and dashboards
408 lines (407 loc) • 14.2 kB
JavaScript
import { getOffsetParent, getBoundingRect, getViewportRect } from "../../core/utils/layout.js";
import { passiveCaptureOptions } from "../../core/utils/dom.js";
import { pointerXY } from "../../core/utils/pointer.js";
const hoverPinModes = [false, "popup-hover", "trigger-click"];
const defaultShowDelay = 300;
function isElementNullOrInDocument(element) {
return element ? element.getRootNode({ composed: true }) === document : true;
}
function showDelayToMs(value, triggerEl) {
if (typeof value === "function") {
value = value(triggerEl);
}
if (typeof value === "number") {
return ensureNumber(value);
}
if (typeof value === "boolean") {
return value ? defaultShowDelay : 0;
}
return 0;
}
function appendIfNeeded(parent, child) {
if (parent.lastChild !== child) {
parent.appendChild(child);
}
}
function ensureNumber(value, fallback = 0) {
return Number.isFinite(value) ? value : fallback;
}
function isHoverPinModeValue(value) {
return hoverPinModes.includes(value);
}
export default function(host) {
const openedPopups = [];
const delayedToShowPopups = /* @__PURE__ */ new Set();
const hoverTriggerInstances = [];
const inspectorLockedInstances = /* @__PURE__ */ new Set();
let hideAllPopups = null;
const setHideAllPopups = (event) => {
if (hideAllPopups === null) {
hideAllPopups = setTimeout(() => hideIfEventOutside(event), 0);
}
};
const clearHideAllPopups = () => {
if (hideAllPopups !== null) {
clearTimeout(hideAllPopups);
hideAllPopups = null;
}
};
let globalListeners = null;
const addHostElHoverListeners = () => {
if (globalListeners !== null) {
return;
}
globalListeners = [
host.addHostElEventListener("mouseenter", ({ target }) => {
for (const instance of hoverTriggerInstances) {
const targetRelatedPopup = findTargetRelatedPopup(instance, target);
const triggerEl = targetRelatedPopup ? targetRelatedPopup.el : target.closest(instance.hoverTriggers);
if (triggerEl) {
if (instance.hideTimer !== null) {
clearTimeout(instance.hideTimer);
instance.hideTimer = null;
}
if (triggerEl !== instance.lastHoverTriggerEl) {
if (!targetRelatedPopup || !targetRelatedPopup.hoverPinned) {
instance.lastHoverTriggerEl = triggerEl;
}
if (!targetRelatedPopup) {
instance.hoverPinned = false;
instance.el.classList.remove("pinned");
instance.show(triggerEl);
}
}
}
}
}, passiveCaptureOptions),
host.addHostElEventListener("mouseleave", ({ target }) => {
for (const instance of hoverTriggerInstances) {
if (instance.lastHoverTriggerEl === target) {
stopDelayedShow(instance);
instance.lastHoverTriggerEl = null;
instance.hideTimer = setTimeout(instance.hide, 100);
}
}
}, passiveCaptureOptions),
host.addGlobalEventListener("scroll", setHideAllPopups, passiveCaptureOptions),
host.addHostElEventListener("scroll", (event) => {
clearHideAllPopups();
hideIfEventOutside(event);
}, passiveCaptureOptions),
host.addGlobalEventListener("click", setHideAllPopups, true),
host.addHostElEventListener("click", (event) => {
clearHideAllPopups();
hideIfEventOutside(event);
setTimeout(hideOnTriggerHasLeftDocument, 50);
for (const instance of hoverTriggerInstances) {
if (instance.hoverPin === "trigger-click") {
if (instance.lastHoverTriggerEl && instance.lastTriggerEl?.contains(event.target)) {
instance.lastHoverTriggerEl = null;
instance.hoverPinned = true;
instance.el.classList.add("pinned");
event.stopPropagation();
}
}
}
}, true)
];
};
pointerXY.subscribe(() => {
for (const popup of openedPopups) {
if (popup.position === "pointer" && !popup.hoverPinned && !popup.frozen && !inspectorLockedInstances.has(popup)) {
popup.updatePosition();
}
}
for (const popup of delayedToShowPopups) {
const { showDelayArgs } = popup;
startDelayedShow(popup, showDelayArgs?.[0], showDelayArgs?.[1]);
}
});
host.inspectMode.subscribe(
(enabled) => enabled ? openedPopups.forEach((popup) => inspectorLockedInstances.add(popup)) : inspectorLockedInstances.clear()
);
function hideIfEventOutside(event) {
openedPopups.slice().forEach((popup) => popup.hideIfEventOutside(event));
}
function hideOnTriggerHasLeftDocument() {
openedPopups.slice().forEach((popup) => popup.hideOnTriggerHasLeftDocument());
}
function hideOnResize() {
openedPopups.slice().forEach((popup) => popup.hideOnResize());
}
function findTargetRelatedPopup(popup, target) {
if (popup.el.contains(target)) {
return popup;
}
return popup.relatedPopups.find((popup2) => findTargetRelatedPopup(popup2, target)) || null;
}
function stopDelayedShow(popup) {
if (popup.showDelayTimer !== null) {
clearTimeout(popup.showDelayTimer);
}
popup.showDelayTimer = null;
popup.showDelayArgs = null;
delayedToShowPopups.delete(popup);
}
function startDelayedShow(popup, triggerEl, render) {
if (popup.showDelayTimer !== null) {
clearTimeout(popup.showDelayTimer);
}
popup.showDelayTimer = setTimeout(() => popup.show(triggerEl, render, true), showDelayToMs(popup.showDelay, triggerEl));
popup.showDelayArgs = [triggerEl, render];
delayedToShowPopups.add(popup);
}
class Popup {
el;
showDelayTimer;
showDelayArgs;
showDelay;
lastTriggerEl;
lastHoverTriggerEl;
render;
position;
positionMode;
pointerOffsetX;
pointerOffsetY;
hoverTriggers;
hoverPin;
hideIfEventOutsideDisabled;
hideOnResizeDisabled;
hideTimer;
hoverPinned;
frozen;
lastRenderMarker;
constructor(options) {
const {
render,
showDelay = false,
position = "trigger",
positionMode = "safe",
pointerOffsetX,
pointerOffsetY,
hoverTriggers,
hoverPin,
hideIfEventOutside: hideIfEventOutside2 = true,
hideOnResize: hideOnResize2 = true,
className
} = options || {};
this.el = document.createElement("div");
this.el.classList.add("discovery-view-popup");
this.showDelayTimer = null;
this.showDelayArgs = null;
this.showDelay = showDelay;
this.hideTimer = null;
this.hide = this.hide.bind(this);
this.lastTriggerEl = null;
this.lastHoverTriggerEl = null;
this.hoverPinned = false;
this.frozen = false;
this.render = render;
this.lastRenderMarker = null;
this.position = position;
this.positionMode = positionMode;
this.pointerOffsetX = ensureNumber(pointerOffsetX, 3);
this.pointerOffsetY = ensureNumber(pointerOffsetY, 3);
this.hoverTriggers = hoverTriggers || null;
this.hoverPin = isHoverPinModeValue(hoverPin) ? hoverPin : false;
this.hideIfEventOutsideDisabled = !hideIfEventOutside2;
this.hideOnResizeDisabled = !hideOnResize2;
if (className) {
this.el.classList.add(className);
}
if (this.hoverTriggers) {
this.el.classList.add("show-on-hover");
this.el.dataset.pinMode = this.hoverPin || "none";
hoverTriggerInstances.push(this);
addHostElHoverListeners();
}
}
get relatedPopups() {
return openedPopups.filter((related) => this.el.contains(related.lastTriggerEl));
}
get visible() {
return openedPopups.includes(this);
}
toggle(...args) {
if (this.visible) {
this.hide();
} else {
this.show(...args);
}
}
async show(triggerEl, render = this.render, showImmediately = false) {
if (!this.visible && !showImmediately && showDelayToMs(this.showDelay, triggerEl) > 0) {
startDelayedShow(this, triggerEl, render);
return;
}
stopDelayedShow(this);
inspectorLockedInstances.delete(this);
host.view.setViewRoot(this.el, "popup", { config: render });
const hostEl = host.dom.container;
if (this.hideTimer !== null) {
clearTimeout(this.hideTimer);
this.hideTimer = null;
}
this.relatedPopups.forEach((related) => related.hide());
this.el.classList.toggle("inspect", host.inspectMode.value);
if (this.lastTriggerEl) {
this.lastTriggerEl.classList.remove("discovery-view-popup-active");
}
if (triggerEl) {
triggerEl.classList.add("discovery-view-popup-active");
}
this.lastTriggerEl = triggerEl || null;
if (!this.visible) {
openedPopups.push(this);
if (openedPopups.length === 1) {
window.addEventListener("resize", hideOnResize);
}
}
if (typeof render === "function") {
this.el.innerHTML = "";
const renderMarker = Symbol();
this.lastRenderMarker = renderMarker;
requestAnimationFrame(() => {
if (this.lastRenderMarker === renderMarker) {
appendIfNeeded(hostEl, this.el);
this.updatePosition();
}
});
await render(this.el, triggerEl, this.hide);
if (this.lastRenderMarker !== renderMarker) {
return;
}
this.lastRenderMarker = null;
}
appendIfNeeded(hostEl, this.el);
this.updatePosition();
}
updatePosition() {
if (!this.visible) {
return;
}
const pointerPosition = this.position === "pointer";
const boxEl = pointerPosition ? null : this.lastTriggerEl;
if (pointerPosition ? this.frozen : !boxEl) {
return;
}
const hostEl = host.dom.container;
const offsetParent = getOffsetParent(hostEl.firstChild);
const viewport = getViewportRect(window, offsetParent);
const { x: pointerX, y: pointerY } = pointerXY.value;
const { pointerOffsetX, pointerOffsetY } = this;
const box = boxEl ? getBoundingRect(boxEl, hostEl) : {
left: Math.round(pointerX) - pointerOffsetX,
right: Math.round(pointerX) + pointerOffsetX,
top: Math.round(pointerY) - pointerOffsetY,
bottom: Math.round(pointerY) + pointerOffsetY
};
const boxLeft = pointerPosition ? box.left : box.right;
const boxRight = pointerPosition ? box.right : box.left;
const availHeightTop = box.top - viewport.top - 3;
const availHeightBottom = viewport.bottom - box.bottom - 3;
const availWidthLeft = boxLeft - viewport.left - 3;
const availWidthRight = viewport.right - boxRight - 3;
let safeRight = availWidthRight >= availWidthLeft;
let safeBottom = availHeightBottom >= availHeightTop;
if (!safeBottom) {
this.el.style.maxHeight = availHeightTop + "px";
this.el.style.top = "auto";
this.el.style.bottom = viewport.bottom - box.top + "px";
this.el.dataset.vTo = "top";
}
if (!safeRight) {
this.el.style.left = "auto";
this.el.style.right = viewport.right - boxLeft + "px";
this.el.style.maxWidth = availWidthLeft + "px";
this.el.dataset.hTo = "left";
}
if (this.positionMode === "natural" && (!safeRight || !safeBottom)) {
const { height, width } = getBoundingRect(this.el);
safeBottom = height <= availHeightBottom;
safeRight = width <= availWidthRight;
}
if (safeBottom) {
this.el.style.maxHeight = availHeightBottom + "px";
this.el.style.top = box.bottom - viewport.top + "px";
this.el.style.bottom = "auto";
this.el.dataset.vTo = "bottom";
}
if (safeRight) {
this.el.style.left = boxRight - viewport.left + "px";
this.el.style.right = "auto";
this.el.style.maxWidth = availWidthRight + "px";
this.el.dataset.hTo = "right";
}
this.relatedPopups.forEach((related) => related.updatePosition());
}
freeze() {
this.frozen = true;
this.el.classList.add("frozen");
}
unfreeze() {
this.frozen = false;
this.el.classList.remove("frozen");
this.updatePosition();
}
hide() {
this.lastRenderMarker = null;
if (this.hideTimer !== null) {
clearTimeout(this.hideTimer);
this.hideTimer = null;
}
stopDelayedShow(this);
if (this.visible && !inspectorLockedInstances.has(this)) {
this.relatedPopups.forEach((related) => related.hide());
openedPopups.splice(openedPopups.indexOf(this), 1);
this.el.remove();
this.unfreeze();
if (this.lastTriggerEl) {
this.lastTriggerEl.classList.remove("discovery-view-popup-active");
this.lastTriggerEl = null;
}
if (openedPopups.length === 0) {
window.removeEventListener("resize", hideOnResize);
}
}
}
hideIfEventOutside({ target }) {
if (this.hideIfEventOutsideDisabled || inspectorLockedInstances.has(this)) {
return;
}
if (this.lastTriggerEl && this.lastTriggerEl.contains(target)) {
return;
}
if (findTargetRelatedPopup(this, target)) {
return;
}
this.hide();
}
hideOnTriggerHasLeftDocument() {
if (!isElementNullOrInDocument(this.lastHoverTriggerEl) || !isElementNullOrInDocument(this.lastTriggerEl)) {
this.hide();
}
}
hideOnResize() {
if (this.hideOnResizeDisabled || inspectorLockedInstances.has(this)) {
return;
}
this.hide();
}
destroy() {
inspectorLockedInstances.delete(this);
stopDelayedShow(this);
const popupIndex = hoverTriggerInstances.indexOf(this);
if (popupIndex !== -1) {
hoverTriggerInstances.splice(popupIndex, 1);
}
this.hide();
this.el = null;
this.lastTriggerEl = null;
this.lastHoverTriggerEl = null;
}
}
;
host.view.Popup = Popup;
}
;