/** * KTUI - Free & Open-Source Tailwind UI Components by Keenthemes * Copyright 2025 by Keenthemes Inc */ import { Instance as PopperInstance, createPopper, Placement, } from '@popperjs/core'; import KTDom from '../../helpers/dom'; import KTData from '../../helpers/data'; import KTComponent from '../component'; import { KTSelectConfigInterface } from './config'; import { FocusManager, EventManager } from './utils'; import { KTSelect } from './select'; // Added import /** * KTSelectDropdown * * A specialized dropdown implementation for the KTSelect component. * This module handles the dropdown functionality for the select component, * including positioning and showing/hiding. */ export class KTSelectDropdown extends KTComponent { protected override readonly _name: string = 'select-dropdown'; protected override readonly _config: KTSelectConfigInterface; // DOM Elements protected _element: HTMLElement; private _toggleElement: HTMLElement; private _dropdownElement: HTMLElement; // State private _isOpen: boolean = false; private _isTransitioning: boolean = false; private _popperInstance: PopperInstance | null = null; private _eventManager: EventManager; private _focusManager: FocusManager; private _ktSelectInstance: KTSelect; // Added instance variable /** * Constructor * @param element The parent element (select wrapper) * @param toggleElement The element that triggers the dropdown * @param dropdownElement The dropdown content element * @param config The configuration options */ constructor( element: HTMLElement, toggleElement: HTMLElement, dropdownElement: HTMLElement, config: KTSelectConfigInterface, ktSelectInstance: KTSelect, // Added parameter ) { super(); this._element = element; this._toggleElement = toggleElement; this._dropdownElement = dropdownElement; this._config = config; this._ktSelectInstance = ktSelectInstance; // Assign instance // For centered modals, don't move dropdown to container to preserve positioning context // For other cases, move to container if specified const modalParent = this._getModalContainer(); const isCenteredModal = modalParent && modalParent.classList.contains('kt-modal-center'); // Only move dropdown if not in centered modal (regardless of strategy override) // This prevents the positioning bug even if user sets dropdownStrategy: 'fixed' const container = this._resolveDropdownContainer(); if (container && !isCenteredModal) { if (container !== this._dropdownElement.parentElement) { container.appendChild(this._dropdownElement); } } this._eventManager = new EventManager(); this._focusManager = new FocusManager( dropdownElement, '[data-kt-select-option]', config, ); this._setupEventListeners(); } /** * Set up event listeners for the dropdown */ private _setupEventListeners(): void { // Toggle click this._eventManager.addListener( this._toggleElement, 'click', this._handleToggleClick.bind(this), ); // Close on outside click this._eventManager.addListener( document as unknown as HTMLElement, 'click', this._handleOutsideClick.bind(this), ); } /** * Handle toggle element click */ private _handleToggleClick(event: Event): void { event.preventDefault(); event.stopPropagation(); if (this._config.disabled) { return; } // Call KTSelect's methods if (this._ktSelectInstance.isDropdownOpen()) { this._ktSelectInstance.closeDropdown(); } else { this._ktSelectInstance.openDropdown(); } } /** * Handle clicks outside the dropdown */ private _handleOutsideClick(event: Event): void { if (!this._isOpen) return; const target = event.target as HTMLElement; if ( !this._element.contains(target) && !this._dropdownElement.contains(target) ) { // Call KTSelect's closeDropdown method this._ktSelectInstance.closeDropdown(); } } /** * Set width of dropdown based on toggle element */ private _setDropdownWidth(): void { if (!this._dropdownElement || !this._toggleElement) return; // Check if width is configured if (this._config.dropdownWidth) { // If custom width is set, use that this._dropdownElement.style.width = this._config.dropdownWidth; } else { // Otherwise, match toggle element width for a cleaner appearance const toggleWidth = this._toggleElement.offsetWidth; this._dropdownElement.style.width = `${toggleWidth}px`; } } /** * Detect if the select is inside a modal container * @returns The modal element if found, null otherwise */ private _getModalContainer(): HTMLElement | null { return this._element.closest( '[data-kt-modal], .kt-modal, .kt-modal-center', ) as HTMLElement | null; } /** * Get the appropriate boundary element for Popper positioning * For centered modals, use .kt-modal-content to avoid transform calculation issues * @returns The boundary element, or null if no modal found */ private _getModalBoundary(): HTMLElement | null { const modalParent = this._getModalContainer(); if (!modalParent) { return null; } // For centered modals, use .kt-modal-content as boundary to avoid transform issues if (modalParent.classList.contains('kt-modal-center')) { const modalContent = modalParent.querySelector( '.kt-modal-content', ) as HTMLElement | null; return modalContent || modalParent; } // For non-centered modals, use the modal element itself return modalParent; } /** * Get the appropriate positioning strategy based on context * @returns 'fixed' if inside non-centered modal, 'absolute' for centered modals or no modal */ private _getPositioningStrategy(): 'fixed' | 'absolute' { // Check if config explicitly sets strategy if (this._config.dropdownStrategy) { return this._config.dropdownStrategy as 'fixed' | 'absolute'; } // For centered modals, use absolute positioning to avoid transform calculation issues // For non-centered modals, use fixed positioning const modalParent = this._getModalContainer(); if (modalParent && modalParent.classList.contains('kt-modal-center')) { return 'absolute'; } // Use fixed positioning for non-centered modals return modalParent ? 'fixed' : 'absolute'; } /** * Initialize the Popper instance for dropdown positioning */ private _initPopper(): void { // Destroy existing popper instance if it exists this._destroyPopper(); // Default offset const offsetValue = '0, 5'; // Get configuration options const placement = this._config.dropdownPlacement || 'bottom-start'; const strategy = this._getPositioningStrategy(); const preventOverflow = this._config.dropdownPreventOverflow !== false; const flip = this._config.dropdownFlip !== false; // Get appropriate boundary element for modal context const boundary = this._getModalBoundary() || 'clippingParents'; // Create new popper instance this._popperInstance = createPopper( this._toggleElement, this._dropdownElement, { placement: placement as Placement, strategy: strategy, modifiers: [ { name: 'offset', options: { offset: this._parseOffset(offsetValue), }, }, { name: 'preventOverflow', options: { boundary: boundary, altAxis: preventOverflow, }, }, { name: 'flip', options: { enabled: flip, fallbackPlacements: ['top-start', 'bottom-end', 'top-end'], }, }, { name: 'sameWidth', enabled: !this._config.dropdownWidth, phase: 'beforeWrite', requires: ['computeStyles'], fn: ({ state }) => { state.styles.popper.width = `${state.rects.reference.width}px`; }, effect: ({ state }) => { // Add type guard for HTMLElement const reference = state.elements.reference as HTMLElement; if (reference && 'offsetWidth' in reference) { state.elements.popper.style.width = `${reference.offsetWidth}px`; } }, }, ], }, ); } /** * Parse offset string into an array of numbers */ private _parseOffset(offset: string): number[] { return offset.split(',').map((value) => parseInt(value.trim(), 10)); } /** * Destroy the Popper instance */ private _destroyPopper(): void { if (this._popperInstance) { this._popperInstance.destroy(); this._popperInstance = null; } } /** * Update dropdown position */ public updatePosition(): void { if (this._popperInstance) { this._popperInstance.update(); } } /** * Open the dropdown */ public open(): void { if (this._config.disabled) { return; } if (this._isOpen || this._isTransitioning) return; // Begin opening transition this._isTransitioning = true; // Set initial styles this._dropdownElement.classList.remove('hidden'); this._dropdownElement.style.opacity = '0'; // Set dropdown width this._setDropdownWidth(); // Reflow KTDom.reflow(this._dropdownElement); // Apply z-index let zIndexToApply: number | null = null; if (this._config.dropdownZindex) { zIndexToApply = this._config.dropdownZindex; } // Consider the dropdown's current z-index if it's already set and higher const currentDropdownZIndexStr = KTDom.getCssProp( this._dropdownElement, 'z-index', ); if (currentDropdownZIndexStr && currentDropdownZIndexStr !== 'auto') { const currentDropdownZIndex = parseInt(currentDropdownZIndexStr); if ( !isNaN(currentDropdownZIndex) && currentDropdownZIndex > (zIndexToApply || 0) ) { zIndexToApply = currentDropdownZIndex; } } // Ensure dropdown is above elements within its original toggle's parent context const toggleParentContextZindex = KTDom.getHighestZindex(this._element); // _element is the select wrapper if ( toggleParentContextZindex !== null && toggleParentContextZindex >= (zIndexToApply || 0) ) { zIndexToApply = toggleParentContextZindex + 1; } if (zIndexToApply !== null) { this._dropdownElement.style.zIndex = zIndexToApply.toString(); } // Initialize popper this._initPopper(); // Add active classes for visual state this._dropdownElement.classList.add('open'); this._toggleElement.classList.add('active'); // ARIA attributes will be handled by KTSelect // Start transition this._dropdownElement.style.opacity = '1'; // Handle transition end KTDom.transitionEnd(this._dropdownElement, () => { this._isTransitioning = false; this._isOpen = true; // Focus and events will be handled by KTSelect }); } /** * Close the dropdown */ public close(): void { if (!this._isOpen || this._isTransitioning) { return; } // Events and ARIA will be handled by KTSelect this._isTransitioning = true; this._dropdownElement.style.opacity = '0'; let transitionComplete = false; const fallbackTimer = setTimeout(() => { if (!transitionComplete) { completeTransition(); } }, 300); const completeTransition = () => { if (transitionComplete) return; transitionComplete = true; clearTimeout(fallbackTimer); this._dropdownElement.classList.add('hidden'); this._dropdownElement.classList.remove('open'); this._toggleElement.classList.remove('active'); // ARIA attributes will be handled by KTSelect this._destroyPopper(); this._isTransitioning = false; this._isOpen = false; // Events will be handled by KTSelect }; KTDom.transitionEnd(this._dropdownElement, completeTransition); if ( KTDom.getCssProp(this._dropdownElement, 'transition-duration') === '0s' ) { completeTransition(); } } /** * Check if dropdown is open */ public isOpen(): boolean { return this._isOpen; } /** * Clean up component */ public override dispose(): void { // Destroy popper this._destroyPopper(); // Remove event listeners this._eventManager.removeAllListeners(this._element); this._eventManager.removeAllListeners(this._toggleElement); this._eventManager.removeAllListeners(document as unknown as HTMLElement); // Clean up focus manager if ( this._focusManager && typeof this._focusManager.dispose === 'function' ) { this._focusManager.dispose(); } // Clean up state this._isOpen = false; this._isTransitioning = false; // Remove data reference KTData.remove(this._element, this._name); } private _resolveDropdownContainer(): HTMLElement | null { const containerSelector = this._config.dropdownContainer; if (containerSelector) { const containerElement = document.querySelector( containerSelector, ) as HTMLElement | null; if (!containerElement && this._config.debug) { console.warn( `KTSelectDropdown: dropdownContainer selector "${containerSelector}" not found. Dropdown will remain in its default position.`, ); } return containerElement; } return null; } }