/** @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` ${!unmovable ? html`
` : null} ${showClose ? html`
` : null}
${resizable ? html`
` : null}
`; } 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``; } 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);