Files
tp-dialog/tp-dialog.js
T

496 lines
15 KiB
JavaScript

/**
@license
Copyright (c) 2022 trading_peter
This program is available under Apache License Version 2.0
*/
import '@tp/tp-icon/tp-icon.js';
import { LitElement, html, css, svg } from 'lit';
import { EventHelpers } from '@tp/helpers/event-helpers.js';
import { closest } from '@tp/helpers/closest.js';
// Global stack to track opened dialogs with closeOnEsc
const dialogStack = [];
let escKeyListener = null;
// Global escape key handler
function handleGlobalEscKey(event) {
if (event.key === 'Escape' && dialogStack.length > 0) {
// Get the most recently opened dialog
const lastDialog = dialogStack[dialogStack.length - 1];
if (lastDialog && lastDialog.closeOnEsc) {
lastDialog.close();
}
}
}
class TpDialog extends EventHelpers(LitElement) {
static get styles() {
return [
css`
:host {
display: flex;
justify-content: center;
align-items: center;
position: fixed;
inset: 0px;
pointer-events: none;
overflow: auto;
z-index: 900;
}
:host([open][modal]) {
pointer-events: all;
}
dialog {
position: relative;
box-sizing: border-box;
border-radius: var(--tp-dialog-border-radius);
background-color: var(--tp-dialog-bg);
color: var(--text);
border: var(--tp-dialog-border);
padding: var(--tp-dialog-padding);
pointer-events: all;
transform: translate(var(--tp-dialog-offset-x, 0px), var(--tp-dialog-offset-y, 0px));
display: flex;
flex-direction: column;
}
dialog:not([open]) {
display: none;
}
/* Only clip/scroll once the dialog has been explicitly sized via resize. */
:host([constrained]) dialog {
overflow: hidden;
}
.scroll-wrapper {
flex: 1;
box-sizing: border-box;
min-height: 0;
overflow: visible;
}
:host([constrained]) .scroll-wrapper {
overflow: auto;
}
.close-icon {
position: absolute;
right: 4px;
top: 5px;
z-index: 3;
--tp-icon-width: 18px;
--tp-icon-height: 18px;
}
.drag-handle {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 30px;
cursor: move;
z-index: 2;
}
.resize-handle {
position: absolute;
bottom: 0;
right: 0;
width: 14px;
height: 14px;
cursor: se-resize;
z-index: 4;
background-image: linear-gradient(135deg, transparent 0%, transparent 50%, var(--tp-dialog-resize-handle-color, #888) 50%, var(--tp-dialog-resize-handle-color, #888) 60%, transparent 60%, transparent 70%, var(--tp-dialog-resize-handle-color, #888) 70%, var(--tp-dialog-resize-handle-color, #888) 80%, transparent 80%);
background-size: 8px 8px;
background-repeat: no-repeat;
background-position: bottom right;
}
`
];
}
render() {
const { showClose, unmovable, resizable } = this;
return html`
<dialog part="dialog">
${!unmovable ? html`
<div class="drag-handle" part="drag-handle" @pointerdown=${this._onDragStart}></div>
` : null}
${showClose ? html`
<div class="close-icon" part="close-icon">
<tp-icon .icon=${this.icon ? this.icon : TpDialog.closeIcon} dialog-dismiss></tp-icon>
</div>
` : null}
<div class="scroll-wrapper" part="scroll-wrapper">
<slot></slot>
</div>
${resizable ? html`
<div class="resize-handle" part="resize-handle" @pointerdown=${this._onResizeStart}></div>
` : null}
</dialog>
`;
}
static get properties() {
return {
open: { type: Boolean, reflect: true },
showClose: { type: Boolean },
icon: { type: Object },
closeOnEsc: { type: Boolean },
closeOnOutsideClick: { type: Boolean },
modal: { type: Boolean, reflect: true },
unmovable: { type: Boolean, reflect: true },
resizable: { type: Boolean, reflect: true },
};
}
static get closeIcon() {
return svg`<path fill="var(--tp-icon-color)" d="M20 6.91L17.09 4L12 9.09L6.91 4L4 6.91L9.09 12L4 17.09L6.91 20L12 14.91L17.09 20L20 17.09L14.91 12L20 6.91Z" />`;
}
get dialog() {
return this.shadowRoot.querySelector('dialog');
}
constructor() {
super();
this._currentPromise = null;
this._resolvePromise = null;
this.modal = false;
this.unmovable = false;
this.resizable = false;
this._offsetX = 0;
this._offsetY = 0;
}
connectedCallback() {
super.connectedCallback();
this.listen(this, 'click', '_onDialogClick');
this.listen(this, 'dialog-close', 'close');
this.listen(this, 'confirmed', '_handleConfirmed');
this.listen(this, 'dismissed', '_handleDismissed');
}
disconnectedCallback() {
super.disconnectedCallback();
this.unlisten(this, 'click', '_onDialogClick');
this.unlisten(this, 'dialog-close', 'close');
this.unlisten(this, 'confirmed', '_handleConfirmed');
this.unlisten(this, 'dismissed', '_handleDismissed');
// Clean up pointer drag/resize listeners
this._cleanupActiveListeners();
// Remove this dialog from the stack
this._removeFromDialogStack();
// Clean up promise if dialog is removed while open
if (this._currentPromise && this._resolvePromise) {
this._resolvePromise('dismissed');
this._currentPromise = null;
this._resolvePromise = null;
}
}
show() {
this.modal = false;
this._resetPosition();
this.dialog.show();
this.open = true;
// Add to dialog stack if closeOnEsc is enabled
this._addToDialogStack();
// Create and return a new promise
this._currentPromise = new Promise((resolve) => {
this._resolvePromise = resolve;
});
return this._currentPromise;
}
showModal() {
this.modal = true;
this._resetPosition();
this.dialog.showModal();
this.open = true;
// Add to dialog stack if closeOnEsc is enabled
this._addToDialogStack();
if (this.closeOnOutsideClick) {
this.addEventListener('click', this._handleOutsideClick, { once: true });
}
// Create and return a new promise
this._currentPromise = new Promise((resolve) => {
this._resolvePromise = resolve;
});
return this._currentPromise;
}
close(e) {
if (e) {
e.stopPropagation();
e.preventDefault();
}
this.dialog.close();
this.dispatchEvent(new CustomEvent('closed', { detail: null, bubbles: true, composed: true }));
this.open = false;
// Remove from dialog stack
this._removeFromDialogStack();
// Clean up pointer drag/resize listeners if they are currently active
this._cleanupActiveListeners();
if (this.closeOnOutsideClick) {
this.removeEventListener('click', this._handleOutsideClick);
}
// If closed without explicit confirm/dismiss (like ESC key), treat as dismissed
if (this._currentPromise && this._resolvePromise) {
this._resolvePromise('dismissed');
this._currentPromise = null;
this._resolvePromise = null;
}
}
_cleanupActiveListeners() {
if (this._boundDragMove) {
window.removeEventListener('pointermove', this._boundDragMove);
this._boundDragMove = null;
}
if (this._boundDragEnd) {
window.removeEventListener('pointerup', this._boundDragEnd);
window.removeEventListener('pointercancel', this._boundDragEnd);
this._boundDragEnd = null;
}
if (this._boundResizeMove) {
window.removeEventListener('pointermove', this._boundResizeMove);
this._boundResizeMove = null;
}
if (this._boundResizeEnd) {
window.removeEventListener('pointerup', this._boundResizeEnd);
window.removeEventListener('pointercancel', this._boundResizeEnd);
this._boundResizeEnd = null;
}
}
_resetPosition() {
this._offsetX = 0;
this._offsetY = 0;
this.style.removeProperty('--tp-dialog-offset-x');
this.style.removeProperty('--tp-dialog-offset-y');
this.removeAttribute('constrained');
const dialogEl = this.dialog;
if (dialogEl) {
dialogEl.style.removeProperty('width');
dialogEl.style.removeProperty('height');
}
}
_onDragStart(e) {
if (this.unmovable) return;
if (e.button !== 0) return; // Only drag with left/main pointer button
this._startX = e.clientX;
this._startY = e.clientY;
this._startOffsetX = this._offsetX || 0;
this._startOffsetY = this._offsetY || 0;
const dragHandle = this.shadowRoot.querySelector('.drag-handle');
if (dragHandle && typeof dragHandle.setPointerCapture === 'function') {
dragHandle.setPointerCapture(e.pointerId);
}
this._boundDragMove = this._onDragMove.bind(this);
this._boundDragEnd = this._onDragEnd.bind(this);
window.addEventListener('pointermove', this._boundDragMove);
window.addEventListener('pointerup', this._boundDragEnd);
window.addEventListener('pointercancel', this._boundDragEnd);
}
_onDragMove(e) {
const dx = e.clientX - this._startX;
const dy = e.clientY - this._startY;
this._offsetX = this._startOffsetX + dx;
this._offsetY = this._startOffsetY + dy;
this.style.setProperty('--tp-dialog-offset-x', `${this._offsetX}px`);
this.style.setProperty('--tp-dialog-offset-y', `${this._offsetY}px`);
}
_onDragEnd(e) {
const dragHandle = this.shadowRoot.querySelector('.drag-handle');
if (dragHandle && typeof dragHandle.releasePointerCapture === 'function') {
try {
dragHandle.releasePointerCapture(e.pointerId);
} catch (err) {}
}
window.removeEventListener('pointermove', this._boundDragMove);
window.removeEventListener('pointerup', this._boundDragEnd);
window.removeEventListener('pointercancel', this._boundDragEnd);
}
_onResizeStart(e) {
if (!this.resizable) return;
if (e.button !== 0) return; // Only resize with left/main pointer button
e.preventDefault();
e.stopPropagation();
// Once the user starts resizing, content must scroll inside the fixed box.
this.setAttribute('constrained', '');
const rect = this.dialog.getBoundingClientRect();
this._startWidth = rect.width;
this._startHeight = rect.height;
this._startX = e.clientX;
this._startY = e.clientY;
this._startOffsetX = this._offsetX || 0;
this._startOffsetY = this._offsetY || 0;
const resizeHandle = this.shadowRoot.querySelector('.resize-handle');
if (resizeHandle && typeof resizeHandle.setPointerCapture === 'function') {
resizeHandle.setPointerCapture(e.pointerId);
}
this._boundResizeMove = this._onResizeMove.bind(this);
this._boundResizeEnd = this._onResizeEnd.bind(this);
window.addEventListener('pointermove', this._boundResizeMove);
window.addEventListener('pointerup', this._boundResizeEnd);
window.addEventListener('pointercancel', this._boundResizeEnd);
}
_onResizeMove(e) {
const dw = e.clientX - this._startX;
const dh = e.clientY - this._startY;
const newWidth = Math.max(150, this._startWidth + dw);
const newHeight = Math.max(100, this._startHeight + dh);
const dw_actual = newWidth - this._startWidth;
const dh_actual = newHeight - this._startHeight;
this._offsetX = this._startOffsetX + dw_actual / 2;
this._offsetY = this._startOffsetY + dh_actual / 2;
const originalTop = (window.innerHeight - newHeight) / 2;
if (originalTop + this._offsetY < 0) {
this._offsetY = -originalTop;
}
this.dialog.style.width = `${newWidth}px`;
this.dialog.style.height = `${newHeight}px`;
this.style.setProperty('--tp-dialog-offset-x', `${this._offsetX}px`);
this.style.setProperty('--tp-dialog-offset-y', `${this._offsetY}px`);
}
_onResizeEnd(e) {
const resizeHandle = this.shadowRoot.querySelector('.resize-handle');
if (resizeHandle && typeof resizeHandle.releasePointerCapture === 'function') {
try {
resizeHandle.releasePointerCapture(e.pointerId);
} catch (err) {}
}
window.removeEventListener('pointermove', this._boundResizeMove);
window.removeEventListener('pointerup', this._boundResizeEnd);
window.removeEventListener('pointercancel', this._boundResizeEnd);
}
_handleConfirmed(event) {
if (this._currentPromise && this._resolvePromise) {
this._resolvePromise('confirmed');
this._currentPromise = null;
this._resolvePromise = null;
}
}
_handleDismissed(event) {
if (this._currentPromise && this._resolvePromise) {
this._resolvePromise('dismissed');
this._currentPromise = null;
this._resolvePromise = null;
}
}
_addToDialogStack() {
if (this.closeOnEsc) {
// Remove if already in stack (shouldn't happen, but just in case)
this._removeFromDialogStack();
// Add to the end of the stack
dialogStack.push(this);
// Set up global listener if this is the first dialog
if (dialogStack.length === 1 && !escKeyListener) {
escKeyListener = handleGlobalEscKey;
document.addEventListener('keydown', escKeyListener);
}
}
}
_removeFromDialogStack() {
const index = dialogStack.indexOf(this);
if (index > -1) {
dialogStack.splice(index, 1);
// Remove global listener if no more dialogs with closeOnEsc
if (dialogStack.length === 0 && escKeyListener) {
document.removeEventListener('keydown', escKeyListener);
escKeyListener = null;
}
}
}
_onDialogClick(event) {
if (this.closeOnOutsideClick) {
const path = event.composedPath();
if (!path.includes(this.dialog)) {
this.close();
event.stopPropagation();
return;
}
}
var rootTarget = event.composedPath()[0];
var target = closest(rootTarget, '[dialog-dismiss]', true) || closest(rootTarget, '[dialog-confirm]', true);
while (target && target !== this) {
if (target.hasAttribute) {
if (target.hasAttribute('dialog-dismiss')) {
var reason = target.getAttribute('dialog-dismiss');
this.dispatchEvent(new CustomEvent('dismissed', { detail: reason.length > 0 ? reason : true, bubbles: true, composed: true }));
this.close();
event.stopPropagation();
break;
} else if (target.hasAttribute('dialog-confirm')) {
var reason = target.getAttribute('dialog-confirm');
this.dispatchEvent(new CustomEvent('confirmed', { detail: reason.length > 0 ? reason : true, bubbles: true, composed: true }));
this.close();
event.stopPropagation();
break;
}
}
target = target.parentNode;
}
}
_handleOutsideClick(event) {
// This method should be implemented if closeOnOutsideClick functionality is needed
// For now, just close the dialog when clicking outside
this.close();
}
}
window.customElements.define('tp-dialog', TpDialog);