import { App } from "../../App"; import { AtomDisposableList } from "../../core/AtomDisposableList"; import { getOwnInheritedProperty } from "../../core/InheritedProperty"; import { CancelToken, IClassOf, IDisposable, IRect } from "../../core/types"; import XNode, { constructorNeedsArgumentsSymbol } from "../../core/XNode"; import styled from "../../style/styled"; import { AtomControl } from "../controls/AtomControl"; import PopupWindowA, { ConfirmPopup } from "./PopupWindow"; export const PopupWindow = PopupWindowA; // let lastTarget = null; document.body.addEventListener("click", (e) => { if ((e.target as HTMLElement).offsetParent) { PopupService.lastTarget = e.target as HTMLElement; } }); styled.css ` [data-force-contain=none] { contain: none !important; } *[data-inline-popup=left] { position: relative; height: 0px; width: 0px; left: 0px; & > * { position: absolute; left: 0px; top: 0px; padding: 5px; max-height: 300px; overflow: auto; border-radius: 5px; background-color: #ffffff; z-index: 200; box-shadow: rgba(50, 50, 105, 0.07) 0px 2px 5px 0px, rgba(0, 0, 0, 0.03) 0px 1px 1px 0px;; border: solid 1px rgba(0, 0, 0, 0.05); } } *[data-inline-popup=right] { position: absolute; height: 0px; width: 0px; right: 0px; & > * { position: absolute; right: 0px; top: 0px; padding: 5px; max-height: 300px; overflow: auto; border-radius: 5px; background-color: #ffffff; z-index: 200; box-shadow: rgba(50, 50, 105, 0.07) 0px 2px 5px 0px, rgba(0, 0, 0, 0.03) 0px 1px 1px 0px;; border: solid 1px rgba(0, 0, 0, 0.05); } } *[data-inline-popup=inline-left] { position: relative; height: 0px; width: 0px; left: 0px; & > * { position: absolute; left: 0px; top: 0px; padding: 5px; max-height: 300px; overflow: auto; border-radius: 5px; background-color: #ffffff; z-index: 200; box-shadow: rgba(50, 50, 105, 0.07) 0px 2px 5px 0px, rgba(0, 0, 0, 0.03) 0px 1px 1px 0px;; border: solid 1px rgba(0, 0, 0, 0.05); } } *[data-center-popup] { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 10000; padding: 5px; background-color: #ffffff; border: solid 1px lightgray; border-radius: 5px; box-shadow: rgba(50, 50, 105, 0.15) 0px 2px 5px 0px, rgba(0, 0, 0, 0.05) 0px 1px 1px 0px;; display: inline-block; } `.withId("popup-service-styles").installGlobal(); const popupCss = styled.css ` padding: 5px; background-color: #ffffff; border: solid 1px lightgray; border-radius: 5px; box-shadow: rgba(50, 50, 105, 0.15) 0px 2px 5px 0px, rgba(0, 0, 0, 0.05) 0px 1px 1px 0px;; display: inline-block; `.installLocal(); export interface IPopupOptions { /** * Popup alignment, default is auto starting with right and below */ alignment?: "bottomLeft" | "bottomRight" | "topRight" | "right" | "auto" | "above" | "below" | "centerOfScreen"; popupStyle?: string; cancelToken?: CancelToken; /** * Default is "close" for popup control to avoid cancel exceptions. */ onClick?: "close" | "cancel" | null | undefined; /** * Used by PopupControl to overwrite parent Element */ parentElement?: HTMLElement; } export interface IPopup { element: HTMLElement; disposables: AtomDisposableList; dispose(); registerDisposable(f: any); } export interface IDialogOptions { title?: string; parameters?: {[key: string]: any}; cancelToken?: CancelToken; modal?: boolean; width?: number | string; height?: number | string; maxWidth?: number | string; maxHeight?: number | string; minWidth?: number | string; minHeight?: number | string; maximize?: boolean; } export class PopupControl extends AtomControl { public static showControl( opener: HTMLElement | AtomControl, { onClick = "close", ... options }: IPopupOptions = {}): Promise { let openerElement: HTMLElement = options?.parentElement; let app: App; if (opener instanceof AtomControl) { openerElement ??= opener.element; app = opener.app; } else { openerElement ??= opener; let start = opener; while (!start.atomControl) { start = start.parentElement; } if (!start) { return Promise.reject("Could not create popup as target is not attached"); } app = start.atomControl.app; } const popup = new this(app); if (onClick === "close") { popup.bindEvent(popup.element, "click", () => setTimeout(() => popup.close(), 10)); } else if (onClick === "cancel") { popup.bindEvent(popup.element, "click", () => setTimeout(() => popup.cancel(), 10)); } const p = PopupService.show(openerElement, popup.element, options); // since popup will be children of openerElement // on dispose(popupElement), popup will be disposed automatically // p.registerDisposable(popup); return new Promise(((resolve, reject) => { let resolved = false; popup.close = (r) => { if (resolved) { return; } resolved = true; PopupService.lastTarget = openerElement; resolve(r); p.dispose(); }; popup.cancel = (e) => { if (resolved) { return; } resolved = true; PopupService.lastTarget = openerElement; reject(e); p.dispose(); }; })); } public close: (r?) => void; public cancel: (r?) => void; } function findHostAndPosition(opener: HTMLElement) { let root = opener; const body = document.body; let rect = opener.getBoundingClientRect(); const offset = { x: rect.left, y: rect.top, handler: null, root }; do { root = root.parentElement; if (root === body) { break; } if (root.parentElement.classList.contains("page-host")) { break; } if (root.classList.contains("popup-host")) { break; } if (root.getAttribute("data-pop-up-host") === "yes") { break; } } while (true); rect = root.getBoundingClientRect(); offset.x -= rect.x; offset.y -= rect.y; offset.root = root; return offset; } function findHost(opener: HTMLElement, offset?: {x: number, y: number}): HTMLElement { // let us find scrollable target offsetParent // find host... let host = opener.offsetParent as HTMLElement; while (host) { const current = host; if (host === document.body) { // we have reached top... return host; } if (host.classList.contains("popup-host")) { // let us use this.. return host; } if (host.dataset.popUpHost === "yes") { return host; } const parent = (host.offsetParent ?? host.parentElement) as HTMLElement; host = parent; if (host.classList.contains("page-host")) { // we have reached popup host... host = current; return host; } if (!host) { continue; } if (!offset) { continue; } offset.x += host.offsetLeft; offset.y += host.offsetTop - (parent?.scrollTop ?? 0); } return host; } export const disableContain = (ce: HTMLElement) => { const containNoneList: HTMLElement[] = []; while (ce) { const style = window.getComputedStyle(ce); const isNotNone = style.contain !== "none"; if (isNotNone) { ce.setAttribute("data-force-contain", "none"); containNoneList.push(ce); } ce = ce.parentElement; } return () => { for (const iterator of containNoneList) { iterator.removeAttribute("data-force-contain"); } }; }; function closeHandler( host: HTMLElement, opener: HTMLElement, container, close) { let handler: any = null; handler = (e: Event) => { // dispatch event once again... if(container?.element) { const ce = new CustomEvent("outsideClick", { detail: e, cancelable: true, bubbles: true}); (container.element as HTMLElement).dispatchEvent(ce); if (ce.defaultPrevented) { return; } } let start = e.target as HTMLElement; if (e.defaultPrevented) { return; } while (start) { if (start === host) { break; } if (start === opener) { return; } if (start === container.element) { return; } start = start.parentElement; } close(); e.preventDefault(); e.stopImmediatePropagation?.(); }; document.body.addEventListener("click", handler, true); container.registerDisposable(() => document.body.removeEventListener("click", handler, true)); const onBack = (e: Event) => { e.preventDefault(); e.stopImmediatePropagation?.(); e.stopPropagation(); close(); }; window.addEventListener("backButton", onBack, true); container.registerDisposable(() => window.removeEventListener("backButton", onBack, true)); setTimeout(() => { container.registerDisposable(disableContain(container.element)); }, 10); } let popupId = 1001; let lastTarget = { element: null, x: 10, y: 10 }; export interface IPopupAlertOptions { message: string | XNode; title?: string; detail?: string | XNode; yesLabel?: string; noLabel?: string; cancelLabel?: string; } export default class PopupService { public static defaultElementTarget: HTMLElement; public static get lastTarget() { const { element, x = 0, y = 0 } = lastTarget; if (element?.isConnected) { return element; } let e = document.elementFromPoint?.(x, y) as HTMLElement ?? document.body; if (this.defaultElementTarget?.isConnected && (e === document.documentElement || e === document.body)) { e = this.defaultElementTarget; } PopupService.lastTarget = e; return e; } public static set lastTarget(element: HTMLElement) { if (!element.isConnected) { return; } if (element === document.documentElement) { return; } if (!this.defaultElementTarget && element !== document.body && element !== document.documentElement) { this.defaultElementTarget = element; } const rect = element.getBoundingClientRect(); lastTarget = { element, x: rect.left + (rect.width / 2), y: rect.top + (rect.height / 2) }; } public static async alert({ message, detail, title = "Alert", yesLabel = "Ok" }: IPopupAlertOptions): Promise { try { const isMsgXNode = message instanceof XNode; const isDetailXNode = detail && detail instanceof XNode; if (isMsgXNode) { (message.attributes ??= {})["data-element"] = "message"; } if (isDetailXNode) { (detail.attributes ??= {})["data-element"] = "details"; } const popup = class extends ConfirmPopup { protected create(): void { this.render(
{ isMsgXNode ? message :
} { detail && (isDetailXNode ? detail :
                        
)}
); } }; return await popup.showModal({ parameters: { message, detail, yesLabel, noLabel: "" }, title }); } catch (e) { if (CancelToken.isCancelled(e)) { return false; } throw e; } } public static async confirm({ message, title = "Confirm", yesLabel = "Yes", noLabel = "No", cancelLabel = null }: IPopupAlertOptions): Promise { try { const popup = class extends ConfirmPopup { protected create(): void { this.render(
{ message instanceof XNode ? message :
}
); } }; return await popup.showModal({ parameters: { message, yesLabel, noLabel, cancelLabel }, title }); } catch (e) { if (CancelToken.isCancelled(e)) { return false; } throw e; } } public static showWindow( opener: HTMLElement, popupClass: typeof PopupWindow, popupOptions?: IDialogOptions ): Promise { return new Promise((resolve, reject) => { const activeElement = document.activeElement as any; const previousTarget = opener; const parent = AtomControl.from(opener); const control = new (popupClass)(parent.app, document.createElement("div")); const vm = getOwnInheritedProperty(control, "viewModel") ?? ("parameters" in control ? (control as any).parameters ??= {} : control); let element = control.element; element.style.zIndex = `${popupId++}`; let resolved = false; const close = (r?) => { // this is to allow binding events // to refresh the data setTimeout(() => { if (!resolved) { resolved = true; setTimeout(resolve, 1, r); // if control's element is null // control has been disposed and no need to dispose it if (control.element) { control.element.remove(); control.dispose(); } element?.remove(); element = undefined; PopupService.lastTarget = previousTarget; try { activeElement?.focus(); } catch (error) { // tslint:disable-next-line: no-console console.error(error); } } }, 1); }; const cancel = (r?) => { // this is to allow binding events // to refresh the data setTimeout(() => { if (!resolved) { resolved = true; setTimeout(reject , 1 , r ?? "cancelled"); // if control's element is null // control has been disposed and no need to dispose it if (control.element) { control.element.remove(); control.dispose(); } element?.remove(); element = undefined; PopupService.lastTarget = previousTarget; try { activeElement?.focus(); } catch (error) { // tslint:disable-next-line: no-console console.error(error); } } }, 1); }; let isModal = false; if (popupOptions) { const { width, height, minHeight, maxHeight, minWidth, maxWidth, maximize, title, parameters, cancelToken, modal } = popupOptions; if (title) { vm.title = title; } let widthSet = false; let heightSet = false; if (maximize) { element.setAttribute("data-maximize","true"); widthSet = heightSet = true; } else { if (width) { element.style.width = typeof width === "number" ? width + "px" : width; widthSet = true; } if (height) { element.style.height = typeof height === "number" ? height + "px" : height; heightSet = true; } if (minWidth) { element.style.minWidth = typeof minWidth === "number" ? minWidth + "px" : minWidth; widthSet = true; } if (minHeight) { element.style.minHeight = typeof minHeight === "number" ? minHeight + "px" : minHeight; heightSet = true; } if (maxWidth) { element.style.maxWidth = typeof maxWidth === "number" ? maxWidth + "px" : maxWidth; widthSet = true; } if (maxHeight) { element.style.maxHeight = typeof maxHeight === "number" ? maxHeight + "px" : maxHeight; heightSet = true; } } if (!widthSet) { element.setAttribute("data-no-width", "true"); } if (!heightSet) { element.setAttribute("data-no-height", "true"); } if (parameters) { for (const key in parameters) { if (Object.prototype.hasOwnProperty.call(parameters, key)) { const e = parameters[key]; vm[key] = e; } } (control as any).init?.() ?.catch((error) => { if (!CancelToken.isCancelled(error)) { console.error(error); } }); } cancelToken?.registerForCancel(cancel); isModal = modal; } const host = findHost(opener); host.appendChild(control.element); vm.cancel = cancel; vm.close = close; if (vm !== control) { control.cancel = cancel; control.close = close; } if (isModal) { const bg = document.createElement("div"); bg.setAttribute("data-window-modal-background","background"); host.appendChild(bg); const onBack = (e: CustomEvent) => { e.preventDefault(); e.stopImmediatePropagation?.(); e.stopPropagation(); (control as any).requestCancel?.(); }; window.addEventListener("backButton", onBack, true); control.registerDisposable({ dispose: () => { bg.remove(); window.removeEventListener("backButton", onBack, true); } }); } else { closeHandler(host, opener, control, cancel); } }); } /** * Display given popup attached to given opener and returns * disposable that can be used to dispose the popup * @param opener Element which opens this popup * @param popup Popup Element, it must be rendered within the opener's parent * @param options IPopupOptions * @returns IDisposable */ public static show( opener: HTMLElement, popup: HTMLElement | XNode, options?: IPopupOptions): IPopup { const previousTarget = opener; const container: IPopup = { element: document.createElement("div"), disposables: new AtomDisposableList(), registerDisposable: null, dispose: null, }; container.registerDisposable = (f) => container.disposables.add(f); let alignment = options?.alignment ?? "auto"; if (alignment === "auto") { const rect = opener.getBoundingClientRect(); const w = window.visualViewport.width; if (rect.left > w / 2) { alignment = "bottomRight"; } } const isCenterOfScreen = alignment === "centerOfScreen"; const popupStyle = options?.popupStyle ?? popupCss; container.element._logicalParent = opener; container.element.classList.add(popupStyle); if (isCenterOfScreen) { container.element.dataset.centerPopup = "center"; } else { container.element.dataset.inlinePopup = "left"; const alignPopup = () => { switch(alignment) { case "bottomRight": container.element.style.top = (opener.offsetTop + opener.offsetHeight) + "px"; container.element.style.right = "0px"; container.element.dataset.inlinePopup = "right"; opener.insertAdjacentElement("afterend", container.element); break; case "topRight": case "right": container.element.style.top = (opener.offsetTop) + "px"; container.element.style.left = (opener.offsetWidth) + "px"; opener.insertAdjacentElement("afterend", container.element); break; case "bottomLeft": container.element.dataset.inlinePopup = "inline-left"; container.element.style.top = (opener.offsetTop + opener.offsetHeight) + "px"; opener.insertAdjacentElement("beforebegin", container.element); break; default: container.element.style.top = (opener.offsetTop + opener.offsetHeight) + "px"; opener.insertAdjacentElement("afterend", container.element); break; } }; if (opener.offsetParent !== opener.parentElement) { opener.parentElement.style.position = "relative"; setTimeout(alignPopup, 5); } else { alignPopup(); } } const parent = AtomControl.from(opener); if (popup instanceof XNode) { // @ts-ignore parent.render(popup, container); } else { container.element.appendChild(popup); } const style = container.element.style; // const offset = findHostAndPosition(opener); // const host = offset.root; // const hostHeight = host.offsetHeight // || host.clientHeight // || (host.firstElementChild as HTMLElement).offsetHeight; // style.position = "absolute"; // offset.y += opener.offsetHeight; // if (options?.alignment === "centerOfScreen") { // style.left = "50%"; // style.top = "50%"; // style.transform = "translate(-50%,-50%)"; // } else { // if (!options || options?.alignment === "auto") { // // check where is more space?? // if (offset.x < (host.offsetWidth / 2)) { // style.left = offset.x + "px"; // } else { // style.right = `${(host.offsetWidth - (offset.x + opener.offsetWidth))}px`; // } // if (offset.y < (hostHeight / 2)) { // style.top = offset.y + "px"; // } else { // style.top = `${offset.y - opener.offsetHeight}px`; // style.transform = "translate(0, -100%)"; // } // } else { // offset.y -= opener.offsetHeight; // style.top = offset.y + "px"; // if (options?.alignment === "right") { // style.left = `${(opener.offsetLeft + opener.offsetWidth)}px`; // } else { // style.left = offset.x + "px"; // } // } // } style.zIndex = `${popupId++}`; // host.appendChild(container.element); container.dispose = () => { if (!container.disposables) { return; } container.disposables.dispose(); parent.dispose(container.element); container.element.remove(); container.disposables = null; PopupService.lastTarget = previousTarget; }; closeHandler(opener.parentElement, opener, container, () => { const e = container.element; container.dispose(); e.remove(); }); const ct = options?.cancelToken; if (ct) { ct.registerForCancel(() => container.dispose()); } return container; } }