/** * KTUI - Free & Open-Source Tailwind UI Components by Keenthemes * Copyright 2025 by Keenthemes Inc */ import KTData from '../../helpers/data'; import KTDom from '../../helpers/dom'; import KTEventHandler from '../../helpers/event-handler'; import KTUtils from '../../helpers/utils'; import KTComponent from '../component'; import { KTModalInterface, KTModalConfigInterface } from './types'; declare global { interface Window { KT_MODAL_INITIALIZED: boolean; KTModal: typeof KTModal; } } export class KTModal extends KTComponent implements KTModalInterface { protected override _name: string = 'modal'; protected override _defaultConfig: KTModalConfigInterface = { zindex: '90', backdrop: true, backdropClass: 'kt-modal-backdrop', backdropStatic: false, keyboard: true, disableScroll: true, persistent: false, focus: true, hiddenClass: 'hidden', }; protected override _config: KTModalConfigInterface = this._defaultConfig; protected _isOpen: boolean = false; protected _isTransitioning: boolean = false; protected _backdropElement: HTMLElement | null = null; protected _targetElement: HTMLElement | null = null; constructor(element: HTMLElement, config?: KTModalConfigInterface) { super(); if (KTData.has(element as HTMLElement, this._name)) return; this._init(element); this._buildConfig(config); this._handlers(); } protected _handlers() { this._element.addEventListener('click', (event: Event) => { const target = event.target as HTMLElement; const modalContent = this._element.querySelector('.kt-modal-content'); // Stop propagation for clicks inside dropdowns rendered within modal content. const dropdownElement = target.closest('[data-kt-select-dropdown]'); if (dropdownElement) { event.stopPropagation(); return; } // Dismiss when clicking anywhere outside modal content. if (modalContent && modalContent.contains(target)) { return; } // Only hide if both backdropStatic is false AND persistent is false if ( this._getOption('backdropStatic') === false && KTUtils.stringToBoolean(this._getOption('persistent')) === false ) { this._hide(); } }); } protected _toggle(targetElement?: HTMLElement): void { const payload = { cancel: false }; this._fireEvent('toggle', payload); this._dispatchEvent('toggle', payload); if (payload.cancel === true) { return; } if (this._isOpen === true) { this._hide(); } else { this._show(targetElement); } } protected _show(targetElement?: HTMLElement): void { if (this._isOpen || this._isTransitioning) { return; } if (targetElement) this._targetElement = targetElement; const payload = { cancel: false }; this._fireEvent('show', payload); this._dispatchEvent('show', payload); if (payload.cancel === true) { return; } KTModal.hide(); if (!this._element) return; this._isTransitioning = true; this._element.setAttribute('role', 'dialog'); this._element.setAttribute('aria-modal', 'true'); this._element.setAttribute('tabindex', '-1'); this._setZindex(); if (this._getOption('backdrop') === true) this._createBackdrop(); if (this._getOption('disableScroll')) { document.body.style.overflow = 'hidden'; } this._element.style.display = 'block'; KTDom.reflow(this._element); this._element.classList.add('open'); this._element.classList.remove(this._getOption('hiddenClass') as string); KTDom.transitionEnd(this._element, () => { this._isTransitioning = false; this._isOpen = true; if (this._getOption('focus') === true) { this._autoFocus(); } this._fireEvent('shown'); this._dispatchEvent('shown'); }); } protected _hide(): void { if (!this._element) return; if (this._isOpen === false || this._isTransitioning) { return; } const payload = { cancel: false }; this._fireEvent('hide', payload); this._dispatchEvent('hide', payload); if (payload.cancel === true) { return; } this._isTransitioning = true; this._element.removeAttribute('role'); this._element.removeAttribute('aria-modal'); this._element.removeAttribute('tabindex'); if (this._getOption('disableScroll')) { document.body.style.overflow = ''; } KTDom.reflow(this._element); this._element.classList.remove('open'); if (this._getOption('backdrop') === true) { this._deleteBackdrop(); } KTDom.transitionEnd(this._element, () => { if (!this._element) return; this._isTransitioning = false; this._isOpen = false; this._element.style.display = ''; this._element.classList.add(this._getOption('hiddenClass') as string); this._fireEvent('hidden'); this._dispatchEvent('hidden'); }); } protected _setZindex(): void { let zindex: number = parseInt(this._getOption('zindex') as string); if (parseInt(KTDom.getCssProp(this._element, 'z-index')) > zindex) { zindex = parseInt(KTDom.getCssProp(this._element, 'z-index')); } if (KTDom.getHighestZindex(this._element) > zindex) { zindex = KTDom.getHighestZindex(this._element) + 1; } this._element.style.zIndex = String(zindex); } protected _autoFocus(): void { if (!this._element) return; const input: HTMLInputElement | null = this._element.querySelector( '[data-kt-modal-input-focus]', ); if (!input) return; else input.focus(); } protected _createBackdrop(): void { if (!this._element) return; const zindex: number = parseInt(KTDom.getCssProp(this._element, 'z-index')); this._backdropElement = document.createElement('DIV'); this._backdropElement.setAttribute('data-kt-modal-backdrop', 'true'); this._backdropElement.addEventListener('click', () => { if ( this._getOption('backdropStatic') === false && KTUtils.stringToBoolean(this._getOption('persistent')) === false ) { this._hide(); } }); this._backdropElement.style.zIndex = (zindex - 1).toString(); document.body.append(this._backdropElement); KTDom.reflow(this._backdropElement); KTDom.addClass( this._backdropElement, this._getOption('backdropClass') as string, ); } protected _deleteBackdrop(): void { if (!this._backdropElement) return; KTDom.reflow(this._backdropElement); this._backdropElement.style.opacity = '0'; KTDom.transitionEnd(this._backdropElement, () => { if (!this._backdropElement) return; KTDom.remove(this._backdropElement); }); } public toggle(targetElement?: HTMLElement): void { return this._toggle(targetElement); } public show(targetElement?: HTMLElement): void { return this._show(targetElement); } public hide(): void { return this._hide(); } public getTargetElement(): HTMLElement | null { return this._targetElement; } public isOpen(): boolean { return this._isOpen; } public static getInstance(element: HTMLElement): KTModal { if (!element) return null; if (KTData.has(element, 'modal')) { return KTData.get(element, 'modal') as KTModal; } if (element.getAttribute('data-kt-modal')) { return new KTModal(element); } return null; } public static getOrCreateInstance( element: HTMLElement, config?: KTModalConfigInterface, ): KTModal { return this.getInstance(element) || new KTModal(element, config); } public static hide(): void { const elements = document.querySelectorAll('[data-kt-modal-initialized]'); elements.forEach((element) => { const modal = KTModal.getInstance(element as HTMLElement); if (modal && modal.isOpen()) { modal.hide(); } }); } public static handleToggle(): void { // wire:navigate / morph can replace document.body; stays stable. KTEventHandler.on( document.documentElement, '[data-kt-modal-toggle]', 'click', (event: Event, target: HTMLElement) => { event.stopPropagation(); const selector = target.getAttribute('data-kt-modal-toggle'); if (!selector) return; const modalElement = document.querySelector(selector); const modal = KTModal.getInstance(modalElement as HTMLElement); if (modal) { modal.toggle(target); } }, ); } public static handleDismiss(): void { KTEventHandler.on( document.documentElement, '[data-kt-modal-dismiss]', 'click', (event: Event, target: HTMLElement) => { event.stopPropagation(); const modalElement = target.closest( '[data-kt-modal-initialized]', ) as HTMLElement; if (modalElement) { const modal = KTModal.getInstance(modalElement); if (modal) { modal.hide(); } } }, ); } public static handleClickAway() { document.addEventListener('click', (event: Event) => { const modalElement = document.querySelector( '.open[data-kt-modal-initialized]', ); if (!modalElement) return; const modal = KTModal.getInstance(modalElement as HTMLElement); if (!modal) return; if (KTUtils.stringToBoolean(modal.getOption('persistent')) === true) return; if (KTUtils.stringToBoolean(modal.getOption('backdrop')) === true) return; if ( modalElement !== event.target && modal.getTargetElement() !== event.target && modalElement.contains(event.target as HTMLElement) === false ) { modal.hide(); } }); } public static handleKeyword() { document.addEventListener('keydown', (event: KeyboardEvent) => { const modalElement = document.querySelector( '.open[data-kt-modal-initialized]', ); const modal = KTModal.getInstance(modalElement as HTMLElement); if (!modal) { return; } // if esc key was not pressed in combination with ctrl or alt or shift if ( event.key === 'Escape' && !(event.ctrlKey || event.altKey || event.shiftKey) ) { modal.hide(); } if (event.code === 'Tab' && !event.metaKey) { return; } }); } public static createInstances(): void { const elements = document.querySelectorAll('[data-kt-modal]'); elements.forEach((element) => { new KTModal(element as HTMLElement); }); } public static init(): void { KTModal.createInstances(); if (window.KT_MODAL_INITIALIZED !== true) { KTModal.handleToggle(); KTModal.handleDismiss(); KTModal.handleClickAway(); KTModal.handleKeyword(); window.KT_MODAL_INITIALIZED = true; } } } if (typeof window !== 'undefined') { window.KTModal = KTModal; }