/** * KTUI - Free & Open-Source Tailwind UI Components by Keenthemes * Copyright 2025 by Keenthemes Inc */ import { Instance as PopperInstance, createPopper, Placement, VirtualElement, } from '@popperjs/core'; import KTDom from '../../helpers/dom'; import KTData from '../../helpers/data'; import KTEventHandler from '../../helpers/event-handler'; import KTComponent from '../component'; import { KTCallableType } from '../../types'; import { KTDropdownConfigInterface, KTDropdownInterface } from './types'; declare global { interface Window { KT_DROPDOWN_INITIALIZED: boolean; KTDropdown: typeof KTDropdown; } } export class KTDropdown extends KTComponent implements KTDropdownInterface { protected override _name: string = 'dropdown'; protected override _defaultConfig: KTDropdownConfigInterface = { zindex: 105, hoverTimeout: 200, placement: 'bottom-start', placementRtl: 'bottom-end', permanent: false, dismiss: false, keyboard: true, trigger: 'click', attach: '', offset: '0px, 5px', offsetRtl: '0px, 5px', hiddenClass: 'hidden', container: '', }; protected override _config: KTDropdownConfigInterface = this._defaultConfig; protected _disabled: boolean = false; protected _toggleElement!: HTMLElement; protected _menuElement!: HTMLElement; protected _isTransitioning: boolean = false; protected _isOpen: boolean = false; /** Timestamp when _show() was last called; used to ignore duplicate _hide() from double handlers */ protected _shownAt: number = 0; constructor(element: HTMLElement, config?: KTDropdownConfigInterface) { super(); if (KTData.has(element as HTMLElement, this._name)) { return; } this._init(element); if (!this._element) { return; } this._buildConfig(config); const toggle = this._element.querySelector( '[data-kt-dropdown-toggle]', ); if (!toggle) { return; } const menu = this._element.querySelector( '[data-kt-dropdown-menu]', ); if (!menu) { return; } this._toggleElement = toggle; this._menuElement = menu; KTData.set(this._menuElement, 'dropdownElement', this._element); this._setupNestedDropdowns(); this._handleContainer(); } protected _handleContainer(): void { if (this._getOption('container')) { if (this._getOption('container') === 'body') { document.body.appendChild(this._menuElement); } else { document .querySelector(this._getOption('container') as string) ?.appendChild(this._menuElement); } } } protected _setupNestedDropdowns(): void { const subDropdowns = this._menuElement.querySelectorAll( '[data-kt-dropdown-toggle]', ); subDropdowns.forEach((subToggle) => { const subItem = subToggle.closest( '[data-kt-dropdown-item]', ) as HTMLElement; const subMenu = subToggle .closest('.kt-menu-item') ?.querySelector('[data-kt-dropdown-menu]'); if (subItem && subMenu) { new KTDropdown(subItem); } }); } protected _click(event: Event): void { event.preventDefault(); event.stopPropagation(); if (this._disabled) { return; } if (this._getOption('trigger') !== 'click') { return; } this._toggle(); } protected _mouseover(_event: MouseEvent): void { if (this._disabled) return; if (this._getOption('trigger') !== 'hover') return; const root = this._element; if (!root) return; if (KTData.get(root, 'hover') === '1') { clearTimeout(KTData.get(root, 'timeout') as number); KTData.remove(root, 'hover'); KTData.remove(root, 'timeout'); } this._show(); } protected _mouseout(event: MouseEvent): void { if (this._disabled) return; if (this._getOption('trigger') !== 'hover') return; const root = this._element; if (!root) return; const relatedTarget = event.relatedTarget as HTMLElement | null; const isWithinDropdown = relatedTarget !== null && root.contains(relatedTarget); if (isWithinDropdown) return; const timeout = setTimeout( () => { if (KTData.get(root, 'hover') === '1') { this._hide(); } }, parseInt(this._getOption('hoverTimeout') as string), ); KTData.set(root, 'hover', '1'); KTData.set(root, 'timeout', timeout); } protected _toggle(): void { if (this._isOpen) { this._hide(); } else { this._show(); } } protected _show(): void { if (this._isOpen || this._isTransitioning) { return; } const payload = { cancel: false }; this._fireEvent('show', payload); this._dispatchEvent('show', payload); if (payload.cancel) return; const root = this._element; if (!root) return; KTDropdown.hide(root); let zIndex: number = parseInt(this._getOption('zindex') as string); const parentZindex: number = KTDom.getHighestZindex(root); if (parentZindex !== null && parentZindex >= zIndex) { zIndex = parentZindex + 1; } if (zIndex > 0) { this._menuElement.style.zIndex = zIndex.toString(); } this._menuElement.style.display = 'block'; this._menuElement.style.opacity = '0'; KTDom.reflow(this._menuElement); this._menuElement.style.opacity = '1'; this._menuElement.classList.remove( this._getOption('hiddenClass') as string, ); this._toggleElement.classList.add('active'); this._menuElement.classList.add('open'); root.classList.add('open'); this._initPopper(); KTDom.transitionEnd(this._menuElement, () => { this._isTransitioning = false; this._isOpen = true; this._fireEvent('shown'); this._dispatchEvent('shown'); }); this._shownAt = Date.now(); } protected _hide(): void { if (!this._isOpen || this._isTransitioning) return; // If another handler fired _hide() right after _show() (e.g. double initHandlers), ignore if (this._shownAt && Date.now() - this._shownAt < 150) return; const payload = { cancel: false }; this._fireEvent('hide', payload); this._dispatchEvent('hide', payload); if (payload.cancel) return; const root = this._element; if (!root) return; this._menuElement.style.opacity = '1'; KTDom.reflow(this._menuElement); this._menuElement.style.opacity = '0'; this._menuElement.classList.remove('open'); this._toggleElement.classList.remove('active'); root.classList.remove('open'); KTDom.transitionEnd(this._menuElement, () => { this._isTransitioning = false; this._isOpen = false; this._menuElement.classList.add(this._getOption('hiddenClass') as string); this._menuElement.style.display = ''; this._menuElement.style.zIndex = ''; this._destroyPopper(); this._fireEvent('hidden'); this._dispatchEvent('hidden'); }); } protected _initPopper(): void { let reference: HTMLElement; const attach = this._getOption('attach') as string; if (attach) { reference = attach === 'parent' ? (this._toggleElement.parentNode as HTMLElement) : (document.querySelector(attach) as HTMLElement); } else { reference = this._toggleElement; } if (reference) { const popper = createPopper( reference as Element | VirtualElement, this._menuElement, this._getPopperConfig(), ); const root = this._element; if (root) { KTData.set(root, 'popper', popper); } } } protected _destroyPopper(): void { const root = this._element; if (!root) return; if (KTData.has(root, 'popper')) { (KTData.get(root, 'popper') as PopperInstance).destroy(); KTData.remove(root, 'popper'); } } protected _isDropdownOpen(): boolean { const root = this._element; if (!root) return false; return ( root.classList.contains('open') && this._menuElement.classList.contains('open') ); } protected _getPopperConfig(): object { const isRtl = KTDom.isRTL(); let placement = this._getOption('placement') as Placement; if (isRtl && this._getOption('placementRtl')) { placement = this._getOption('placementRtl') as Placement; } let offsetValue = this._getOption('offset'); if (isRtl && this._getOption('offsetRtl')) { offsetValue = this._getOption('offsetRtl') as Placement; } const offset = offsetValue ? offsetValue .toString() .split(',') .map((value) => parseInt(value.trim(), 10)) : [0, 0]; const strategy = this._getOption('overflow') === true ? 'absolute' : 'fixed'; const altAxis = this._getOption('flip') !== false; return { placement: placement, strategy: strategy, modifiers: [ { name: 'offset', options: { offset: offset }, }, { name: 'preventOverflow', options: { altAxis: altAxis }, }, { name: 'flip', options: { flipVariations: false }, }, ], }; } protected _getToggleElement(): HTMLElement { return this._toggleElement; } protected _getContentElement(): HTMLElement { return this._menuElement; } // General Methods public click(event: Event): void { this._click(event); } public mouseover(event: MouseEvent): void { this._mouseover(event); } public mouseout(event: MouseEvent): void { this._mouseout(event); } public show(): void { this._show(); } public hide(): void { this._hide(); } public toggle(): void { this._toggle(); } public getToggleElement(): HTMLElement { return this._toggleElement; } public getContentElement(): HTMLElement { return this._menuElement; } public isPermanent(): boolean { return this._getOption('permanent') as boolean; } public disable(): void { this._disabled = true; } public enable(): void { this._disabled = false; } public isOpen(): boolean { return this._isDropdownOpen(); } // Static Methods public static getElement(reference: HTMLElement): HTMLElement | null { if (reference && reference.hasAttribute('data-kt-dropdown-initialized')) return reference; const findElement = reference && (reference.closest('[data-kt-dropdown-initialized]') as HTMLElement); if (findElement) return findElement; // Fallback: look for parent with data-kt-dropdown attribute if (reference) { const dropdownContainer = reference.closest( '[data-kt-dropdown]', ) as HTMLElement; if (dropdownContainer) return dropdownContainer; } if ( reference && reference.hasAttribute('data-kt-dropdown-menu') && KTData.has(reference, 'dropdownElement') ) { return KTData.get(reference, 'dropdownElement') as HTMLElement; } return null; } public static getInstance(element: HTMLElement): KTDropdown | null { const resolved = this.getElement(element); if (!resolved) { return null; } element = resolved; if (KTData.has(element, 'dropdown')) { const instance = KTData.get(element, 'dropdown') as KTDropdown; return instance; } if (element.getAttribute('data-kt-dropdown-initialized') === 'true') { return new KTDropdown(element); } return null; } public static getOrCreateInstance( element: HTMLElement, config?: KTDropdownConfigInterface, ): KTDropdown { return this.getInstance(element) || new KTDropdown(element, config); } public static update(): void { document .querySelectorAll('.open[data-kt-dropdown-initialized]') .forEach((item) => { if (KTData.has(item as HTMLElement, 'popper')) { ( KTData.get(item as HTMLElement, 'popper') as PopperInstance ).forceUpdate(); } }); } public static hide(skipElement?: HTMLElement): void { document .querySelectorAll( '.open[data-kt-dropdown-initialized]:not([data-kt-dropdown-permanent="true"])', ) .forEach((item) => { if (skipElement && (skipElement === item || item.contains(skipElement))) return; const dropdown = KTDropdown.getInstance(item as HTMLElement); if (dropdown) dropdown.hide(); }); } public static handleClickAway(): void { document.addEventListener('click', (event: Event) => { document .querySelectorAll( '.open[data-kt-dropdown-initialized]:not([data-kt-dropdown-permanent="true"])', ) .forEach((element) => { const dropdown = KTDropdown.getInstance(element as HTMLElement); if (!dropdown) return; const contentElement = dropdown.getContentElement(); const toggleElement = dropdown.getToggleElement(); if ( toggleElement === event.target || toggleElement.contains(event.target as HTMLElement) || contentElement === event.target || contentElement.contains(event.target as HTMLElement) ) { return; } dropdown.hide(); }); }); } public static handleKeyboard(): void { document.addEventListener('keydown', (event: KeyboardEvent) => { const dropdownEl = document.querySelector( '.open[data-kt-dropdown-initialized]', ) as HTMLElement | null; if (!dropdownEl) return; const dropdown = KTDropdown.getInstance(dropdownEl); if (!dropdown || !dropdown._getOption('keyboard')) return; if ( event.key === 'Escape' && !(event.ctrlKey || event.altKey || event.shiftKey) ) { dropdown.hide(); } }); } public static handleMouseover(): void { KTEventHandler.on( document.body, '[data-kt-dropdown-toggle], [data-kt-dropdown-menu]', 'mouseover', ((event?: Event, target?: HTMLElement) => { if (!event || !target) return; const dropdown = KTDropdown.getInstance(target); if (dropdown && dropdown._getOption('trigger') === 'hover') { dropdown.mouseover(event as MouseEvent); } }) as KTCallableType, ); } public static handleMouseout(): void { KTEventHandler.on( document.body, '[data-kt-dropdown-toggle], [data-kt-dropdown-menu]', 'mouseout', ((event?: Event, target?: HTMLElement) => { if (!event || !target) return; const dropdown = KTDropdown.getInstance(target); if (dropdown && dropdown._getOption('trigger') === 'hover') { dropdown.mouseout(event as MouseEvent); } }) as KTCallableType, ); } public static handleClick(): void { KTEventHandler.on(document.body, '[data-kt-dropdown-toggle]', 'click', (( event?: Event, target?: HTMLElement, ) => { if (!event || !target) return; const dropdown = KTDropdown.getInstance(target); if (dropdown) { dropdown.click(event); } }) as KTCallableType); } public static handleDismiss(): void { KTEventHandler.on(document.body, '[data-kt-dropdown-dismiss]', 'click', (( event?: Event, target?: HTMLElement, ) => { if (!event || !target) return; const dropdown = KTDropdown.getInstance(target); if (dropdown) { dropdown.hide(); } }) as KTCallableType); } public static initHandlers(): void { this.handleClickAway(); this.handleKeyboard(); this.handleMouseover(); this.handleMouseout(); this.handleClick(); this.handleDismiss(); } public static createInstances(): void { const elements = document.querySelectorAll('[data-kt-dropdown]'); elements.forEach((element) => { new KTDropdown(element as HTMLElement); }); } public static init(): void { KTDropdown.createInstances(); if (window.KT_DROPDOWN_INITIALIZED !== true) { KTDropdown.initHandlers(); window.KT_DROPDOWN_INITIALIZED = true; } } /** * Force reinitialization of dropdowns by clearing KTData entries. * Useful for Livewire wire:navigate where persisted elements need reinitialization. */ public static reinit(): void { const elements = document.querySelectorAll('[data-kt-dropdown]'); elements.forEach((element) => { try { // Get existing instance to clean up Popper const instance = KTDropdown.getInstance(element as HTMLElement); if (instance && typeof instance.hide === 'function') { instance.hide(); // This will destroy Popper } // Clear KTData entries KTData.remove(element as HTMLElement, 'dropdown'); KTData.remove(element as HTMLElement, 'popper'); // Remove initialization attribute to allow fresh initialization element.removeAttribute('data-kt-dropdown-initialized'); const menu = element.querySelector('[data-kt-dropdown-menu]'); if (menu) { KTData.remove(menu as HTMLElement, 'dropdownElement'); } } catch { // Ignore errors for individual elements } }); // Now create fresh instances KTDropdown.createInstances(); // Always ensure handlers are set up (similar to KTMenu.init() behavior) // Event handlers use delegation so they persist, but we ensure they're attached KTDropdown.initHandlers(); } } if (typeof window !== 'undefined') { window.KTDropdown = KTDropdown; }