web-ui-pack
Version:
Web package with UI elements
677 lines (676 loc) • 26.8 kB
JavaScript
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; }
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);