ui/Dialog.tstypescript
import { getDefault, stringTemplate } from "../utils/shared";
import { Button } from "./Button";
import { CLOSE_ICON } from "./icons";
import { View } from "./View";
import type { IViewParams, ViewEventsList } from "./View";
import type { EventsHandler } from "../Events";
import type { DockSide } from "./Dock";
export interface IDialogParams extends IViewParams {
title?: string;
visible?: boolean;
resizable?: boolean;
width?: number;
height?: number;
left?: number;
right?: number;
top?: number;
minHeight?: number;
maxHeight?: number;
minWidth?: number;
maxWidth?: number;
useHide?: boolean;
dock?: DockSide;
hideToolbar?: boolean;
}
export type DialogEventsList = ["resize", "focus", "visibility", "dragstart", "dragend"];
/** Carries the dialog back from its element, the way a Button does. */
export interface DialogElement extends HTMLElement {
__og_dialog__?: Dialog<any>;
}
/** Preferred side of the anchor element a dialog opens on. */
export type DialogPlacement = "right" | "below";
const DEFAULT_WIDTH = 300;
const DEFAULT_HEIGHT = 200;
const DIALOG_EVENTS: DialogEventsList = ["resize", "focus", "visibility", "dragstart", "dragend"];
/** Pixels the pointer has to move past its header press before that counts as a drag */
const DRAG_THRESHOLD = 4;
const TEMPLATE = `<div class="og-ddialog"
style="display:{display}; resize:{resize}; width: {width}px; {height}; top: {top}px; left: {left}px; min-height: {minHeight}; max-height: {maxHeight}; min-width: {minWidth}; max-width: {maxWidth};">
<div class="og-ddialog-header">
<div class="og-ddialog-header__title">{title}</div>
<div class="og-ddialog-header__buttons"></div>
</div>
<div class="og-ddialog-container"></div>
<div class="og-ddialog-resize-handle" style="display:{resizeHandleDisplay};"></div>
</div>`;
/**
* Floating dialog window with a draggable header, a resizable body and a close button.
* Dragged by its header to an edge of its container, the dialog docks to that side when a
* {@link DockManager} watches the container, and the `dock` option opens it docked at once.
* @class
* @extends {View}
* @param {IDialogParams} [options] - Options:
* @param {string} [options.title=""] - Header title.
* @param {boolean} [options.visible=true] - Initial visibility.
* @param {boolean} [options.resizable=true] - Resize handle flag.
* @param {number} [options.width=300] - Dialog width in pixels.
* @param {number} [options.height=200] - Dialog height in pixels. Fits the content when it is not set.
* @param {number} [options.left=0] - Offset from the left edge of the container in pixels.
* @param {number} [options.right] - Offset from the right edge of the container in pixels, applied instead of the left one.
* @param {number} [options.top=0] - Offset from the top edge of the container in pixels.
* @param {number} [options.minHeight] - Minimal height in pixels.
* @param {number} [options.maxHeight] - Maximal height in pixels.
* @param {number} [options.minWidth] - Minimal width in pixels.
* @param {number} [options.maxWidth] - Maximal width in pixels.
* @param {boolean} [options.useHide=false] - Hides the dialog on close instead of removing it.
* @param {DockSide} [options.dock] - Side to open docked to the first time the dialog is shown, if a dock manager is there.
* @param {boolean} [options.hideToolbar=false] - Hides the bar the dialog is dragged by. Without it the dialog cannot be moved.
*/
class Dialog<M> extends View<M> {
static __zIndex__: number = 100;
public override events: EventsHandler<DialogEventsList> & EventsHandler<ViewEventsList>;
public $header: HTMLElement | null;
public $title: HTMLElement | null;
public $container: HTMLElement | null;
public $buttons: HTMLElement | null;
public $resizeHandle: HTMLElement | null;
public useHide: boolean;
protected _startPosX: number;
protected _startPosY: number;
protected _startWidth: number;
protected _startHeight: number;
protected _closeBtn: Button;
protected _visibility: boolean;
protected _right: number | null;
protected _width: number;
protected _height: number;
protected _firstOpenPositioned: boolean;
protected _resizable: boolean;
protected _isResizing: boolean;
protected _touchDragPointerId: number | null;
protected _touchResizePointerId: number | null;
protected _titleText: string;
protected _dock: DockSide | null;
protected _hideToolbar: boolean;
constructor(options: IDialogParams = {}) {
const title = options.title || "";
super({
template: stringTemplate(TEMPLATE, {
title,
display: getDefault(options.visible, true) ? "flex" : "none",
resize: getDefault(options.resizable, true) ? "both" : "none",
width: options.width || DEFAULT_WIDTH,
height: options.height ? `height: ${options.height || DEFAULT_HEIGHT}px` : "",
left: options.left || 0,
top: options.top || 0,
minHeight: options.minHeight ? `${options.minHeight}px` : "unset",
maxHeight: options.maxHeight ? `${options.maxHeight}px` : "unset",
minWidth: options.minWidth ? `${options.minWidth}px` : "unset",
maxWidth: options.maxWidth ? `${options.maxWidth}px` : "unset",
resizeHandleDisplay: getDefault(options.resizable, true) ? "block" : "none"
}),
...options
});
//@ts-ignore
this.events = this.events.registerNames(DIALOG_EVENTS);
this._width = options.width || DEFAULT_WIDTH;
this._height = options.height || DEFAULT_HEIGHT;
this._startPosX = 0;
this._startPosY = 0;
this._startWidth = 0;
this._startHeight = 0;
this.$header = null;
this.$title = null;
this.$container = null;
this.$buttons = null;
this.$resizeHandle = null;
this._closeBtn = new Button({
icon: CLOSE_ICON,
classList: ["og-button-size__20"]
});
this.useHide = options.useHide || false;
this._visibility = getDefault(options.visible, true);
this._right = options.right != undefined ? options.right : null;
this._firstOpenPositioned = false;
this._resizable = getDefault(options.resizable, true);
this._isResizing = false;
this._touchDragPointerId = null;
this._touchResizePointerId = null;
this._titleText = title;
this._dock = options.dock || null;
this._hideToolbar = getDefault(options.hideToolbar, false);
}
/** Side this dialog asks to open docked to. */
public get dock(): DockSide | null {
return this._dock;
}
public setContainer(htmlStr: string) {
this.$container!.innerHTML = htmlStr;
}
public get container(): HTMLElement | null {
return this.$container;
}
public get width(): number {
return this.el ? parseFloat(this.el.style.width) : this._width;
}
public get height(): number {
return this.el ? parseFloat(this.el.style.height) : this._height;
}
public setTitle(title: string): void {
this._titleText = title;
if (this.$title) {
this.$title.textContent = title;
}
}
public bringToFront() {
this.el!.style.zIndex = String(Dialog.__zIndex__++);
}
public override render(params: any): this {
super.render(params);
(this.el as DialogElement).__og_dialog__ = this;
this.bringToFront();
this.$header = this.select(".og-ddialog-header");
this.$title = this.select(".og-ddialog-header__title");
this.$container = this.select(".og-ddialog-container");
this.$buttons = this.select(".og-ddialog-header__buttons");
this.$resizeHandle = this.select(".og-ddialog-resize-handle");
if (this._hideToolbar) {
this.$header!.style.display = "none";
}
this.setTitle(this._titleText);
this._initEvents();
this._initButtons();
if (this._right != null) {
this.el!.style.visibility = "hidden";
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.el!.style.visibility = "visible";
const right = this._right;
//@ts-ignore
if (this.el!.parentNode && right != null) {
this.setPosition(
(this.el!.parentNode as HTMLElement).clientWidth - this.el!.clientWidth - right
);
}
obs.disconnect();
}
});
});
observer.observe(this.el!);
}
return this;
}
public show() {
if (!this._visibility) {
this._visibility = true;
this.el!.style.display = "flex";
this.bringToFront();
this.events.dispatch(this.events.visibility, true, this);
}
}
public hide() {
if (this._visibility) {
this._visibility = false;
this.el!.style.display = "none";
this.events.dispatch(this.events.visibility, false, this);
}
}
public close() {
if (this.useHide) {
this.hide();
} else {
this.remove();
}
}
public setVisibility(visibility: boolean) {
if (visibility) {
this.show();
} else {
this.hide();
}
}
public getVisibility(): boolean {
return this._visibility;
}
/**
* Places the dialog next to an anchor element, once, on its first open.
* @param anchorEl - Element to place the dialog against, e.g. the control toggle button.
* @param rootEl - Container the dialog is kept inside of, the dialog parent by default.
* @param gap - Distance between the anchor and the dialog in pixels.
* @param placement - "right" opens the dialog right of the anchor, mirrored to its left when the
* anchor sits in the right half of the container; "below" opens it underneath the anchor.
*/
public positionNearElementOnFirstOpen(
anchorEl: HTMLElement | null,
rootEl?: HTMLElement | null,
gap: number = 8,
placement: DialogPlacement = "right"
) {
if (this._firstOpenPositioned || !this.el || !anchorEl) return;
const root = rootEl || this.el.parentElement || document.body;
const rootRect = root.getBoundingClientRect();
const anchorRect = anchorEl.getBoundingClientRect();
const dialogWidth = this.el.offsetWidth || this.width || DEFAULT_WIDTH;
const styleHeight = parseFloat(this.el.style.height || "0");
const dialogHeight = this.el.offsetHeight || (isNaN(styleHeight) ? 0 : styleHeight) || DEFAULT_HEIGHT;
const rootWidth = root.clientWidth || rootRect.width || window.innerWidth;
const rootHeight = root.clientHeight || rootRect.height || window.innerHeight;
const anchorCenterX = anchorRect.left + anchorRect.width * 0.5 - rootRect.left;
const openToRight = anchorCenterX <= rootWidth * 0.5;
let left: number;
let top: number;
if (placement === "below") {
left = anchorRect.left - rootRect.left;
top = anchorRect.bottom - rootRect.top + gap;
} else {
left = openToRight
? anchorRect.right - rootRect.left + gap
: anchorRect.left - rootRect.left - dialogWidth - gap;
top = anchorRect.top - rootRect.top;
}
left = Math.max(0, Math.min(left, rootWidth - dialogWidth));
if (rootHeight > dialogHeight) {
top = Math.max(0, Math.min(top, rootHeight - dialogHeight));
} else {
top = Math.max(0, top);
}
this.setPosition(left, top);
this._firstOpenPositioned = true;
}
protected _initButtons() {
this._closeBtn.events.on("click", this._onCloseBtnClick);
this._closeBtn.appendTo(this.$buttons!);
}
public override afterRender(parentNode: HTMLElement) {
this._initEvents();
}
protected _initEvents() {
if (this.$header) {
this.$header.style.touchAction = "none";
this.$header.addEventListener("mousedown", this._onMouseDown);
this.$header.addEventListener("pointerdown", this._onPointerDown);
}
this.el?.addEventListener("mousedown", this._onMouseDownAll);
this.el?.addEventListener("pointerdown", this._onPointerDownAll);
if (this.$resizeHandle && this._resizable) {
this.$resizeHandle.addEventListener("mousedown", this._onResizeMouseDown);
this.$resizeHandle.addEventListener("pointerdown", this._onResizePointerDown);
}
}
protected _onCloseBtnClick = () => {
this.close();
};
protected _onMouseDownAll = () => {
this.bringToFront();
};
protected _onPointerDownAll = (e: PointerEvent) => {
if (e.pointerType !== "touch") {
return;
}
this.bringToFront();
};
protected _isHeaderButtonsTarget(target: EventTarget | null): boolean {
return target instanceof Element && !!target.closest(".og-ddialog-header__buttons");
}
protected _onMouseDown = (e: MouseEvent) => {
if (this._isHeaderButtonsTarget(e.target)) {
return;
}
e.preventDefault();
this._startPosX = e.clientX;
this._startPosY = e.clientY;
document.addEventListener("mousemove", this._onMouseMove);
document.addEventListener("mouseup", this._onMouseUp);
};
protected _onPointerDown = (e: PointerEvent) => {
if (e.pointerType !== "touch" || this._isHeaderButtonsTarget(e.target) || !e.isPrimary) {
return;
}
e.preventDefault();
this._startPosX = e.clientX;
this._startPosY = e.clientY;
this._touchDragPointerId = e.pointerId;
this.$header?.setPointerCapture(e.pointerId);
document.addEventListener("pointermove", this._onPointerMove);
document.addEventListener("pointerup", this._onPointerUp);
document.addEventListener("pointercancel", this._onPointerUp);
};
public setPosition(x?: number, y?: number) {
if (!this.el) return;
if (x != undefined) {
this._right = null;
this.el.style.left = `${x}px`;
}
if (y != undefined) {
this.el.style.top = `${y}px`;
}
}
public getLeft(): number {
if (!this.el) {
return 0;
}
return parseFloat(this.el.style.left) || 0;
}
public getTop(): number {
if (!this.el) {
return 0;
}
return parseFloat(this.el.style.top) || 0;
}
protected _onMouseMove = (e: MouseEvent) => {
e.preventDefault();
if (!this._beginDragIfPastThreshold(e.clientX, e.clientY)) {
return;
}
let dx = this._startPosX - e.clientX;
let dy = this._startPosY - e.clientY;
this._startPosX = e.clientX;
this._startPosY = e.clientY;
this.setPosition(this.el!.offsetLeft - dx, this.el!.offsetTop - dy);
};
protected _onPointerMove = (e: PointerEvent) => {
if (e.pointerType !== "touch" || this._touchDragPointerId !== e.pointerId) {
return;
}
e.preventDefault();
if (!this._beginDragIfPastThreshold(e.clientX, e.clientY)) {
return;
}
let dx = this._startPosX - e.clientX;
let dy = this._startPosY - e.clientY;
this._startPosX = e.clientX;
this._startPosY = e.clientY;
this.setPosition(this.el!.offsetLeft - dx, this.el!.offsetTop - dy);
};
protected _beginDragIfPastThreshold(clientX: number, clientY: number): boolean {
if (this.el!.classList.contains("dragging")) {
return true;
}
const dx = clientX - this._startPosX;
const dy = clientY - this._startPosY;
if (dx * dx + dy * dy < DRAG_THRESHOLD * DRAG_THRESHOLD) {
return false;
}
this._startDragging();
return true;
}
protected _startDragging() {
if (!this.el!.classList.contains("dragging")) {
this.el!.classList.add("dragging");
this.events.dispatch(this.events.dragstart, this);
}
}
protected _clearDragging() {
if (this.el!.classList.contains("dragging")) {
this.events.dispatch(this.events.dragend, this);
this.el!.classList.remove("dragging");
}
}
protected _onMouseUp = () => {
this._clearDragging();
document.removeEventListener("mouseup", this._onMouseUp);
document.removeEventListener("mousemove", this._onMouseMove);
};
protected _onPointerUp = (e: PointerEvent) => {
if (e.pointerType !== "touch" || this._touchDragPointerId !== e.pointerId) {
return;
}
this._touchDragPointerId = null;
if (this.$header?.hasPointerCapture(e.pointerId)) {
this.$header.releasePointerCapture(e.pointerId);
}
this._clearDragging();
document.removeEventListener("pointermove", this._onPointerMove);
document.removeEventListener("pointerup", this._onPointerUp);
document.removeEventListener("pointercancel", this._onPointerUp);
};
protected _onResizeMouseDown = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
this._startResizing(e.clientX, e.clientY);
document.addEventListener("mousemove", this._onResizeMouseMove);
document.addEventListener("mouseup", this._onResizeMouseUp);
};
protected _onResizePointerDown = (e: PointerEvent) => {
if (e.pointerType !== "touch" || !e.isPrimary) {
return;
}
e.preventDefault();
e.stopPropagation();
this._startResizing(e.clientX, e.clientY);
this._touchResizePointerId = e.pointerId;
this.$resizeHandle?.setPointerCapture(e.pointerId);
document.addEventListener("pointermove", this._onResizePointerMove);
document.addEventListener("pointerup", this._onResizePointerUp);
document.addEventListener("pointercancel", this._onResizePointerUp);
};
protected _startResizing(clientX: number, clientY: number) {
const computedStyle = getComputedStyle(this.el!);
this._isResizing = true;
this._startPosX = clientX;
this._startPosY = clientY;
this._startWidth = parseFloat(computedStyle.width);
this._startHeight = parseFloat(computedStyle.height);
this.bringToFront();
}
protected _resize(clientX: number, clientY: number) {
let width = this._startWidth + (clientX - this._startPosX);
let height = this._startHeight + (clientY - this._startPosY);
const minWidth = parseFloat(this.el!.style.minWidth);
const maxWidth = parseFloat(this.el!.style.maxWidth);
const minHeight = parseFloat(this.el!.style.minHeight);
const maxHeight = parseFloat(this.el!.style.maxHeight);
if (!isNaN(minWidth)) {
width = Math.max(minWidth, width);
}
if (!isNaN(maxWidth)) {
width = Math.min(maxWidth, width);
}
if (!isNaN(minHeight)) {
height = Math.max(minHeight, height);
}
if (!isNaN(maxHeight)) {
height = Math.min(maxHeight, height);
}
this.el!.style.width = `${width}px`;
this.el!.style.height = `${height}px`;
this._width = width;
this._height = height;
this.events.dispatch(this.events.resize, this);
}
protected _onResizeMouseMove = (e: MouseEvent) => {
e.preventDefault();
this._resize(e.clientX, e.clientY);
};
protected _onResizePointerMove = (e: PointerEvent) => {
if (e.pointerType !== "touch" || this._touchResizePointerId !== e.pointerId) {
return;
}
e.preventDefault();
this._resize(e.clientX, e.clientY);
};
protected _clearResizing() {
this._isResizing = false;
}
protected _onResizeMouseUp = () => {
this._clearResizing();
document.removeEventListener("mousemove", this._onResizeMouseMove);
document.removeEventListener("mouseup", this._onResizeMouseUp);
};
protected _onResizePointerUp = (e: PointerEvent) => {
if (e.pointerType !== "touch" || this._touchResizePointerId !== e.pointerId) {
return;
}
this._touchResizePointerId = null;
if (this.$resizeHandle?.hasPointerCapture(e.pointerId)) {
this.$resizeHandle.releasePointerCapture(e.pointerId);
}
this._clearResizing();
document.removeEventListener("pointermove", this._onResizePointerMove);
document.removeEventListener("pointerup", this._onResizePointerUp);
document.removeEventListener("pointercancel", this._onResizePointerUp);
};
public override remove() {
this._clearDragging();
this._clearResizing();
this._clearEvents();
this._firstOpenPositioned = false;
super.remove();
}
protected _clearEvents() {
this._closeBtn.events.off("click", this._onCloseBtnClick);
document.removeEventListener("mouseup", this._onMouseUp);
document.removeEventListener("mousemove", this._onMouseMove);
document.removeEventListener("pointermove", this._onPointerMove);
document.removeEventListener("pointerup", this._onPointerUp);
document.removeEventListener("pointercancel", this._onPointerUp);
document.removeEventListener("mousemove", this._onResizeMouseMove);
document.removeEventListener("mouseup", this._onResizeMouseUp);
document.removeEventListener("pointermove", this._onResizePointerMove);
document.removeEventListener("pointerup", this._onResizePointerUp);
document.removeEventListener("pointercancel", this._onResizePointerUp);
this.$header?.removeEventListener("mousedown", this._onMouseDown);
this.$header?.removeEventListener("pointerdown", this._onPointerDown);
this.el?.removeEventListener("mousedown", this._onMouseDownAll);
this.el?.removeEventListener("pointerdown", this._onPointerDownAll);
if (this.$resizeHandle && this._resizable) {
this.$resizeHandle.removeEventListener("mousedown", this._onResizeMouseDown);
this.$resizeHandle.removeEventListener("pointerdown", this._onResizePointerDown);
}
}
}
export { Dialog };