import { AtomDisposableList } from "@web-atoms/core/dist/core/AtomDisposableList"; import { CancelToken } from "@web-atoms/core/dist/core/types"; import XNode, { IElementAttributes, xnodeSymbol } from "@web-atoms/core/dist/core/XNode"; import { AtomControl, ElementValueSetters } from "@web-atoms/core/dist/web/controls/AtomControl"; import "./AtomPopover.css"; import { relativeRect } from "./relativeRect"; import getContainingBlock from "./getContainingBlock"; ElementValueSetters["anchor-left"] = (c, e, v) => e.setAttribute("anchor-left", v); ElementValueSetters["anchor-right"] = (c, e, v) => e.setAttribute("anchor-right", v); ElementValueSetters["anchor-top"] = (c, e, v) => e.setAttribute("anchor-top", v); ElementValueSetters["anchor-bottom"] = (c, e, v) => e.setAttribute("anchor-bottom", v); export interface IAnchorPopover extends IElementAttributes { "anchor-left"?: "parent-left" | "parent-right", "anchor-right"?: "parent-left" | "parent-right", "anchor-top"?: "parent-top" | "parent-bottom", "anchor-bottom"?: "parent-top" | "parent-bottom" } declare global { namespace JSX { interface IntrinsicElements { "atom-pop-over": IAnchorPopover; } } } class AtomPopoverElement extends HTMLElement { timer: any; lastParent: HTMLElement; result: any; aborted = "cancel"; root: ShadowRoot; slotElement: HTMLSlotElement; connectedCallback() { // set defaults... if(!this.root) { const root = this.root = this.attachShadow({ mode: "open" }); // const container = document.createElement("div"); // container.setAttribute("part", "root"); const slot = document.createElement("slot"); slot.setAttribute("part", "container"); this.slotElement = slot; root.appendChild(slot); // container.appendChild(slot); // root.appendChild(container); } setTimeout(() => this.updatePosition(), 100); window.addEventListener("scroll", this.updatePosition, { passive : true, capture: true }); setTimeout(() => { document.body.addEventListener("click", this.closePopover); }, 100); } disconnectedCallback() { if(this.lastParent?.isConnected) { this.dispatchEvent(new CustomEvent("removed", { detail: { result: this.result, aborted: this.aborted }, bubbles: true })); } document.body.removeEventListener("click", this.closePopover); window.removeEventListener("scroll", this.updatePosition); clearInterval(this.timer); } closePopover = (e: Event) => { const cancelOnBlur = /^(true|yes|1)$/i.test(this.getAttribute("cancel-on-blur") || "true"); if (!cancelOnBlur) { return; } setTimeout(() => { // this is to let other event handlers handle clicks let target = e.target as HTMLElement; while(target) { if (target.hasAttribute("data-close-on-click")) { break; } if(target === this.parentElement) { return; } target = target.parentElement; } // click was outside this.lastParent = null; const ce = new CustomEvent("removing", { detail: { result: this.result, aborted: this.aborted }, bubbles: true, cancelable: true }); this.dispatchEvent(ce); if (ce.defaultPrevented) { return; } this.dispatchEvent(new CustomEvent("removed", { detail: { result: this.result, aborted: this.aborted }, bubbles: true, })); this.remove(); }, 250); }; updatePosition = () => { if (!this.parentElement) { return; } const lastParent = this.lastParent = this.parentElement; if (!lastParent) { return; } // let start = this.firstElementChild; // while(start) { // start.setAttribute("slot", "container"); // start = start.nextElementSibling; // } const cb = getContainingBlock(lastParent) as HTMLElement; if (!cb) { return; } (this as any).containingBlock = cb; const cbr = cb.getBoundingClientRect(); const rrSelf = relativeRect(this, cb); const rrParent = relativeRect(this.parentElement, cb); let anchorBottom = this.getAttribute("anchor-bottom"); let anchorRight = this.getAttribute("anchor-right"); let anchorTop = this.getAttribute("anchor-top"); let anchorLeft = this.getAttribute("anchor-left"); // auto adjust if the target is outside the // container const firstChild = this.firstElementChild.getBoundingClientRect(); let diffTop = Math.min(window.visualViewport.height, cbr.height) - (rrParent.top + firstChild.height); if(diffTop > 0) { diffTop = 0; } else { diffTop -= 40; if (!anchorBottom) { anchorBottom = "parent-top"; anchorTop = void 0; } } let diffBottom = rrParent.top - firstChild.height; if (diffBottom > 0) { diffBottom = 0; } const topLeft = { "parent-left": `${rrParent.left}px`, "parent-right": `${rrSelf.left}px`, "parent-top": `${rrParent.top + diffTop}px`, "parent-bottom": `${rrParent.top + rrParent.height + diffTop}px` }; const bottomRight = { "parent-left": `${rrParent.right}px`, "parent-right": `${rrSelf.right}px`, "parent-top": `${rrParent.bottom + rrSelf.height + diffBottom}px`, "parent-bottom": `${rrParent.bottom + rrParent.height + diffBottom}px` }; const style = this.slotElement.style; style.removeProperty("left"); style.removeProperty("top"); style.removeProperty("right"); style.removeProperty("bottom"); if (!anchorBottom) { anchorTop ||= "parent-bottom"; } if (!anchorRight) { anchorLeft ||= "parent-right"; } if (anchorTop) { style.top = topLeft[anchorTop]; } if (anchorLeft) { style.left = topLeft[anchorLeft]; } if (anchorBottom) { style.bottom = bottomRight[anchorBottom]; } if (anchorRight) { style.right = bottomRight[anchorRight]; } }; } const existingPopup = Symbol("popup"); export interface IAtomPopoverOptions { nodeFactory?: (data) => XNode | HTMLElement; dataFactory?: () => any; "anchor-left"?: "parent-left" | "parent-right"; "anchor-right"?: "parent-left" | "parent-right"; "anchor-top"?: "parent-top" | "parent-bottom"; "anchor-bottom"?: "parent-top" | "parent-bottom"; closeOnClick?: boolean, cancelToken?: CancelToken; } export default abstract class AtomPopover { owner: AtomControl; popover: HTMLElement; result: any; disposables = new AtomDisposableList(); resultPromise: Promise; resultResolve: (value: T | PromiseLike) => void; resultReject: (reason?: any) => void; parent: HTMLElement; popoverContainer: HTMLElement; removed: boolean; static create( parent: HTMLElement | AtomControl, options: IAtomPopoverOptions = {}, ) { return parent[existingPopup] ??= new (this as any)(parent, options); } static show( parent: HTMLElement | AtomControl, options: IAtomPopoverOptions = {} ) { const p: AtomPopover = (parent[existingPopup] ??= new (this as any)(parent, options)); return p.resultPromise; } static menu( parent: HTMLElement | AtomControl, node: HTMLElement | XNode, options: IAtomPopoverOptions = {} ) { const ct = new CancelToken(); options.nodeFactory = () => node; const p: AtomPopover = new (this as any)(parent, options); p.result = 1; p.disposables.add(p.owner.bindEvent(p.popover, "click", () => { setTimeout(() => { ct.cancel(); }, 10); })); return p; } set renderer(v: XNode | HTMLElement) { const owner = this.owner; let first = this.popoverContainer.firstElementChild as HTMLElement; while (first) { const next = first.nextElementSibling as HTMLElement; owner.dispose(first); first.remove(); first = next; } if (!v) { return; } if (v[xnodeSymbol]) { // @ts-expect-error this.owner.render(
{v}
, this.popoverContainer, this.owner); } else { this.popoverContainer.appendChild(v as HTMLElement); } (this.popoverContainer as any).updatePosition?.(); } constructor( parent: HTMLElement | AtomControl, options: IAtomPopoverOptions = {} ) { const p1 = parent; this.disposables.add(() => p1[existingPopup] = null); if (parent instanceof AtomControl) { this.owner = parent; parent = this.owner.element; } else { this.owner = AtomControl.from(parent); } const popover = this.popover = document.createElement("atom-pop-over"); this.popoverContainer = popover; parent.appendChild(popover); const { cancelToken, nodeFactory, dataFactory, closeOnClick = true, "anchor-left": anchorLeft, "anchor-right": anchorRight, "anchor-top": anchorTop, "anchor-bottom": anchorBottom } = options; cancelToken?.registerForCancel(this.removing as any); if (anchorLeft) { popover.setAttribute("anchor-left", anchorLeft); } if(anchorRight) { popover.setAttribute("anchor-right", anchorRight); } if (anchorTop) { popover.setAttribute("anchor-top", anchorTop); } if (anchorBottom) { popover.setAttribute("anchor-bottom", anchorBottom); } if (closeOnClick) { popover.setAttribute("data-close-on-click", "1"); } popover.addEventListener("removing", this.removing); popover.addEventListener("removed", this.remove); this.disposables.add(() => { popover.removeEventListener("removed", this.remove); popover.removeEventListener("removing", this.removing); }); this.init ??= (data) => { if (nodeFactory) { this.renderer = nodeFactory(data); } }; const p = this.init?.(dataFactory?.()); if (p?.then) { p.then(() => void 0, console.warn); } this.resultPromise = new Promise((resolve, reject) => { this.resultResolve = resolve; this.resultReject = reject; }); // ignore error this.resultPromise.catch(() => void 0); } abstract init(data?: any); close(r) { this.removed = true; (this.popover as any).result = r ?? null; this.popover.remove(); } async cancel() { this.removed = true; delete (this.popover as any).result; this.popover.remove(); } removing = (ce?: Event) => { if (this.cancel) { ce?.preventDefault(); const c = this.cancel?.(); if (c?.then) { c.then(() => { this.renderer = void 0; this.remove(); }, console.warn); } } }; remove = () => { const { result } = this.popover as any; if (result === void 0) { this.resultReject?.("canceled"); } else { this.resultResolve?.(result); } this.disposables.dispose(); // dispose this.popover.remove(); } } customElements.define("atom-pop-over", AtomPopoverElement);