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 " " 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;