import { LitElement, html, css } from 'lit'; import { property, state } from 'lit/decorators.js'; export class Modal extends LitElement { @property({ type: Boolean }) open = false; @property({ type: Boolean }) disableBackdropClick = false; @property({ type: Boolean }) disableEscapeKeyDown = false; @property({ type: Boolean }) hideBackdrop = false; @property({ type: Boolean }) disableAutoFocus = false; @property({ type: Boolean }) disableEnforceFocus = false; @property({ type: Boolean }) disableRestoreFocus = false; @property({ type: Boolean }) keepMounted = false; @property({ type: String }) closeAfterTransition = ''; @property({ type: Number }) zIndex = 1300; @state() private _exited = true; @state() private _hasTransition = false; private _lastFocusedElement: Element | null = null; private _sentinelStart: HTMLElement | null = null; private _sentinelEnd: HTMLElement | null = null; private _modalRef: HTMLElement | null = null; static styles = css` :host { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: var(--modal-z-index, 1300); display: none; } :host([open]) { display: flex; } .modal-root { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: inherit; display: flex; align-items: center; justify-content: center; } .modal-backdrop { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); opacity: 1; transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; -webkit-tap-highlight-color: transparent; } .modal-backdrop.entering { opacity: 0; } .modal-backdrop.entered { opacity: 1; } .modal-backdrop.exiting { opacity: 0; } .modal-backdrop.exited { opacity: 0; } .modal-backdrop.invisible { background-color: transparent; } .modal-content { position: relative; display: flex; flex-direction: column; max-width: calc(100% - 64px); max-height: calc(100% - 64px); outline: 0; opacity: 1; transform: scale(1); transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, transform 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; } .modal-content.entering { opacity: 0; transform: scale(0.75); } .modal-content.entered { opacity: 1; transform: scale(1); } .modal-content.exiting { opacity: 0; transform: scale(0.75); } .modal-content.exited { opacity: 0; transform: scale(0.75); } /* Focus trap sentinels */ .focus-sentinel { width: 0; height: 0; overflow: hidden; position: absolute; top: 0; left: 0; opacity: 0; pointer-events: none; } /* Scrollbar management */ :host([open]) { overflow: hidden; } /* Mobile optimizations */ @media (max-width: 600px) { .modal-content { max-width: calc(100% - 32px); max-height: calc(100% - 32px); } } /* Reduced motion support */ @media (prefers-reduced-motion: reduce) { .modal-backdrop, .modal-content { transition: none; } } `; connectedCallback() { super.connectedCallback(); this._setupZIndex(); } disconnectedCallback() { super.disconnectedCallback(); this._restoreBodyScroll(); this._restoreFocus(); } updated(changedProperties: Map) { super.updated(changedProperties); if (changedProperties.has('open')) { if (this.open) { this._handleOpen(); } else { this._handleClose(); } } if (changedProperties.has('zIndex')) { this._setupZIndex(); } } private _setupZIndex() { this.style.setProperty('--modal-z-index', this.zIndex.toString()); } private _handleOpen() { this._exited = false; // Store the currently focused element if (!this.disableRestoreFocus) { this._lastFocusedElement = document.activeElement; } // Prevent body scroll this._preventBodyScroll(); // Handle focus if (!this.disableAutoFocus) { this._autoFocus(); } // Add event listeners this._addEventListeners(); // Dispatch open event this._dispatchEvent('open'); } private _handleClose() { // Remove event listeners this._removeEventListeners(); // Restore body scroll this._restoreBodyScroll(); // Restore focus if (!this.disableRestoreFocus) { this._restoreFocus(); } // Handle transition if (this._hasTransition && this.closeAfterTransition) { // Wait for transition to complete setTimeout(() => { this._exited = true; this._dispatchEvent('exited'); }, 225); // Default transition duration } else { this._exited = true; this._dispatchEvent('exited'); } // Dispatch close event this._dispatchEvent('close'); } private _preventBodyScroll() { const body = document.body; const scrollY = window.scrollY; body.style.position = 'fixed'; body.style.top = `-${scrollY}px`; body.style.width = '100%'; body.style.overflow = 'hidden'; } private _restoreBodyScroll() { const body = document.body; const scrollY = body.style.top; body.style.position = ''; body.style.top = ''; body.style.width = ''; body.style.overflow = ''; if (scrollY) { window.scrollTo(0, parseInt(scrollY || '0') * -1); } } private _autoFocus() { // Focus the modal content or first focusable element setTimeout(() => { if (this._modalRef) { const focusableElement = this._findFocusableElement(this._modalRef); if (focusableElement) { (focusableElement as HTMLElement).focus(); } else { this._modalRef.focus(); } } }, 0); } private _restoreFocus() { if (this._lastFocusedElement && typeof (this._lastFocusedElement as any).focus === 'function') { setTimeout(() => { (this._lastFocusedElement as HTMLElement).focus(); }, 0); } } private _findFocusableElement(container: Element): Element | null { const focusableSelectors = [ 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', 'a[href]', '[tabindex]:not([tabindex="-1"])', '[contenteditable="true"]' ].join(', '); return container.querySelector(focusableSelectors); } private _addEventListeners() { document.addEventListener('keydown', this._handleKeyDown); if (!this.disableEnforceFocus) { document.addEventListener('focusin', this._handleFocusIn); } } private _removeEventListeners() { document.removeEventListener('keydown', this._handleKeyDown); document.removeEventListener('focusin', this._handleFocusIn); } private _handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape' && !this.disableEscapeKeyDown) { event.preventDefault(); event.stopPropagation(); this._handleBackdropClick(); } // Handle Tab key for focus trapping if (event.key === 'Tab' && !this.disableEnforceFocus) { this._handleTabKey(event); } }; private _handleFocusIn = (event: FocusEvent) => { if (!this.disableEnforceFocus && this._modalRef && !this._modalRef.contains(event.target as Node)) { // Focus escaped the modal, bring it back this._autoFocus(); } }; private _handleTabKey(event: KeyboardEvent) { if (!this._modalRef) return; const focusableElements = this._modalRef.querySelectorAll([ 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', 'a[href]', '[tabindex]:not([tabindex="-1"])', '[contenteditable="true"]' ].join(', ')); const firstElement = focusableElements[0] as HTMLElement; const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement; if (event.shiftKey) { // Shift + Tab if (document.activeElement === firstElement) { event.preventDefault(); lastElement?.focus(); } } else { // Tab if (document.activeElement === lastElement) { event.preventDefault(); firstElement?.focus(); } } } private _handleBackdropClick = (event?: Event) => { if (event) { event.stopPropagation(); } if (!this.disableBackdropClick) { this._dispatchEvent('backdrop-click', { originalEvent: event }); // Close the modal by default this.open = false; } }; private _handleContentClick = (event: Event) => { // Prevent backdrop click when clicking on content event.stopPropagation(); }; private _dispatchEvent(type: string, detail?: any) { const event = new CustomEvent(type, { bubbles: true, composed: true, detail }); this.dispatchEvent(event); } firstUpdated(changedProperties: Map) { super.firstUpdated(changedProperties); // Get references to elements this._modalRef = this.shadowRoot?.querySelector('.modal-content') as HTMLElement; this._sentinelStart = this.shadowRoot?.querySelector('.focus-sentinel-start') as HTMLElement; this._sentinelEnd = this.shadowRoot?.querySelector('.focus-sentinel-end') as HTMLElement; // Make modal content focusable if it doesn't have tabindex if (this._modalRef && !this._modalRef.hasAttribute('tabindex')) { this._modalRef.setAttribute('tabindex', '-1'); } // Check if content has transitions this._hasTransition = this.closeAfterTransition !== ''; } render() { if (!this.open && !this.keepMounted && this._exited) { return html``; } const backdropClasses = [ 'modal-backdrop', this.hideBackdrop ? 'invisible' : '' ].filter(Boolean).join(' '); return html` `; } } // Utility function to create a modal programmatically export function createModal(options: { content?: HTMLElement | string; open?: boolean; disableBackdropClick?: boolean; disableEscapeKeyDown?: boolean; hideBackdrop?: boolean; zIndex?: number; onClose?: () => void; onOpen?: () => void; } = {}): Modal { const modal = new Modal(); // Set properties if (options.open !== undefined) modal.open = options.open; if (options.disableBackdropClick !== undefined) modal.disableBackdropClick = options.disableBackdropClick; if (options.disableEscapeKeyDown !== undefined) modal.disableEscapeKeyDown = options.disableEscapeKeyDown; if (options.hideBackdrop !== undefined) modal.hideBackdrop = options.hideBackdrop; if (options.zIndex !== undefined) modal.zIndex = options.zIndex; // Add content if (options.content) { if (typeof options.content === 'string') { modal.innerHTML = options.content; } else { modal.appendChild(options.content); } } // Add event listeners if (options.onClose) { modal.addEventListener('close', options.onClose); } if (options.onOpen) { modal.addEventListener('open', options.onOpen); } // Append to body document.body.appendChild(modal); return modal; }