/* eslint-disable ts/no-this-alias */ /* eslint-disable no-useless-call */ import type { VNode } from 'vue'; import type { DropdownButtonItemArgs } from '../dropdown-button/dropdown-button-item'; import type { FormItemWrapperArgs, MarginType } from '../form/form-item-wrapper'; import { Component, Prop, Watch } from 'vue-facing-decorator'; import PowerduckState from '../../app/powerduck-state'; import TsxComponent from '../../app/vuetsx'; import AccessibilityUtils from '../../common/utils/accessibility-utils'; import FormItemWrapper from '../form/form-item-wrapper'; import './css/smart-dropdown.scss'; // --- Resources --- export class SmartDropdownResources { static placeholderDefault = 'Vyberte...'; static doneButtonText = 'Hotovo'; static loadingTextShort = '...'; static loadingTextLong = 'Načítavam...'; static noResultsSearch = 'Nenašli sa žiadne výsledky.'; static noResultsDefault = 'Žiadne možnosti.'; static searchResultTitle = 'Výsledky vyhľadávania'; static selectedTitle = 'Vybrané'; static categoriesTitle = 'Kategórie'; static summarySuffix = ' vybrané'; static closeLabel = PowerduckState.getResourceValue('close'); static searchOnlyPrompt = 'Zadajte hľadaný výraz...'; // Typewriter effect timing configuration (in milliseconds) static typewriterTypingSpeed = 50; // ms per character when typing static typewriterDeletingSpeed = 30; // ms per character when deleting static typewriterPauseAfterTyping = 1500; // ms to pause after completing a word static typewriterPauseAfterDeleting = 300; // ms to pause after deleting static typewriterFadeInDuration = 300; // ms for fade-in effect on last item /** Icon class for the mobile back button (e.g., 'gp gp-arrow-left' or 'fa fa-arrow-left') */ static mobileBackIconClass: string | null = 'icon icon-arrow-left'; } // --- Interfaces --- export interface SmartDropdownCategoryItem { id: number | string; text: string; [key: string]: any; } export interface SmartDropdownSearchResultItem { id: number | string; text: string; subtitle?: string; imageUrl?: string; [key: string]: any; } export type SmartDropdownItem = SmartDropdownCategoryItem | SmartDropdownSearchResultItem; export interface SmartDropdownSection { id: string; title?: string; items: SmartDropdownItem[]; } /** Callback to change selection state of an item */ export type SelectionChangedCallback = (forceSelected?: boolean) => void; /** Arguments passed to customListItemRender */ export interface CustomListItemRenderArgs { /** The item being rendered */ item: SmartDropdownItem; /** Whether the item is currently selected */ isSelected: boolean; /** Callback to change selection state. Call with true to select, false to deselect, or undefined to toggle */ selectionChanged: SelectionChangedCallback; /** Renders the item using the default SmartDropdown renderer */ baseRender: () => VNode; } interface SmartDropdownArgs extends FormItemWrapperArgs { categories: SmartDropdownCategoryItem[]; customSections: SmartDropdownSection[]; searchData: (text: string) => Promise; value: SmartDropdownItem[]; multiselect: boolean; placeholder: string; selectionDisplay: 'chips' | 'text'; buttonLayout: 'footer' | 'inline'; searchMode: 'dropdown' | 'input'; customTriggerScope?: 'mobile' | 'desktop' | 'all'; /** Custom renderer for list items. Receives args object with item, isSelected, selectionChanged, and baseRender */ customListItemRender?: (args: CustomListItemRenderArgs) => VNode; customTriggerRender?: () => VNode; changed: (e: SmartDropdownItem[]) => void; /** Called when an item is clicked. Return true to prevent default selection behavior. */ onItemClick?: (item: SmartDropdownItem) => boolean; /** Delay in ms before showing the loading indicator. Default 650ms. */ loadingIndicatorDelay?: number; /** Optional header shown at the top of the list to guide users. Can be a string or VNode. */ listHeader?: string | VNode; /** Structured placeholder configuration with separate mobile/desktop settings */ placeholderConfig?: SmartDropdownPlaceholderConfig; /** When true, only shows search results (no sections/categories until user searches) */ searchOnlyMode?: boolean; /** Called before the dropdown opens. If defined, must resolve before dropdown opens. */ beforeDropdownOpen?: (context: BeforeDropdownOpenContext) => Promise; /** Called before the dropdown closes. */ beforeDropdownClose?: () => void; /** When true, hides the confirm button (useful for search-only mode) */ hideConfirmButton?: boolean; /** Debounce timeout in ms for search input. Set to 0 for no debouncing (instant search). Default 800ms. */ searchDebounceTimeout?: number; /** * Called when the user confirms via Enter key. Fires after any selection/auto-match * has been applied and the dropdown has been closed. Useful for advancing focus to * the next field in a multi-step form. */ confirmedByEnter?: () => void; } /** Context passed to beforeDropdownOpen callback */ export interface BeforeDropdownOpenContext { /** How the dropdown open was triggered */ trigger: 'click' | 'programmatic'; } /** Placeholder effect type */ export type PlaceholderEffect = 'typewriter' | 'none'; /** Placeholder configuration for a specific viewport */ export interface PlaceholderViewportConfig { /** Array of placeholder strings. For typewriter effect, cycles through them. For 'none', uses first value. */ values: string[]; /** Effect type: 'typewriter' animates through values, 'none' shows first value statically */ effect: PlaceholderEffect; } /** Structured placeholder configuration */ export interface SmartDropdownPlaceholderConfig { mobile: PlaceholderViewportConfig; desktop: PlaceholderViewportConfig; } @Component export default class SmartDropdown extends TsxComponent implements SmartDropdownArgs { @Prop() label!: string; @Prop() labelButtons!: DropdownButtonItemArgs[]; @Prop() subtitle!: string; @Prop() cssClass!: string; @Prop() mandatory!: boolean; @Prop() disabled!: boolean; @Prop() wrap!: boolean; @Prop() hint: string; @Prop() appendIcon: string; @Prop() prependIcon: string; @Prop() maxWidth?: number; @Prop() marginType?: MarginType; @Prop() appendClicked: () => void; @Prop() prependClicked: () => void; @Prop() prependIconClicked: () => void; @Prop() appendIconClicked: () => void; @Prop() keyDown: (e: KeyboardEvent) => void; @Prop() keyUp: (e: KeyboardEvent) => void; @Prop() enterPressed: (e: KeyboardEvent) => void; @Prop() showClearValueButton!: boolean; // --- Props from SmartDropdown--- @Prop({ type: Array, required: true }) readonly categories!: SmartDropdownCategoryItem[]; @Prop({ type: Array, default: () => [] }) readonly customSections!: SmartDropdownSection[]; @Prop({ type: Function, required: true }) readonly searchData!: (text: string) => Promise; @Prop({ type: Array, default: () => [] }) readonly value!: SmartDropdownItem[]; @Prop({ type: Boolean, default: false }) readonly multiselect!: boolean; @Prop({ type: String, default: () => SmartDropdownResources.placeholderDefault }) readonly placeholder!: string; @Prop({ type: String, default: 'text' }) readonly selectionDisplay!: 'chips' | 'text'; @Prop({ type: String, default: 'footer' }) readonly buttonLayout!: 'footer' | 'inline'; @Prop({ type: String, default: 'dropdown' }) readonly searchMode!: 'dropdown' | 'input'; @Prop({ type: String, default: 'all' }) readonly customTriggerScope!: 'mobile' | 'desktop' | 'all'; @Prop({ type: Function }) readonly customListItemRender?: (args: CustomListItemRenderArgs) => VNode; @Prop({ type: Function }) readonly customTriggerRender?: () => VNode; @Prop() readonly changed: (e: SmartDropdownItem[]) => void; @Prop({ type: Function }) readonly onItemClick?: (item: SmartDropdownItem) => boolean; @Prop({ type: Number, default: 650 }) readonly loadingIndicatorDelay!: number; @Prop() readonly listHeader?: string | VNode; @Prop({ type: Object }) readonly placeholderConfig?: SmartDropdownPlaceholderConfig; @Prop({ type: Boolean, default: false }) readonly searchOnlyMode!: boolean; @Prop({ type: Function }) readonly beforeDropdownOpen?: (context: BeforeDropdownOpenContext) => Promise; @Prop({ type: Function }) readonly beforeDropdownClose?: () => void; @Prop({ type: Boolean, default: null }) readonly hideConfirmButton!: boolean | null; @Prop({ type: Number, default: 800 }) readonly searchDebounceTimeout!: number; @Prop({ type: Function }) readonly confirmedByEnter?: () => void; // --- State --- isOpen = false; isClosing = false; searchQuery = ''; searchResults: SmartDropdownSearchResultItem[] = []; isLoading = false; isSearchPending = false; debounceTimer: number | null = null; loadingIndicatorTimer: number | null = null; focusedIndex = -1; triggerInputValue = ''; // Typewriter effect state typewriterText = ''; typewriterIndex = 0; typewriterCharIndex = 0; typewriterTimer: number | null = null; typewriterComplete = false; typewriterStarted = false; typewriterFadingIn = false; // True when showing last item with fade-in isMobileViewport = false; // Accessibility ID uid = `smart-dd-${Math.random().toString(36).slice(2, 9)}`; // --- Watchers --- @Watch('value', { deep: true, immediate: true }) onSelectionChange() { if (!this.isOpen || this.searchQuery === '') { this.triggerInputValue = this.displayText; } } // --- Computed --- get listboxId(): string { return `${this.uid}-listbox`; } get activeDescendantId(): string | undefined { return this.focusedIndex >= 0 ? `${this.uid}-option-${this.focusedIndex}` : undefined; } get flattenedDisplayItems(): SmartDropdownItem[] { // Helper to get flat list for keyboard navigation if (this.isSearchActive) { return this.searchResults; } const items: SmartDropdownItem[] = []; if (this.pinnedSelectedItems.length > 0) { items.push(...this.pinnedSelectedItems); } this.customSections.forEach(s => items.push(...s.items)); if (this.standardDisplayItems.length > 0) { items.push(...this.standardDisplayItems); } return items; } /** Normalizes text for case- and accent-insensitive comparison */ private normalizeForMatch(text: string): string { if (text == null) { return ''; } return text .toString() .toLowerCase() .normalize('NFD') .replace(/\p{Diacritic}/gu, '') .trim(); } /** * Finds an exact (case- and accent-insensitive) match for the given query * across the currently visible items, custom sections, and categories. */ private findExactMatchForQuery(query: string): SmartDropdownItem | null { const normalizedQuery = this.normalizeForMatch(query); if (normalizedQuery.length === 0) { return null; } const candidates: SmartDropdownItem[] = [ ...this.searchResults, ...this.customSections.flatMap(s => s.items), ...this.categories, ]; return candidates.find(item => this.normalizeForMatch(item.text) === normalizedQuery) ?? null; } /** * Handles the Enter key while the dropdown is open. Selects the focused item, * or — if nothing is focused — tries to auto-select an exact match for the * current search query. Always closes the dropdown and notifies the parent * via confirmedByEnter so it can advance focus to the next field. */ private handleEnterKey() { if (this.focusedIndex >= 0) { this.handleItemClick(this.flattenedDisplayItems[this.focusedIndex]); } else if (this.isSearchActive) { const exactMatch = this.findExactMatchForQuery(this.searchQuery); if (exactMatch) { this.handleItemClick(exactMatch, true); } } this.confirmAndClose(); this.confirmedByEnter?.(); } get standardDisplayItems(): SmartDropdownItem[] { if (this.multiselect && this.selectionDisplay === 'text' && this.value.length > 0) { const selectedIds = new Set(this.value.map(i => i.id)); return this.categories.filter(c => !selectedIds.has(c.id)); } return this.categories; } get pinnedSelectedItems(): SmartDropdownItem[] { if (this.multiselect && this.selectionDisplay === 'text' && !this.isSearchActive) { return this.value; } return []; } get isSearchActive(): boolean { return this.searchQuery.trim().length > 0; } /** Gets the current viewport config based on screen size */ get currentViewportConfig(): PlaceholderViewportConfig | null { if (!this.placeholderConfig) { return null; } return this.isMobileViewport ? this.placeholderConfig.mobile : this.placeholderConfig.desktop; } /** Returns the effective placeholder - either typewriter text or static placeholder */ get effectivePlaceholder(): string { const config = this.currentViewportConfig; if (config && config.values.length > 0) { if (config.effect === 'typewriter' && this.typewriterStarted) { return this.typewriterText; } // For 'none' effect or typewriter not started yet, return first value return config.values[0]; } return this.placeholder; } /** Returns true if typewriter effect is currently animating */ get isTypewriterActive(): boolean { const config = this.currentViewportConfig; return !!(config && config.effect === 'typewriter' && this.typewriterStarted && !this.typewriterComplete); } /** Returns true if confirm button should be shown */ get showConfirmButton(): boolean { // If explicitly set, use that value if (this.hideConfirmButton !== null) { return !this.hideConfirmButton; } // Default: hide in searchOnlyMode, show otherwise return !this.searchOnlyMode; } get displayText(): string { if (this.value.length === 0) { return ''; } if (!this.multiselect) { return this.value[0].text; } if (this.value.length === 1) { return this.value[0].text; } return `${this.value.length}${SmartDropdownResources.summarySuffix}`; } mounted() { // Detect initial viewport this.updateViewport(); } // Vue 3 hook — the previous `beforeDestroy` name is a Vue 2 API and never ran, // leaking the document-level click/keydown listeners on unmount-while-open. beforeUnmount() { this.removeEventHandlers(); this.stopTypewriter(); } // --- Methods --- /** Updates the viewport state based on current window width */ updateViewport() { this.isMobileViewport = window.innerWidth <= 768; } /** Starts the typewriter effect if configured for current viewport */ startTypewriter() { // Update viewport first this.updateViewport(); const config = this.currentViewportConfig; if (!config || config.effect !== 'typewriter' || config.values.length === 0) { return; } // Respect accessibility: skip animation for users with reduced motion or screen readers if (AccessibilityUtils.shouldSkipAnimation()) { // Show the last string immediately without animation this.typewriterText = config.values[config.values.length - 1]; this.typewriterComplete = true; this.typewriterStarted = true; return; } // Already started or completed if (this.typewriterStarted) { return; } this.typewriterStarted = true; this.typewriterIndex = 0; this.typewriterCharIndex = 0; this.typewriterComplete = false; this.typewriterFadingIn = false; this.typewriterText = ''; // Use configurable speeds from resources const TYPING_SPEED = SmartDropdownResources.typewriterTypingSpeed; const DELETING_SPEED = SmartDropdownResources.typewriterDeletingSpeed; const PAUSE_AFTER_TYPING = SmartDropdownResources.typewriterPauseAfterTyping; const PAUSE_AFTER_DELETING = SmartDropdownResources.typewriterPauseAfterDeleting; let isDeleting = false; const tick = () => { const strings = config.values; const currentString = strings[this.typewriterIndex]; const isLastString = this.typewriterIndex === strings.length - 1; const isSecondToLast = this.typewriterIndex === strings.length - 2; if (!isDeleting) { // Typing this.typewriterCharIndex++; this.typewriterText = currentString.substring(0, this.typewriterCharIndex); if (this.typewriterCharIndex === currentString.length) { // Finished typing current string if (isLastString) { // Last string - stop and keep it this.typewriterComplete = true; return; } // Pause then start deleting this.typewriterTimer = window.setTimeout(() => { isDeleting = true; tick(); }, PAUSE_AFTER_TYPING); return; } } else { // Deleting this.typewriterCharIndex--; this.typewriterText = currentString.substring(0, this.typewriterCharIndex); if (this.typewriterCharIndex === 0) { // Finished deleting, move to next string isDeleting = false; this.typewriterIndex++; // If next string is the last one, use fade-in instead of typing if (isSecondToLast) { this.typewriterFadingIn = true; this.typewriterText = strings[strings.length - 1]; // After fade completes, mark as done this.typewriterTimer = window.setTimeout(() => { this.typewriterFadingIn = false; this.typewriterComplete = true; }, SmartDropdownResources.typewriterFadeInDuration); return; } this.typewriterTimer = window.setTimeout(tick, PAUSE_AFTER_DELETING); return; } } this.typewriterTimer = window.setTimeout(tick, isDeleting ? DELETING_SPEED : TYPING_SPEED); }; tick(); } /** Stops the typewriter effect */ stopTypewriter() { if (this.typewriterTimer) { clearTimeout(this.typewriterTimer); this.typewriterTimer = null; } } /** Opens the dropdown programmatically */ openDropdown() { setTimeout(() => { this.toggleDropdownInternal(true, 'programmatic'); }, 0); } /** Toggles the dropdown (public API, used by click handlers) */ private toggleDropdown(force?: boolean) { this.toggleDropdownInternal(force, 'click'); } /** Internal toggle implementation with trigger context */ private async toggleDropdownInternal(force: boolean | undefined, trigger: 'click' | 'programmatic') { const nextState = typeof force === 'boolean' ? force : !this.isOpen; if (nextState) { // Call beforeDropdownOpen callback if defined if (this.beforeDropdownOpen) { try { await this.beforeDropdownOpen({ trigger }); } catch { // If callback rejects, don't open the dropdown return; } } this.isOpen = true; this.focusedIndex = -1; // Reset focus logic // Start typewriter when dropdown opens (if configured) this.startTypewriter(); this.$nextTick(() => { const isMobile = window.innerWidth <= 768; const dropdownInput = this.$el.querySelector('.dropdown-search-input') as HTMLInputElement; const triggerInput = this.$el.querySelector('.trigger-input') as HTMLInputElement; // Focus Logic const shouldUseExternalFocus = this.searchMode === 'input' && !isMobile && this.customTriggerScope !== 'desktop'; if (shouldUseExternalFocus) { triggerInput?.select(); } else { dropdownInput?.focus(); } // Initial Scroll Logic if (this.pinnedSelectedItems.length > 0) { const listContainer = this.$el.querySelector('.list-container') as HTMLElement; const categoryHeader = this.$el.querySelector('.section-categories') as HTMLElement; if (listContainer && categoryHeader) { listContainer.scrollTop = categoryHeader.offsetTop; } } }); this.removeEventHandlers(); const self = this; this.handleClickOutside = (e: Event) => { if (!self.$el.contains(e.target as Node)) { self.closeDropdown.call(self); } }; this.handleGlobalKeydown = (e: KeyboardEvent) => { if (!this.isOpen) { // Allow opening with Enter or Down Arrow/Space if focused on trigger if ((e.key === 'Enter' || e.key === 'ArrowDown' || e.key === ' ') && (e.target as HTMLElement).classList.contains('filter-trigger')) { self.toggleDropdownInternal.call( self, true, 'click', ); e.preventDefault(); } return; } const totalItems = self.flattenedDisplayItems.length; switch (e.key) { case 'Escape': self.closeDropdown.call(self); e.preventDefault(); break; case 'Enter': e.preventDefault(); (self as any).handleEnterKey.call(self); break; case 'ArrowDown': e.preventDefault(); self.focusedIndex = (self.focusedIndex + 1) % totalItems; self.scrollItemIntoView.call(self, self.focusedIndex); break; case 'ArrowUp': e.preventDefault(); self.focusedIndex = (self.focusedIndex - 1 + totalItems) % totalItems; self.scrollItemIntoView.call(self, self.focusedIndex); break; case 'Home': e.preventDefault(); self.focusedIndex = 0; self.scrollItemIntoView.call(self, self.focusedIndex); break; case 'End': e.preventDefault(); self.focusedIndex = totalItems - 1; self.scrollItemIntoView.call(self, self.focusedIndex); break; case 'Tab': self.closeDropdown.call(self); // Tab out closes dropdown break; } }; document.addEventListener('click', this.handleClickOutside); document.addEventListener('keydown', this.handleGlobalKeydown); } else { this.closeDropdown(); } } removeEventHandlers() { if (this.handleClickOutside) { document.removeEventListener('click', this.handleClickOutside); } if (this.handleGlobalKeydown) { document.removeEventListener('keydown', this.handleGlobalKeydown); } } closeDropdown() { const isMobile = window.innerWidth <= 768; if (isMobile && this.isOpen && !this.isClosing) { // Trigger closing animation on mobile this.isClosing = true; // Listen for animation end to actually close this.$nextTick(() => { const dropdown = this.$el.querySelector('.filter-dropdown') as HTMLElement; if (dropdown) { let hasCompleted = false; this.beforeDropdownClose?.(); const completeClose = () => { if (hasCompleted) { return; } hasCompleted = true; // eslint-disable-next-line ts/no-use-before-define dropdown.removeEventListener('animationend', onAnimationEnd); this.isOpen = false; this.isClosing = false; this.focusedIndex = -1; this.triggerInputValue = this.displayText; this.searchQuery = ''; }; const onAnimationEnd = () => completeClose(); dropdown.addEventListener('animationend', onAnimationEnd); // Fallback timeout in case animationend doesn't fire setTimeout(completeClose, 1000); } }); } else if (!this.isClosing) { // Desktop: close immediately this.isOpen = false; this.focusedIndex = -1; this.triggerInputValue = this.displayText; this.searchQuery = ''; this.beforeDropdownClose?.(); } } confirmAndClose() { if (this.isLoading) { return; } this.searchQuery = ''; this.searchResults = []; this.closeDropdown(); } handleClickOutside = (e: Event) => { if (!this.$el.contains(e.target as Node)) { this.closeDropdown(); } }; handleGlobalKeydown = (e: KeyboardEvent) => { if (!this.isOpen) { // Allow opening with Enter or Down Arrow/Space if focused on trigger if ((e.key === 'Enter' || e.key === 'ArrowDown' || e.key === ' ') && (e.target as HTMLElement).classList.contains('filter-trigger')) { this.toggleDropdown(true); e.preventDefault(); } return; } const totalItems = this.flattenedDisplayItems.length; switch (e.key) { case 'Escape': this.closeDropdown(); e.preventDefault(); break; case 'Enter': e.preventDefault(); (this as any).handleEnterKey(); break; case 'ArrowDown': e.preventDefault(); this.focusedIndex = (this.focusedIndex + 1) % totalItems; this.scrollItemIntoView(this.focusedIndex); break; case 'ArrowUp': e.preventDefault(); this.focusedIndex = (this.focusedIndex - 1 + totalItems) % totalItems; this.scrollItemIntoView(this.focusedIndex); break; case 'Home': e.preventDefault(); this.focusedIndex = 0; this.scrollItemIntoView(this.focusedIndex); break; case 'End': e.preventDefault(); this.focusedIndex = totalItems - 1; this.scrollItemIntoView(this.focusedIndex); break; case 'Tab': this.closeDropdown(); // Tab out closes dropdown break; } }; scrollItemIntoView(index: number) { // Use nextTick to ensure DOM is updated if virtualized (not here, but good practice) this.$nextTick(() => { const itemId = `${this.uid}-option-${index}`; const el = document.getElementById(itemId); el?.scrollIntoView({ block: 'nearest' }); }); } /** * Suppresses the browser's default focus-driven scroll when a dropdown item * containing a checkbox/radio input is clicked (QA__G-162). When the user * clicks a `