UNPKG

web-ui-pack

Version:
677 lines (676 loc) 26.8 kB
import { getOffset, PopupPlacements } from "./popupPlacements"; import { findScrollParentAll } from "../helpers/findScrollParent"; import WUPPopupArrowElement from "./popupArrowElement"; import PopupListener from "./popupListener"; import { getBoundingInternalRect, px2Number, styleTransform } from "../helpers/styleHelpers"; import animateDropdown from "../helpers/animateDropdown"; import animateStack from "../helpers/animateStack"; import isIntoView from "../helpers/isIntoView"; import viewportSize from "../helpers/viewportSize"; import WUPBaseModal from "../baseModal"; const attachLst = new Map(); const tagName = "wup-popup"; export default class WUPPopupElement extends WUPBaseModal { static get observedOptions() { return ["openCase", "target", "placement"]; } static get observedAttributes() { return ["w-target", "w-placement", "w-animation"]; } static get mappedAttributes() { return { target: { type: 2, }, placement: { type: 5, parse: (attrValue) => this.$placementAttrs(attrValue) ?? [...this.$defaults.placement], }, animation: { type: 5, parse: (attrValue) => { switch (attrValue) { case "drawer": return 1; case "stack": return 2; default: return 0; } }, }, }; } static $placements = PopupPlacements; static $placementAttrs = (attr) => { switch (attr) { case "top-start": return [PopupPlacements.$top.$start.$adjust, PopupPlacements.$bottom.$start.$adjust]; case "top-middle": return [PopupPlacements.$top.$middle.$adjust, PopupPlacements.$bottom.$middle.$adjust]; case "top-end": return [PopupPlacements.$top.$end.$adjust, PopupPlacements.$bottom.$end.$adjust]; case "bottom-start": return [PopupPlacements.$bottom.$start.$adjust, PopupPlacements.$top.$start.$adjust]; case "bottom-middle": return [PopupPlacements.$bottom.$middle.$adjust, PopupPlacements.$top.$middle.$adjust]; case "bottom-end": return [PopupPlacements.$bottom.$end.$adjust, PopupPlacements.$top.$end.$adjust]; case "left-start": return [PopupPlacements.$left.$start.$adjust, PopupPlacements.$right.$start.$adjust]; case "left-middle": return [PopupPlacements.$left.$middle.$adjust, PopupPlacements.$right.$middle.$adjust]; case "left-end": return [PopupPlacements.$left.$end.$adjust, PopupPlacements.$right.$end.$adjust]; case "right-start": return [PopupPlacements.$right.$start.$adjust, PopupPlacements.$left.$start.$adjust]; case "right-middle": return [PopupPlacements.$right.$middle.$adjust, PopupPlacements.$left.$middle.$adjust]; case "right-end": return [PopupPlacements.$right.$end.$adjust, PopupPlacements.$left.$end.$adjust]; default: return undefined; } }; static get $styleRoot() { return ` :root { --popup-anim-t: 300ms; --popup-text: inherit; --popup-bg: #fff; --popup-shadow: #0003; --tooltip-text: inherit; --tooltip-bg: rgba(255,255,255,0.9); --tooltip-shadow: #0003; } [wupdark] { --popup-text: #d8d8d8; --popup-bg: #2b3645; --popup-shadow: #0006; --tooltip-text: #d8d8d8; --tooltip-bg: rgba(16,70,82,0.9); --tooltip-shadow: #0006; }`; } static get $style() { return `${super.$style} :host, :host-arrow { --popup-anim: var(--popup-anim-t) cubic-bezier(0, 0, 0.2, 1) 0ms; opacity: 0; } :host { top:0;left:0; padding: 4px; margin: 0; box-shadow: 0 1px 4px 0 var(--popup-shadow); color: var(--popup-text); background: var(--popup-bg); text-overflow: ellipsis; } :host[tooltip], :host[tooltip]+:host-arrow { --popup: var(--tooltip-text); --popup-bg: var(--tooltip-bg); --popup-shadow: var(--tooltip-shadow); } :host[show]+:host-arrow { opacity: 1; } @media not all and (prefers-reduced-motion) { :host, :host+:host-arrow { transition: opacity var(--popup-anim); } } :host[w-animation] { transition-property: none; opacity: 1; } :host[w-animation=stack] { overflow: visible; }`; } static $defaults = { animation: 0, placement: [ WUPPopupElement.$placements.$top.$middle.$adjust, WUPPopupElement.$placements.$bottom.$middle.$adjust, ], toFitElement: document.body, openCase: 8, hoverOpenTimeout: 200, hoverCloseTimeout: 500, }; static cloneDefaults() { const d = super.cloneDefaults(); d.placement = [...d.placement]; return d; } static $attach(options, callback) { let popup; const savedDetach = attachLst.get(options.target); if (savedDetach) { console.warn(`${tagName.toUpperCase()}. $attach is called again on the same target. Possible memory leak. Use detach() before new attach`); savedDetach(); } const attach = () => { if (popup && !popup.$options.target) { popup.$options.target = options.target; } const opts = popup ? { ...options, ...popup.$options, target: popup.$options.target } : options; let isHiding = false; const lstn = new PopupListener(opts, (v, e) => { isHiding = false; const isCreate = !popup; if (!popup) { const p = document.body.appendChild(document.createElement(opts.tagName ?? tagName)); popup = p; Object.assign(p._opts, opts); opts.text && p.append(opts.text); p.#attach = () => { p.#attach = attach; return lstn; }; callback?.call(this, p); } if (!popup.goOpen.call(popup, v, e)) { if (isCreate) { popup._refListener = undefined; popup.remove.call(popup); } return null; } return popup; }, async (v, e) => { isHiding = true; const ok = await popup.goClose.call(popup, v, e); if (ok && isHiding) { popup._refListener = undefined; popup.remove.call(popup); popup = undefined; } return ok; }); return lstn; }; const r = attach(); function detach() { if (popup) { popup.$isOpened && popup.goClose.call(popup, 6, null); popup.remove.call(popup); } r.stopListen(); popup = undefined; attachLst.delete(options.target); } attachLst.set(options.target, detach); return detach; } $refArrow; $refresh() { this.#state && this.buildState(); } gotReady() { super.gotReady(); this.init(); } gotRender() { } _refListener; #attach; init() { this.disposeListener(); if (this.#attach) { this._refListener = this.#attach(); } else { if (this._opts.openCase === 1) { this.goOpen(1, null); return; } if (this._opts.openCase !== 0) { this._opts.target = this.defineTarget(); this._refListener = new PopupListener(this._opts, (v, e) => { this.goOpen(v, e); return this.$isOpened ? this : null; }, (v, e) => { this.goClose(v, e); const ok = this.$isClosing || !this.$isOpened; return ok; }); } } } gotChanges(propsChanged) { super.gotChanges(propsChanged); if (propsChanged) { this.$isOpened && this.goClose(7, null); this.init(); } } defineTarget() { let el; const attrTrg = this.getAttribute("w-target"); if (attrTrg) { el = document.querySelector(attrTrg); } else if (this._opts.target) { return this._opts.target; } else { el = this.previousElementSibling; } if (el instanceof HTMLElement || el instanceof SVGElement) { return el; } throw new Error(`${this.tagName}. Target as HTMLElement|SVGElement is not defined'${attrTrg ? ` for ${attrTrg}` : ""}'`); } setMaxHeight(px) { this.style.maxHeight = px ? `${px}px` : ""; if (this.#state?.userStyles?.inheritY) { this.#state.userStyles.inheritY.style.maxHeight = this.style.maxHeight; } } setMaxWidth(px) { this.style.maxWidth = px ? `${px}px` : ""; if (this.#state?.userStyles?.inheritX) { this.#state.userStyles.inheritX.style.maxWidth = this.style.maxWidth; } } #state; buildState() { this.#state = {}; this.setMaxWidth(null); this.setMaxHeight(null); this.style.minWidth = ""; this.style.minHeight = ""; const target = this._opts.target; if (!target.isConnected) { throw new Error(`${this.tagName}. Target is not appended to document`); } if (!this._opts.animation) { this.style.transform = ""; } const style = getComputedStyle(this); let child = this.children.item(0); if (!(child instanceof HTMLElement) && !(child instanceof SVGElement)) { child = null; } this.#state.userStyles = { maxW: px2Number(style.maxWidth), minW: Math.max(5, px2Number(style.paddingRight) + px2Number(style.paddingLeft), px2Number(style.minWidth)), maxH: px2Number(style.maxHeight), minH: Math.max(5, px2Number(style.paddingTop) + px2Number(style.paddingBottom), px2Number(style.minHeight)), borderRadius: 0, inheritY: child && style.overflowY === "visible" ? child : null, inheritX: child && style.overflowX === "visible" ? child : null, animTime: Number.parseFloat(style.animationDuration.substring(0, style.animationDuration.length - 1)) * 1000, }; this.#state.scrollParents = findScrollParentAll(target) ?? undefined; if (this._opts.arrowEnable) { const el = this.$refArrow || document.createElement(WUPPopupArrowElement.tagName); this.$refArrow = el; const nextEl = this.nextSibling; if (nextEl) { this.parentElement.insertBefore(el, nextEl); } else { this.parentElement.appendChild(el); } if (this._opts.arrowClass) { el.className = this._opts.arrowClass; } this.#state.userStyles.borderRadius = Math.max.apply(this, style.borderRadius.split(" ").map((s) => px2Number(s))); } if (!this._opts.placement.length) { this._opts.placement.push(PopupPlacements.$top.$middle.$adjust); } const adjustRules = this._opts.placement .filter((v) => v.$adjust) .map((v) => v.$adjust); const otherRules = Object.keys(PopupPlacements) .filter((k) => !this._opts.placement.includes(PopupPlacements[k].$middle) && !adjustRules.includes(PopupPlacements[k].$middle.$adjust)) .map((k) => PopupPlacements[k].$middle.$adjust); this.#state.placements = [ ...this._opts.placement, ...adjustRules, ...adjustRules.map((v) => v.$resizeWidth), ...adjustRules.map((v) => v.$resizeHeight), ...otherRules, ...otherRules.map((v) => v.$resizeWidth), ...otherRules.map((v) => v.$resizeHeight), ]; } _stopAnimation; goAnimate(animTime, isHide) { if (!isHide && animTime) { this.style.opacity = "0"; setTimeout(() => (this.style.opacity = ""), 2); } this._isStopChanges = true; if (this._opts.animation === 1) { this.setAttribute("w-animation", "drawer"); this._isStopChanges = false; const pa = animateDropdown(this, animTime, isHide); this._stopAnimation = () => { delete this._stopAnimation; pa.stop(this._opts.animation !== 1); }; return pa; } if (this._opts.animation === 2) { this.setAttribute("w-animation", "stack"); this._isStopChanges = false; const items = this.querySelector("[items]") || this.querySelector("li")?.parentElement.children || (this.children.length > 1 && this.children) || (this.children[0]?.children.length && this.children[0].children) || this.children; const pos = this.getAttribute("position"); const isVertical = pos === "bottom" || pos === "top"; const pa = animateStack(this.$options.target, items, animTime, isHide, isVertical); this._stopAnimation = () => { delete this._stopAnimation; pa.stop(this._opts.animation !== 2); }; return pa; } this.removeAttribute("w-animation"); this._isStopChanges = false; return new Promise((resolve) => { const t = setTimeout(() => resolve(true), animTime); this._stopAnimation = () => { delete this._stopAnimation; clearTimeout(t); resolve(false); }; }); } goOpen(openCase, ev) { this._opts.target = this.defineTarget(); if (openCase === 0 && this._refListener) { this._refListener.open(null, ev); return this._whenOpen; } return super.goOpen(openCase ?? 0, ev); } gotOpen(openCase, ev) { this._stopAnimation?.call(this); this.#state && window.cancelAnimationFrame(this.#state.frameId); this.buildState(); const goUpdatePos = () => { if (this.$isOpened) { this.#state.prevRect = this.updatePosition(); const id = window.requestAnimationFrame(goUpdatePos); if (this.#state) { this.#state.frameId = id; this.#state.prevSize = { w: this.offsetWidth, h: this.offsetHeight }; } } }; goUpdatePos(); this.goAnimate(this.animTime, false); } goClose(closeCase, ev) { if (closeCase === 0 && this._refListener) { this._refListener.close(null, ev); return this._whenClose; } const skipWait = closeCase === 7 || closeCase === 6; return super.goClose(closeCase ?? 0, ev, skipWait); } gotClose(closeCase, ev, immediately) { this._stopAnimation?.call(this); !immediately && this.goAnimate(this.animTime, true).then((isEnd) => { if (isEnd) { const fix = this._whenClose; this.resetState(); this._whenClose = fix; } }); } getTargetRect(target) { return target.getBoundingClientRect(); } updatePosition() { const trg = this._opts.target; if (!trg.isConnected) { this.goClose(6, null); this.#attach && this.remove(); return undefined; } const tRect = this.getTargetRect(trg); if (!tRect.width || !tRect.height) { this.style.display = "none"; return this.#state.prevRect; } const screenSize = viewportSize(); const isScreenChanged = this.#state.prevScreenSize && (this.#state.prevScreenSize.w !== screenSize.vw || this.#state.prevScreenSize.h !== screenSize.vh); if (!isScreenChanged && this.#state.prevRect && this.#state.prevRect.top === tRect.top && this.#state.prevRect.left === tRect.left && this.#state.prevRect.width === tRect.width && this.#state.prevRect.height === tRect.height && this.#state.prevSize && this.#state.prevSize.h === this.offsetHeight && this.#state.prevSize.w === this.offsetWidth) { return this.#state.prevRect; } isScreenChanged && this.buildState(); this.#state.prevScreenSize = { w: screenSize.vw, h: screenSize.vh }; const fitEl = this._opts.toFitElement; const fit = getBoundingInternalRect(fitEl); fit.el = fitEl; const a = this._opts.offsetFitElement; if (a) { fit.top += a[0]; fit.right -= a[1]; fit.bottom -= a[2] ?? a[0]; fit.left += a[3] ?? a[1]; fit.width = fit.right - fit.left; fit.height = fit.bottom - fit.top; } let ds = fit.bottom - screenSize.vh; if (ds > 0) { fit.bottom -= ds; fit.height -= ds; } ds = fit.right - screenSize.vw; if (ds > 0) { fit.right -= ds; fit.width -= ds; } const tdef = { height: Math.round(tRect.height), width: Math.round(tRect.width), top: Math.round(tRect.top), left: Math.round(tRect.left), bottom: Math.round(tRect.bottom), right: Math.round(tRect.right), }; const maxW = Math.min(this.#state.userStyles.maxW || Number.MAX_SAFE_INTEGER, screenSize.vw - (a ? a[2] ?? a[0] : 0) * 2); const maxH = Math.min(this.#state.userStyles.maxH || Number.MAX_SAFE_INTEGER, screenSize.vh - (a ? a[3] ?? a[1] : 0) * 2); if (this._opts.minWidthByTarget) { this.style.minWidth = `${Math.min(tdef.width, maxW)}px`; } else if (this.style.minWidth) { this.style.minWidth = ""; } if (this._opts.minHeightByTarget) { this.style.minHeight = `${Math.min(tdef.height, maxH)}px`; } else if (this.style.minHeight) { this.style.minHeight = ""; } this.style.display = ""; this.setMaxHeight(0); let _defMaxW = 0; if (this._opts.maxWidthByTarget) { _defMaxW = Math.min(tdef.width, maxW); } else { this.setMaxWidth(0); if (this.offsetWidth > screenSize.vw) { _defMaxW = screenSize.vw; } } _defMaxW !== 0 && this.setMaxWidth(_defMaxW); let _defMaxH = 0; if (this.offsetHeight > screenSize.vh) { _defMaxH = screenSize.vh; } _defMaxH !== 0 && this.setMaxHeight(_defMaxH); if (this.$refArrow) { this.$refArrow.style.display = ""; this.$refArrow.style.width = ""; this.$refArrow.style.height = ""; } const { offset } = this._opts; const me = { w: this.offsetWidth, h: this.offsetHeight, el: this, offset: getOffset(typeof offset === "function" ? offset.call(this, tRect) : offset), minH: this.#state.userStyles.minH, minW: this.#state.userStyles.minW, arrow: this.$refArrow ? { h: this.$refArrow.offsetHeight, w: this.$refArrow.offsetWidth, offset: getOffset(this._opts.arrowOffset), } : { h: 0, w: 0, offset: { bottom: 0, left: 0, right: 0, top: 0 } }, }; const t = { el: trg, top: tdef.top - me.offset.top, left: tdef.left - me.offset.left, right: tdef.right + me.offset.right, bottom: tdef.bottom + me.offset.bottom, height: 0, width: 0, }; if (this.#state.scrollParents) { const viewResult = isIntoView(t.el, { scrollParents: this.#state.scrollParents, elRect: t }); const isHiddenByScroll = viewResult.hidden; if (isHiddenByScroll) { this.style.display = "none"; if (this.$refArrow) { this.$refArrow.style.display = "none"; } return tRect; } let sp = this.#state.scrollParents[0]; sp = sp === document.documentElement ? document.body : sp; const scrollRect = getBoundingInternalRect(sp); t.top = Math.max(scrollRect.top, t.top); t.bottom = Math.min(scrollRect.bottom, t.bottom); t.left = Math.max(scrollRect.left, t.left); t.right = Math.min(scrollRect.right, t.right); } t.height = t.bottom - t.top; t.width = t.right - t.left; let lastRule; const process = () => { const hasOverflow = (p, meSize) => p.left < fit.left || p.top < fit.top || p.freeW < this.#state.userStyles.minW || p.freeH < this.#state.userStyles.minH || p.left + Math.min(meSize.w, p.maxW || Number.MAX_SAFE_INTEGER, maxW) > fit.right || p.top + Math.min(meSize.h, p.maxH || Number.MAX_SAFE_INTEGER, maxH) > fit.bottom; let pos = {}; const isOk = this.#state.placements.some((pfn) => { lastRule = pfn; pos = pfn(t, me, fit); let ok = !hasOverflow(pos, me); if (ok) { if (pos.maxW != null && !_defMaxW) { this.setMaxWidth(pos.maxW); } if (pos.maxH != null && !_defMaxH) { this.setMaxHeight(pos.maxH); } if (this.offsetHeight !== me.h || this.offsetWidth !== me.w) { const meUpdated = { ...me, w: this.offsetWidth, h: this.offsetHeight }; pos = pfn(t, meUpdated, fit); ok = !hasOverflow(pos, meUpdated); if (!ok) { this.setMaxWidth(_defMaxW); this.setMaxHeight(_defMaxH); } } } return ok; }); !isOk && console.error(`${this.tagName}. Impossible to place without overflow`, this); if (this.$refArrow) { const checkSize = (relatedSize) => { const maxArrowSize = Math.max(relatedSize - this.#state.userStyles.borderRadius * 2, 0); if (me.arrow.w > maxArrowSize) { me.arrow.w = maxArrowSize; me.arrow.h = maxArrowSize / 2; this.$refArrow.style.width = `${me.arrow.w}px`; this.$refArrow.style.height = `${me.arrow.h}px`; pos = lastRule(t, me, fit); } }; if (pos.arrowLeft == null) { checkSize(this.offsetWidth); pos.arrowLeft = t.left + t.width / 2 - me.arrow.w / 2; pos.arrowLeft = Math.round(Math.min(Math.max(pos.arrowLeft, pos.left + this.#state.userStyles.borderRadius), pos.left + this.offsetWidth - me.arrow.w - this.#state.userStyles.borderRadius)); } else if (pos.arrowTop == null) { checkSize(this.offsetHeight); pos.arrowTop = t.top + t.height / 2 - me.arrow.h / 2; pos.arrowTop = Math.round(Math.min(Math.max(pos.arrowTop, pos.top + this.#state.userStyles.borderRadius + me.arrow.h / 2), pos.top + this.offsetHeight - this.#state.userStyles.borderRadius - me.arrow.w / 2 - me.arrow.h / 2)); } this.$refArrow.style.transform = `translate(${pos.arrowLeft}px, ${pos.arrowTop}px) rotate(${pos.arrowAngle}.1deg)`; } this.style.transform = `translate(${pos.left}px, ${pos.top}px)`; this.setAttribute("position", pos.attr); return pos; }; const was = styleTransform(this, "translate", ""); const pos = process(); const meRect = this.getBoundingClientRect(); const dx = Math.round(meRect.left - pos.left); const dy = Math.round(meRect.top - pos.top); if (dx || dy) { styleTransform(this, "translate", `${pos.left - dx}px, ${pos.top - dy}px`); this.$refArrow && styleTransform(this.$refArrow, "translate", `${pos.arrowLeft - dx}px, ${pos.arrowTop - dy}px`); } if (was) { this.style.transform += ` ${was}`; } return t.el.getBoundingClientRect(); } resetState() { super.resetState(); delete this._stopAnimation; this.style.display = ""; this.#state && window.cancelAnimationFrame(this.#state.frameId); this.#state = undefined; if (this.$refArrow) { this.$refArrow.remove(); this.$refArrow = undefined; } } gotRemoved() { this.#state?.frameId && window.cancelAnimationFrame(this.#state.frameId); this.#state = undefined; this.$refArrow?.remove(); this.$refArrow = undefined; super.gotRemoved(); } disposeListener() { this._refListener?.stopListen(); this._refListener = undefined; } dispose() { super.dispose(); this.disposeListener(); } } customElements.define(tagName, WUPPopupElement);