mirror of
https://github.com/wekan/wekan.git
synced 2026-02-09 01:34:21 +01:00
696 lines
No EOL
26 KiB
JavaScript
696 lines
No EOL
26 KiB
JavaScript
import { BlazeComponent } from 'meteor/peerlibrary:blaze-components';
|
|
import { Template } from 'meteor/templating';
|
|
|
|
const PopupBias = {
|
|
Before: Symbol("S"),
|
|
Overlap: Symbol("M"),
|
|
After: Symbol("A"),
|
|
Fullscreen: Symbol("F"),
|
|
includes(e) {
|
|
return Object.values(this).includes(e);
|
|
}
|
|
}
|
|
|
|
// this class is a bit cumbersome and could probably be done simpler.
|
|
// it manages two things : initial placement and sizing given an opener element,
|
|
// and then movement and resizing. one difficulty was to be able, as a popup
|
|
// which can be resized from the "outside" (CSS4) and move from the inside (inner
|
|
// component), which also grows and shrinks frequently, to adapt.
|
|
// I tried many approach and failed to get the perfect fit; I feel that there is
|
|
// always something indeterminate at some point. so the only drawback is that
|
|
// if a popup contains another resizable component (e.g. card details), and if
|
|
// it has been resized (with CSS handle), it will lose its dimensions when dragging
|
|
// it next time.
|
|
class PopupDetachedComponent extends BlazeComponent {
|
|
onCreated() {
|
|
// Set by parent/caller (usually PopupComponent)
|
|
({ nonPlaceholderOpener: this.nonPlaceholderOpener, closeDOMs: this.closeDOMs = [], followDOM: this.followDOM } = this.data());
|
|
|
|
|
|
if (typeof(this.closeDOMs) === "string") {
|
|
// helper for passing arg in JADE template
|
|
this.closeDOMs = this.closeDOMs.split(';');
|
|
}
|
|
|
|
// The popup's own header, if it exists
|
|
this.closeDOMs.push("click .js-close-detached-popup");
|
|
}
|
|
|
|
// Main intent of this component is to have a modular popup with defaults:
|
|
// - sticks to its opener while being a child of body (thus in the same stacking context, no z-index issue)
|
|
// - is responsive on shrink while keeping position absolute
|
|
// - can grow back to initial position step by step
|
|
// - exposes various sizes as CSS variables so each rendered popup can use them to adapt defaults
|
|
// * issue is that it is done by hand, with heurisitic/simple algorithm from my thoughts, not sure it covers edge cases
|
|
// * however it works well so far and maybe more "fixed" element should be popups
|
|
onRendered() {
|
|
// Remember initial ratio between initial dimensions and viewport
|
|
const viewportHeight = window.innerHeight;
|
|
const viewportWidth = window.innerWidth;
|
|
|
|
this.popup = this.firstNode();
|
|
this.popupOpener = this.data().openerElement;
|
|
|
|
const popupStyle = window.getComputedStyle(this.firstNode());
|
|
// margin may be in a relative unit, not computable in JS, but we get the actual pixels here
|
|
this.popupMargin = parseFloat(popupStyle.getPropertyValue("--popup-margin"), 10) || Math.min(window.innerWidth / 50, window.innerHeight / 50);
|
|
|
|
this.dims(this.computeMaxDims());
|
|
|
|
this.initialPopupWidth = this.popupDims.width;
|
|
this.initialPopupHeight = this.popupDims.height;
|
|
this.initialHeightRatio = this.initialPopupHeight / viewportHeight;
|
|
this.initialWidthRatio = this.initialPopupWidth / viewportWidth;
|
|
|
|
this.dims(this.computePopupDims());
|
|
|
|
|
|
if (this.followDOM) {
|
|
this.innerElement = this.find(this.followDOM) ?? document.querySelector(this.followDOM);
|
|
}
|
|
|
|
this.follow();
|
|
this.toFront();
|
|
|
|
// #FIXME the idea of keeping the initial ratio on resize is quite bad. remove that part.
|
|
// there is a reactive variable for window resize in Utils, but the interface is too slow
|
|
// with all reactive stuff, use events when possible and when not really bypassing logic
|
|
$(window).on('resize', () => {
|
|
// #FIXME there is a bug when window grows; popup outer container
|
|
// will grow beyond the size of content and it's not easy to fix for me (and I feel tired of this popup)
|
|
this.dims(this.computePopupDims());
|
|
});
|
|
}
|
|
|
|
margin() {
|
|
return this.popupMargin;
|
|
}
|
|
|
|
ensureDimsLimit(dims) {
|
|
// boilerplate to make sure that popup visually fits
|
|
let { left, top, width, height } = dims;
|
|
let overflowBottom = top + height + 2 * this.margin() - window.innerHeight;
|
|
let overflowRight = left + width + 2 * this.margin() - window.innerWidth;
|
|
if (overflowRight > 0) {
|
|
width = Math.max(20 * this.margin(), Math.min(width - overflowRight, window.innerWidth - 2 * this.margin()));
|
|
}
|
|
if (overflowBottom > 0) {
|
|
height = Math.max(10 * this.margin(), Math.min(height - overflowBottom, window.innerHeight - 2 * this.margin()));
|
|
}
|
|
left = Math.max(left, this.margin());
|
|
top = Math.max(top, this.margin());
|
|
return { left, top, width, height }
|
|
}
|
|
|
|
dims(newDims) {
|
|
if (!this.popupDims) {
|
|
this.popupDims = {};
|
|
}
|
|
if (newDims) {
|
|
newDims = this.ensureDimsLimit(newDims);
|
|
for (const e of Object.keys(newDims)) {
|
|
let value = parseFloat(newDims[e]);
|
|
if (!isNaN(value)) {
|
|
$(this.popup).css(e, `${value}px`);
|
|
this.popupDims[e] = value;
|
|
}
|
|
}
|
|
}
|
|
return this.popupDims;
|
|
}
|
|
|
|
isFullscreen() {
|
|
return this.fullscreen;
|
|
}
|
|
|
|
maximize() {
|
|
this.fullscreen = true;
|
|
this.dims(this.computePopupDims());
|
|
if (this.innerElement) {
|
|
$(this.innerElement).css('width', '');
|
|
$(this.innerElement).css('height', '')
|
|
}
|
|
}
|
|
|
|
minimize() {
|
|
this.fullscreen = false;
|
|
this.dims(this.computePopupDims());
|
|
}
|
|
|
|
follow() {
|
|
const adaptChild = new ResizeObserver((_) => {
|
|
if (this.fullscreen) {return}
|
|
const width = this.innerElement?.scrollWidth || this.popup.scrollWidth;
|
|
const height = this.innerElement?.scrollHeight || this.popup.scrollHeight;
|
|
// we don't want to run this during something that we have caused, eg. dragging
|
|
if (!this.mouseDown) {
|
|
// extra-"future-proof" stuff: if somebody adds a margin to the popup, it would trigger a loop
|
|
if (Math.abs(this.dims().width - width) < 20 && Math.abs(this.dims().height - height) < 20) { return }
|
|
|
|
// if inner shrinks, follow
|
|
if (width < this.dims().width || height < this.dims().height) {
|
|
this.dims({ width, height });
|
|
}
|
|
// otherwise it may be complicated to find a generic situation, but we have the
|
|
// classic positionning procedure which works, so use it and ignore positionning
|
|
else {
|
|
const newDims = this.computePopupDims();
|
|
// a bit twisted/ad-hoc for card details, in the edge case where they are opened when collapsed then uncollapsed,
|
|
// not sure to understand why the sizing works differently that starting uncollapsed then doing the same sequence
|
|
this.dims(this.ensureDimsLimit({
|
|
top: this.dims().top,
|
|
left: this.dims().left,
|
|
width: Math.max(newDims.width, width),
|
|
height: Math.max(newDims.height, height)
|
|
}));
|
|
}
|
|
}
|
|
else {
|
|
const { width, height } = this.popup.getBoundingClientRect();
|
|
// only case when we bypass .dims(), to avoid loop
|
|
this.popupDims.width = width;
|
|
this.popupDims.height = height;
|
|
}
|
|
});
|
|
|
|
if (this.innerElement) {
|
|
adaptChild.observe(this.innerElement);
|
|
} else {
|
|
adaptChild.observe(this.popup);
|
|
}
|
|
}
|
|
|
|
currentZ(z = undefined) {
|
|
// relative, add a constant to be above root elements
|
|
if (z !== undefined) {
|
|
this.firstNode().style.zIndex = parseInt(z) + 10;
|
|
}
|
|
return parseInt(this.firstNode().style.zIndex) - 10;
|
|
}
|
|
|
|
// a bit complex...
|
|
toFront() {
|
|
this.currentZ(Math.max(...PopupComponent.stack.map(p => BlazeComponent.getComponentForElement(p.outerView.firstNode()).currentZ())) || 0 + 1);
|
|
|
|
}
|
|
|
|
toBack() {
|
|
this.currentZ(Math.min(...PopupComponent.stack.map(p => BlazeComponent.getComponentForElement(p.outerView.firstNode()).currentZ())) || 1 - 1);
|
|
}
|
|
|
|
events() {
|
|
// needs to be done at this level; "parent" is not a parent in DOM
|
|
let closeEvents = {};
|
|
|
|
this.closeDOMs?.forEach((e) => {
|
|
closeEvents[e] = (_) => {
|
|
this.parentComponent().destroy();
|
|
}
|
|
})
|
|
|
|
const miscEvents = {
|
|
'click .js-confirm'() {
|
|
this.data().afterConfirm?.call(this);
|
|
},
|
|
// bad heuristic but only for best-effort UI
|
|
'pointerdown .pop-over'() {
|
|
this.mouseDown = true;
|
|
},
|
|
'pointerup .pop-over'() {
|
|
this.mouseDown = false;
|
|
}
|
|
};
|
|
|
|
const movePopup = (event) => {
|
|
event.preventDefault();
|
|
$(event.target).addClass('is-active');
|
|
const deltaHandleX = this.dims().left - event.clientX;
|
|
const deltaHandleY = this.dims().top - event.clientY;
|
|
|
|
const onPointerMove = (e) => {
|
|
this.dims(this.ensureDimsLimit({ left: e.clientX + deltaHandleX, top: e.clientY + deltaHandleY, width: this.dims().width, height: this.dims().height }));
|
|
|
|
if (this.popup.scrollY) {
|
|
this.popup.scrollTo(0, 0);
|
|
}
|
|
};
|
|
|
|
const onPointerUp = (event) => {
|
|
$(document).off('pointermove', onPointerMove);
|
|
$(document).off('pointerup', onPointerUp);
|
|
$(event.target).removeClass('is-active');
|
|
};
|
|
|
|
if (Utils.shouldIgnorePointer(event)) {
|
|
onPointerUp(event);
|
|
return;
|
|
}
|
|
|
|
$(document).on('pointermove', onPointerMove);
|
|
$(document).on('pointerup', onPointerUp);
|
|
};
|
|
|
|
// We do not manage dragging without our own header
|
|
const handleDOM = this.data().handleDOM;
|
|
if (this.data().showHeader) {
|
|
const handleSelector = Utils.isMiniScreen() ? '.js-popup-drag-handle' : '.header-title';
|
|
miscEvents[`pointerdown ${handleSelector}`] = (e) => movePopup(e);
|
|
}
|
|
if (handleDOM) {
|
|
miscEvents[`pointerdown ${handleDOM}`] = (e) => movePopup(e);
|
|
}
|
|
return super.events().concat(closeEvents).concat(miscEvents);
|
|
}
|
|
|
|
computeMaxDims() {
|
|
// Get size of inner content, even if it overflows
|
|
const content = this.find('.content');
|
|
let popupHeight = content.scrollHeight;
|
|
let popupWidth = content.scrollWidth;
|
|
if (this.data().showHeader) {
|
|
const headerRect = this.find('.header');
|
|
popupHeight += headerRect.scrollHeight;
|
|
popupWidth = Math.max(popupWidth, headerRect.scrollWidth)
|
|
}
|
|
return { width: Math.max(popupWidth, $(this.popup).width()), height: Math.max(popupHeight, $(this.popup).height()) };
|
|
|
|
}
|
|
|
|
placeOnSingleDimension(elementLength, openerPos, openerLength, maxLength, biases, n) {
|
|
// avoid too much recursion if no solution
|
|
if (!n) {
|
|
n = 0;
|
|
}
|
|
if (n >= 5) {
|
|
// if we exhausted a bias, remove it
|
|
n = 0;
|
|
biases.pop();
|
|
if (biases.length === 0) {
|
|
return -1;
|
|
}
|
|
} else {
|
|
n += 1;
|
|
}
|
|
|
|
if (!biases?.length) {
|
|
const cut = maxLength / 3;
|
|
|
|
if (openerPos < cut) {
|
|
// Corresponds to the default ordering: if element is close to the axe's start,
|
|
// try to put the popup after it; then to overlap; and give up otherwise.
|
|
biases = [PopupBias.After, PopupBias.Overlap]
|
|
}
|
|
else if (openerPos > 2 * cut) {
|
|
// Same idea if popup is close to the end
|
|
biases = [PopupBias.Before, PopupBias.Overlap]
|
|
}
|
|
else {
|
|
// If in the middle, try to overlap: choosing between start or end, even for
|
|
// default, is too arbitrary; a custom order can be passed in argument.
|
|
biases = [PopupBias.Overlap]
|
|
}
|
|
}
|
|
// Remove the first element and get it
|
|
const bias = biases.splice(0, 1)[0];
|
|
|
|
let factor;
|
|
const openerRef = openerPos + openerLength / 2;
|
|
if (bias === PopupBias.Before) {
|
|
factor = 1;
|
|
}
|
|
else if (bias === PopupBias.Overlap) {
|
|
factor = openerRef / maxLength;
|
|
}
|
|
else {
|
|
factor = 0;
|
|
}
|
|
|
|
let candidatePos = openerRef - elementLength * factor;
|
|
const deltaMax = candidatePos + elementLength - maxLength;
|
|
if (candidatePos < 0 || deltaMax > 0) {
|
|
if (deltaMax <= 2 * this.margin()) {
|
|
// if this is just a matter of margin, try again
|
|
// useful for (literal) corner cases
|
|
biases = [bias].concat(biases);
|
|
openerPos -= 5;
|
|
}
|
|
if (biases.length === 0) {
|
|
// we could have returned candidate position even if the size is too large, so
|
|
// that the caller can choose, but it means more computations and edge cases...
|
|
// any negative means fullscreen overall as the caller will take the maximum between
|
|
// margin and candidate.
|
|
return -1;
|
|
}
|
|
return this.placeOnSingleDimension(elementLength, openerPos, openerLength, maxLength, biases, n);
|
|
}
|
|
return candidatePos;
|
|
}
|
|
|
|
computePopupDims() {
|
|
if (!this.isRendered?.()) {
|
|
return;
|
|
}
|
|
|
|
// Coordinates of opener related to viewport
|
|
let { x: parentX, y: parentY } = this.nonPlaceholderOpener.getBoundingClientRect();
|
|
let { height: parentHeight, width: parentWidth } = this.nonPlaceholderOpener.getBoundingClientRect();
|
|
|
|
// Initial dimensions scaled to the viewport, if it has changed
|
|
let popupHeight = window.innerHeight * this.initialHeightRatio;
|
|
let popupWidth = window.innerWidth * this.initialWidthRatio;
|
|
|
|
if (this.fullscreen || Utils.isMiniScreen() && popupWidth >= 4 * window.innerWidth / 5 && popupHeight >= 4 * window.innerHeight / 5) {
|
|
// Go fullscreen!
|
|
popupWidth = window.innerWidth;
|
|
// Avoid address bar, let a bit of margin to scroll
|
|
popupHeight = 4 * window.innerHeight / 5;
|
|
return ({
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
left: 0,
|
|
top: 0,
|
|
});
|
|
} else {
|
|
// Current viewport dimensions
|
|
let maxHeight = window.innerHeight - this.margin() * 2;
|
|
let maxWidth = window.innerWidth - this.margin() * 2;
|
|
let biasX, biasY;
|
|
if (Utils.isMiniScreen()) {
|
|
// On mobile I found that being able to close a popup really close from where it has been clicked
|
|
// is comfortable; so given that the close button is top-right, we prefer the position of
|
|
// popup being right-bottom, when possible. We then try every position, rather than choosing
|
|
// relatively to the relative position of opener in viewport
|
|
biasX = [PopupBias.Before, PopupBias.Overlap, PopupBias.After];
|
|
biasY = [PopupBias.After, PopupBias.Overlap, PopupBias.Before];
|
|
}
|
|
|
|
const candidateX = this.placeOnSingleDimension(popupWidth, parentX, parentWidth, maxWidth, biasX);
|
|
const candidateY = this.placeOnSingleDimension(popupHeight, parentY, parentHeight, maxHeight, biasY);
|
|
|
|
// Reasonable defaults that can be overriden by CSS later: popups are tall, try to fit the reste
|
|
// of the screen starting from parent element, or full screen if element if not fitting
|
|
return ({
|
|
width: popupWidth,
|
|
height: popupHeight,
|
|
left: candidateX,
|
|
top: candidateY,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
class PopupComponent extends BlazeComponent {
|
|
static stack = [];
|
|
// good enough as long as few occurences of such cases
|
|
static multipleBlacklist = ["cardDetails"];
|
|
|
|
// to provide compatibility with Popup.open().
|
|
static open(args) {
|
|
const openerView = Blaze.getView(args.openerElement);
|
|
if (!openerView) {
|
|
console.warn(`no parent found for popup ${args.name}, attaching to body: this should not happen`);
|
|
}
|
|
|
|
|
|
// render ourselves; everything is automatically managed from that moment, we just added
|
|
// a level of indirection but this will not interfere with data
|
|
const popup = new PopupComponent();
|
|
Blaze.renderWithData(
|
|
popup.renderComponent(BlazeComponent.currentComponent()),
|
|
args,
|
|
args.openerElement,
|
|
null,
|
|
openerView
|
|
);
|
|
return popup;
|
|
}
|
|
|
|
static destroy() {
|
|
PopupComponent.stack.at(-1)?.destroy();
|
|
}
|
|
|
|
static findParentPopup(element) {
|
|
return BlazeComponent.getComponentForElement($(element).closest('.pop-over')[0]);
|
|
}
|
|
|
|
static toFront(event) {
|
|
const popup = PopupComponent.findParentPopup(event.target)
|
|
popup?.toFront();
|
|
return popup;
|
|
}
|
|
|
|
static toBack(event) {
|
|
const popup = PopupComponent.findParentPopup(event.target);
|
|
popup?.toBack();
|
|
return popup;
|
|
}
|
|
|
|
static maximize(event) {
|
|
const popup = PopupComponent.findParentPopup(event.target);
|
|
popup?.toFront();
|
|
popup?.maximize();
|
|
return popup;
|
|
}
|
|
|
|
static minimize(event) {
|
|
const popup = PopupComponent.findParentPopup(event.target);
|
|
popup?.minimize();
|
|
return popup;
|
|
}
|
|
|
|
|
|
getOpenerElement(view) {
|
|
// Look for the first parent view whose first DOM element is not virtually us
|
|
const firstNode = $(view.firstNode());
|
|
|
|
// The goal is to have the best chances to get the element whose size and pos
|
|
// are relevant; e.g. when clicking on a date on a minicard, we don't wan't
|
|
// the opener to be set to the minicard.
|
|
// In order to work in general, we need to take special situations into account,
|
|
// e.g. the placeholder is isolated, or does not have previous node, and so on.
|
|
// In general we prefer previous node, then next, then any displayed sibling,
|
|
// then the parent, and so on.
|
|
let candidates = [];
|
|
if (!firstNode.hasClass(this.popupPlaceholderClass())) {
|
|
candidates.push(firstNode);
|
|
}
|
|
candidates = candidates.concat([firstNode.prev(), firstNode.next()]);
|
|
const otherSiblings = Array.from(firstNode.siblings()).filter(e => !candidates.includes(e));
|
|
|
|
for (const cand of candidates.concat(otherSiblings)) {
|
|
const displayCSS = cand?.css("display");
|
|
if (displayCSS && displayCSS !== "none") {
|
|
return cand[0];
|
|
}
|
|
}
|
|
return this.getOpenerElement(view.parentView);
|
|
}
|
|
|
|
getParentData(view) {;
|
|
let data;
|
|
// ⚠️ node can be a text node
|
|
while (view.firstNode?.()?.classList?.contains(this.popupPlaceholderClass())) {
|
|
view = view.parentView;
|
|
data = Blaze.getData(view);
|
|
}
|
|
// This is VERY IMPORTANT to get data like this and not with templateInstance.data,
|
|
// because this form is reactive. So all inner popups have reactive data, which is nice
|
|
return data;
|
|
}
|
|
|
|
onCreated() {
|
|
// #FIXME prevent secondary popups to open
|
|
// Special "magic number" case: never render, for any reason, the same card
|
|
// const maybeID = this.parentComponent?.()?.data?.()?._id;
|
|
// if (maybeID && PopupComponent.stack.find(e => e.parentComponent().data?.()?._id === maybeID)) {
|
|
// this.destroy();
|
|
// return;
|
|
// }
|
|
// do not render a template multiple times
|
|
const existing = PopupComponent.stack.find((e) => (e.name == this.data().name));
|
|
if (existing && PopupComponent.multipleBlacklist.indexOf(this.data().name)) {
|
|
// ⚠️ is there a default better than another? I feel that closing existing
|
|
// popup is not bad in general because having the same button for open and close
|
|
// is common
|
|
if (PopupComponent.multipleBlacklist.includes(existing.name)) {
|
|
existing.destroy();
|
|
}
|
|
// but is could also be re-rendering, eg
|
|
// existing.render();
|
|
return;
|
|
}
|
|
|
|
// All of this, except name, is optional. The rest is provided "just in case", for convenience (hopefully)
|
|
//
|
|
// - name is the name of a template to render inside the popup (to the detriment of its size) or the contrary
|
|
// - showHeader can be turned off if the inner content always have a header with buttons and so on
|
|
// - title is shown when header is shown
|
|
// - miscOptions is for compatibility
|
|
// - closeVar is an optional string representing a Session variable: if set, the popup reactively closes when the variable changes and set the variable to null on close
|
|
// - closeDOMs can be used alternatively; it is an array of "<event> <selector>" to listen that closes the popup.
|
|
// if header is shown, closing the popup is already managed. selector is relative to the inner template (same as its event map)
|
|
// - followDOM is an element whose dimension will serve as reference so that popup can react to inner changes; works only with inline styles (otherwise we probably would need IntersectionObserver-like stuff, async etc)
|
|
// - handleDOM is an element who can be clicked to move popup
|
|
// it is useful when the content can be redimensionned/moved by code or user; we still manage events, resizes etc
|
|
// but allow inner elements or handles to do it (and we adapt).
|
|
const data = this.data();
|
|
this.popupArgs = {
|
|
name: data.name,
|
|
showHeader: data.showHeader ?? true,
|
|
title: data.title,
|
|
openerElement: data.openerElement,
|
|
closeDOMs: data.closeDOMs,
|
|
followDOM: data.followDOM,
|
|
handleDOM: data.handleDOM,
|
|
forceData: data.miscOptions?.dataContextIfCurrentDataIsUndefined,
|
|
afterConfirm: data.miscOptions?.afterConfirm,
|
|
}
|
|
this.name = this.data().name;
|
|
|
|
this.innerTemplate = Template[this.name];
|
|
this.innerComponent = BlazeComponent.getComponent(this.name);
|
|
|
|
this.outerComponent = BlazeComponent.getComponent('popupDetached');
|
|
if (!(this.innerComponent || this.innerTemplate)) {
|
|
throw new Error(`template and/or component ${this.name} not found`);
|
|
}
|
|
|
|
// If arg is not set, must be closed manually by calling destroy()
|
|
if (this.popupArgs.closeVar) {
|
|
this.closeInitialValue = Session.get(this.data().closeVar);
|
|
if (!this.closeInitialValue === undefined) {
|
|
this.autorun(() => {
|
|
if (Session.get(this.data().closeVar) !== this.closeInitialValue) {
|
|
this.onDestroyed();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
popupPlaceholderClass() {
|
|
return "popup-placeholder";
|
|
}
|
|
|
|
render() {
|
|
const oldOuterView = this.outerView;
|
|
// see below for comments
|
|
this.outerView = Blaze.renderWithData(
|
|
// data is passed through the parent relationship
|
|
// we need to render it again to keep events in sync with inner popup
|
|
this.outerComponent.renderComponent(this.component()),
|
|
this.popupArgs,
|
|
document.body,
|
|
null,
|
|
this.openerView
|
|
);
|
|
this.innerView = Blaze.renderWithData(
|
|
// the template to render: either the content is a BlazeComponent or a regular template
|
|
// if a BlazeComponent, render it as a template first
|
|
this.innerComponent?.renderComponent?.(this.component()) || this.innerTemplate,
|
|
// dataContext used for rendering: each time we go find data, because it is non-reactive
|
|
() => (this.popupArgs.forceData || this.getParentData(this.currentView)),
|
|
// DOM parent: ask to the detached popup, will be inserted at the last child
|
|
this.outerView.firstNode()?.getElementsByClassName('content')?.[0] || document.body,
|
|
// "stop" DOM element; we don't use
|
|
null,
|
|
// important: this is the Blaze.View object which will be set as `parentView` of
|
|
// the rendered view. we set it as the parent view, so that the detached popup
|
|
// can interact with its "parent" without being a child of it, and without
|
|
// manipulating DOM directly.
|
|
this.openerView
|
|
);
|
|
if (oldOuterView) {
|
|
Blaze.remove(oldOuterView);
|
|
}
|
|
}
|
|
|
|
onRendered() {
|
|
if (this.detached) {return}
|
|
// Use plain Blaze stuff to be able to render all templates, but use components when available/relevant
|
|
this.currentView = Blaze.currentView || Blaze.getView(this.component().firstNode());
|
|
|
|
// Placement will be related to the opener (usually clicked element)
|
|
// But template data and view related to the opener are not the same:
|
|
// - view is probably outer, as is was already rendered on click
|
|
// - template data could be found with Template.parentData(n), but `n` can
|
|
// vary depending on context: using those methods feels more reliable for this use case
|
|
this.popupArgs.openerElement ??= this.getOpenerElement(this.currentView);
|
|
this.openerView = Blaze.getView(this.popupArgs.openerElement);
|
|
// With programmatic/click opening, we get the "real" opener; with dynamic
|
|
// templating we get the placeholder and need to go up to get a glimpse of
|
|
// the "real" opener size. It is quite imprecise in that case (maybe the
|
|
// interesting opener is a sibling, not an ancestor), but seems to do the job
|
|
// for now.
|
|
// Also it feels sane that inner content does not have a reference to
|
|
// a virtual placeholder.
|
|
const opener = this.popupArgs.openerElement;
|
|
let sizedOpener = opener;
|
|
if (opener.classList?.contains?.(this.popupPlaceholderClass())) {
|
|
sizedOpener = opener.parentNode;
|
|
}
|
|
this.popupArgs.nonPlaceholderOpener = sizedOpener;
|
|
|
|
PopupComponent.stack.push(this);
|
|
|
|
try {
|
|
this.render();
|
|
// Render above other popups by default
|
|
} catch(e) {
|
|
// If something went wrong during rendering, do not create
|
|
// "zombie" popups
|
|
console.error(`cannot render popup ${this.name}: ${e}`);
|
|
this.destroy();
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
this.detached = true;
|
|
if (!PopupComponent.stack.includes(this)) {
|
|
// Avoid loop destroy
|
|
return;
|
|
}
|
|
// Maybe overkill but may help to avoid leaking memory
|
|
// as programmatic rendering is less usual
|
|
for (const view of [this.innerView, this.currentView, this.outerView]) {
|
|
try {
|
|
Blaze.remove(view);
|
|
} catch {
|
|
console.warn(`A view failed to be removed: ${view}`)
|
|
}
|
|
}
|
|
this.innerComponent?.removeComponent?.();
|
|
this.outerComponent?.removeComponent?.();
|
|
this.removeComponent();
|
|
|
|
// not necesserly removed in order, e.g. multiple cards
|
|
PopupComponent.stack = PopupComponent.stack.filter(e => e !== this);
|
|
}
|
|
|
|
|
|
closeWithPlaceholder(parentElement) {
|
|
// adapted from https://stackoverflow.com/questions/52834774/dom-event-when-element-is-removed
|
|
// strangely, when opener is removed because of a reactive change, this component
|
|
// do not get any lifecycle hook called, so we need to bridge the gap. Simply
|
|
// "close" popup when placeholder is off-DOM.
|
|
while (parentElement.nodeType === Node.TEXT_NODE) {
|
|
parentElement = parentElement.parentElement;
|
|
}
|
|
const placeholder = parentElement.getElementsByClassName(this.popupPlaceholderClass());
|
|
if (!placeholder.length) {
|
|
return;
|
|
}
|
|
const observer = new MutationObserver(() => {
|
|
// DOM element being suppressed is reflected in array
|
|
if (placeholder.length === 0) {
|
|
this.destroy();
|
|
}
|
|
});
|
|
observer.observe(parentElement, {childList: true});
|
|
}
|
|
}
|
|
|
|
PopupComponent.register("popup");
|
|
PopupDetachedComponent.register('popupDetached');
|
|
|
|
export default PopupComponent; |