UNPKG

@underpostnet/underpost

Version:

Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc

1,008 lines (900 loc) 36.1 kB
/** * Reusable search component with extensible plugin architecture. * Provides typeahead search functionality with support for multiple search providers, * custom rendering, keyboard navigation, and theme-aware styling. * @module src/client/components/core/SearchBox.js * @namespace SearchBoxClient */ import { loggerFactory } from './Logger.js'; import { s, getAllChildNodes, htmls } from './VanillaJs.js'; import { Translate } from './Translate.js'; import { darkTheme, ThemeEvents, subThemeManager, lightenHex, darkenHex } from './Css.js'; const logger = loggerFactory(import.meta); /** * SearchBox class providing extensible search functionality. * Supports default menu/route search and pluggable search providers with * custom rendering, click handlers, and result merging. * @memberof SearchBoxClient */ class SearchBox { /** * Internal data storage for search state and handlers. * @type {object} * @memberof SearchBoxClient.SearchBox */ static Data = {}; /** * Registry of registered search provider plugins. * Each provider implements the search provider interface: * - id: Unique identifier string * - search: async (query, context) => Promise<Array<result>> * - renderResult: (result, index, context) => string (HTML) * - onClick: (result, context) => void * - priority: number (lower number = higher priority) * @type {Array<object>} * @memberof SearchBoxClient.SearchBox */ static providers = []; /** * Recent search results manager with localStorage persistence. * Tracks clicked results from all providers (routes and custom). * Maintains order of most-recent-first results across sessions. * @type {object} * @memberof SearchBoxClient.SearchBox */ static RecentResults = { /** * Storage key for localStorage persistence * @type {string} */ storageKey: 'searchbox_recent_results', /** * Maximum number of recent results to keep in history * @type {number} */ maxResults: 20, /** * Get all cached recent results from localStorage * @returns {Array<object>} Array of recent result objects */ getAll: function () { try { const stored = localStorage.getItem(this.storageKey); return stored ? JSON.parse(stored) : []; } catch (error) { logger.warn('Error reading search history from localStorage:', error); return []; } }, /** * Save recent results to localStorage * @param {Array<object>} results - Array of results to save */ saveAll: function (results) { try { localStorage.setItem(this.storageKey, JSON.stringify(results.slice(0, this.maxResults))); } catch (error) { logger.warn('Error saving search history to localStorage:', error); } }, /** * Add a result to recent history (moves to front if duplicate) * Removes duplicates and maintains max size limit. * Only stores serializable data (excludes DOM elements). * @param {object} result - Result object to add (must have id and providerId/routerId) */ add: function (result) { if (!result || (!result.id && !result.routerId)) { logger.warn('SearchBox.RecentResults.add: Invalid result, missing id or routerId'); return; } // Create a clean copy excluding DOM elements (fontAwesomeIcon, imgElement) const cleanResult = { id: result.id, routerId: result.routerId, type: result.type, providerId: result.providerId, title: result.title, subtitle: result.subtitle, tags: result.tags, createdAt: result.createdAt, data: result.data, }; const recent = this.getAll(); // Remove duplicate if it exists (based on id and providerId/routerId) const filteredRecent = recent.filter((r) => { if (cleanResult.providerId && r.providerId) { return !(r.id === cleanResult.id && r.providerId === cleanResult.providerId); } else if (cleanResult.routerId && r.routerId) { return r.routerId !== cleanResult.routerId; } return true; }); // Add new result to front filteredRecent.unshift(cleanResult); // Save to localStorage this.saveAll(filteredRecent); }, /** * Clear all recent results from localStorage */ clear: function () { try { localStorage.removeItem(this.storageKey); } catch (error) { logger.warn('Error clearing search history:', error); } }, /** * Remove a single result from recent history by ID and provider * @param {string} resultId - Result ID to remove * @param {string} [providerId] - Provider ID of the result (optional, for routes use null) */ remove: function (resultId, providerId) { const recent = this.getAll(); const filtered = recent.filter((r) => { // Match by ID and providerId (or routerId for routes) if (providerId) { return !(r.id === resultId && r.providerId === providerId); } else { // For routes (providerId is null), match by routerId instead return !(r.routerId === resultId); } }); this.saveAll(filtered); }, }; /** * Registers a search provider plugin for extensible search functionality. * Replaces any existing provider with the same ID. * @memberof SearchBoxClient.SearchBox * @param {object} provider - The search provider object to register. * @param {string} provider.id - Unique identifier for the provider. * @param {Function} provider.search - Async function: (query, context) => Promise<Array<result>>. * @param {Function} [provider.renderResult] - Custom renderer: (result, index, context) => HTML string. * @param {Function} [provider.onClick] - Click handler: (result, context) => void. * @param {number} [provider.priority=50] - Priority for result ordering (lower = higher priority). * @returns {void} */ static registerProvider(provider) { if (!provider.id || !provider.search) { logger.error('Invalid provider. Must have id and search function'); return; } // Remove existing provider with same id this.providers = this.providers.filter((p) => p.id !== provider.id); // Add new provider this.providers.push({ id: provider.id, search: provider.search, renderResult: provider.renderResult || ((result) => this.defaultRenderResult(result)), onClick: provider.onClick || (() => {}), priority: provider.priority || 50, // Lower number = higher priority in results }); logger.info(`Registered search provider: ${provider.id}`); } /** * Unregisters a search provider by its ID. * @memberof SearchBoxClient.SearchBox * @param {string} providerId - The ID of the provider to unregister. * @returns {void} */ static unregisterProvider(providerId) { this.providers = this.providers.filter((p) => p.id !== providerId); logger.info(`Unregistered search provider: ${providerId}`); } /** * Default result renderer with support for tags and badges. * Used when a provider doesn't supply a custom renderResult function. * @memberof SearchBoxClient.SearchBox * @param {object} result - The search result object to render. * @param {string} result.id - Result identifier. * @param {string} [result.icon] - HTML for icon display. * @param {string} [result.title] - Result title text. * @param {string} [result.subtitle] - Result subtitle text. * @param {Array<string>} [result.tags] - Array of tag strings. * @param {string} result.type - Result type identifier. * @param {string} result.providerId - Provider ID that generated this result. * @returns {string} HTML string for the search result. */ static defaultRenderResult(result) { const icon = result.icon || '<i class="fas fa-file"></i>'; const title = result.title || result.id || 'Untitled'; const subtitle = result.subtitle || ''; const tags = result.tags || []; // instance tags if available const tagsHtml = tags.length > 0 ? `<div class="search-result-tags"> ${tags.map((tag) => `<span class="search-result-tag">${tag}</span>`).join('')} </div>` : ''; return html` <div class="search-result-item" data-result-id="${result.id}" data-result-type="${result.type}" data-provider-id="${result.providerId}" > <div class="search-result-icon">${icon}</div> <div class="search-result-content"> <div class="search-result-title">${title}</div> ${subtitle ? `<div class="search-result-subtitle">${subtitle}</div>` : ''} ${tagsHtml} </div> </div> `; } /** * Navigates through search results using keyboard arrow keys. * Optimized for performance with direct DOM manipulation and efficient scrolling. * Supports wrap-around navigation (top to bottom and vice versa). * @memberof SearchBoxClient.SearchBox * @param {string} direction - Navigation direction: 'up' or 'down'. * @param {string} containerId - Results container element ID or class name. * @param {number} currentIndex - Current active result index (0-based). * @param {number} totalItems - Total number of result items. * @returns {number} New active index after navigation. */ static navigateResults(direction, containerId, currentIndex, totalItems) { if (!containerId || totalItems === 0) return currentIndex; const container = s(`#${containerId}`) || s(`.${containerId}`); const allItems = container ? container.querySelectorAll('.search-result-item') : []; if (!allItems || allItems.length === 0) return currentIndex; // Remove active class from current item (efficient DOM manipulation) if (allItems[currentIndex]) { allItems[currentIndex].classList.remove('active-search-result'); } // Calculate new index with wrap-around let newIndex = currentIndex; if (direction === 'up') { newIndex = currentIndex > 0 ? currentIndex - 1 : allItems.length - 1; } else if (direction === 'down') { newIndex = currentIndex < allItems.length - 1 ? currentIndex + 1 : 0; } // Add active class to new item and ensure visibility if (allItems[newIndex]) { allItems[newIndex].classList.add('active-search-result'); // Use optimized scroll method (no animation, instant positioning) this.scrollIntoViewIfNeeded(allItems[newIndex], container); } return newIndex; } /** * Searches through default application routes for matches. * Backward compatible with Modal.js search functionality. * Matches route IDs and translated route names against the query string. * @memberof SearchBoxClient.SearchBox * @param {string} query - The search query string. * @param {object} context - Search context object. * @param {object} [context.RouterInstance] - Router instance containing routes. * @param {object} [context.options] - Additional search options. * @param {string} [context.options.searchCustomImgClass] - Custom image class to search for. * @returns {Array<object>} Array of route search results. */ static searchRoutes(query, context) { const results = []; const { RouterInstance, options = {} } = context; if (!RouterInstance) return results; const routerInstance = RouterInstance.Routes(); for (const _routerId of Object.keys(routerInstance)) { const routerId = _routerId.slice(1); if (routerId) { if ( s(`.main-btn-${routerId}`) && (routerId.toLowerCase().match(query.toLowerCase()) || (Translate.Data[routerId] && Object.keys(Translate.Data[routerId]).filter((keyLang) => Translate.Data[routerId][keyLang].toLowerCase().match(query.toLowerCase()), ).length > 0)) ) { const fontAwesomeIcon = getAllChildNodes(s(`.main-btn-${routerId}`)).find((e) => { return e.classList && Array.from(e.classList).find((e) => e.match('fa-') && !e.match('fa-grip-vertical')); }); const imgElement = getAllChildNodes(s(`.main-btn-${routerId}`)).find((e) => { return ( e.classList && Array.from(e.classList).find((e) => options.searchCustomImgClass ? e.match(options.searchCustomImgClass) : e.match('img-btn-square-menu'), ) ); }); if (imgElement || fontAwesomeIcon) { results.push({ id: routerId, routerId, fontAwesomeIcon, imgElement, type: 'route', providerId: 'default-routes', }); } } } } return results; } /** * Executes search across all registered providers and default routes. * Combines results from multiple sources and sorts by priority. * @memberof SearchBoxClient.SearchBox * @param {string} query - The search query string. * @param {object} [context={}] - Search context object passed to all providers. * @returns {Promise<Array<object>>} Promise resolving to combined, priority-sorted results array. */ static async search(query, context = {}) { const allResults = []; // Always include default route search (backward compatible) const routeResults = this.searchRoutes(query, context); allResults.push(...routeResults); // Execute all registered providers const providerPromises = this.providers.map(async (provider) => { try { const results = await provider.search(query, context); return results.map((result) => ({ ...result, providerId: provider.id, priority: provider.priority, })); } catch (error) { logger.error(`Error in provider ${provider.id}:`, error); return []; } }); const providerResults = await Promise.all(providerPromises); providerResults.forEach((results) => { allResults.push(...results); }); // Sort by priority allResults.sort((a, b) => (a.priority || 50) - (b.priority || 50)); return allResults; } /** * Renders search results into a container element. * Delegates rendering to provider-specific renderers or default route renderer. * Automatically attaches click handlers and calls provider post-render hooks. * @memberof SearchBoxClient.SearchBox * @param {Array<object>} results - Array of search results to render. * @param {string} containerId - Results container element ID or class name. * @param {object} [context={}] - instance context passed to renderers and handlers. * @returns {void} */ static renderResults(results, containerId, context = {}) { const container = s(`#${containerId}`) || s(`.${containerId}`); if (!container) { logger.warn(`Container ${containerId} not found`); return; } if (!results || results.length === 0) { container.innerHTML = ''; return; } // Check if this is rendering recently clicked items (not search results) // context.isRecentHistory is set when rendering from history, not from search query const isRecentHistory = context.isRecentHistory === true; let htmlContent = ''; results.forEach((result, index) => { const provider = this.providers.find((p) => p.id === result.providerId); let resultHtml = ''; if (result.type === 'route' || !provider) { // Default route rendering (backward compatible) resultHtml = this.renderRouteResult(result, index, context); } else { // Custom provider rendering resultHtml = provider.renderResult(result, index, context); } // Only add delete button for recently clicked items (not search results) if (isRecentHistory) { // Wrapper with relative position for absolute delete button htmlContent += ` <div class="search-result-wrapper search-result-history-item" data-result-id="${result.id || result.routerId}" data-provider-id="${result.providerId || 'default-routes'}"> ${resultHtml} <button class="search-result-delete-btn" data-result-id="${result.id || result.routerId}" data-provider-id="${result.providerId || 'default-routes'}" title="Remove from history" aria-label="Remove from history" > <i class="fas fa-trash-alt"></i> </button> </div> `; } else { // Search results: no delete button, no wrapper overhead htmlContent += resultHtml; } }); container.innerHTML = htmlContent; // Attach click handlers this.attachClickHandlers(results, containerId, context); // Only attach delete handlers for recently clicked items if (isRecentHistory) { this.attachDeleteHandlers(container, results, containerId, context); } // Call post-render callbacks from providers results.forEach((result) => { const provider = this.providers.find((p) => p.id === result.providerId); if (provider && provider.attachTagHandlers) { provider.attachTagHandlers(); } }); } /** * Attaches delete event handlers to result delete buttons within a specific container. * Removes only the clicked result from history with smooth animation feedback. * Only affects delete buttons within the specified container. * @memberof SearchBoxClient.SearchBox * @param {HTMLElement} container - The container element to search within. * @param {Array<object>} results - Array of search results. * @param {string} containerId - Results container element ID or class name. * @param {object} [context={}] - Context object. * @returns {void} */ static attachDeleteHandlers(container, results, containerId, context = {}) { // Only select delete buttons within this specific container const deleteButtons = container.querySelectorAll('.search-result-delete-btn'); deleteButtons.forEach((btn) => { btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const resultId = btn.getAttribute('data-result-id'); const providerId = btn.getAttribute('data-provider-id'); // Animate removal const wrapper = btn.closest('.search-result-history-item'); if (wrapper) { wrapper.classList.add('search-result-removing'); setTimeout(() => { // Remove only this specific result from history storage if (providerId === 'default-routes') { this.RecentResults.remove(resultId, null); } else { this.RecentResults.remove(resultId, providerId); } // Filter out only the deleted result from the array const remaining = results.filter((r) => { if (providerId === 'default-routes') { // For routes, match by routerId return r.routerId !== resultId; } else { // For providers, match by id and providerId return !(r.id === resultId && r.providerId === providerId); } }); // Re-render with remaining results (context.isRecentHistory should stay true) if (remaining.length > 0) { this.renderResults(remaining, containerId, context); } else { // If no results left, clear container and hide clear-all button container.innerHTML = ''; const clearAllBtn = document.querySelector('.btn-search-history-clear-all'); if (clearAllBtn) { clearAllBtn.style.display = 'none'; } } }, 200); } }); }); } /** * Renders a default route search result. * Backward compatible with Modal.js search functionality. * Displays route icon and translated route name. * @memberof SearchBoxClient.SearchBox * @param {object} result - The route result object to render. * @param {string} result.routerId - Route identifier. * @param {HTMLElement} [result.fontAwesomeIcon] - FontAwesome icon element. * @param {HTMLElement} [result.imgElement] - Image icon element. * @param {number} index - The index of this result in the results array. * @param {object} [context={}] - instance context object. * @param {object} [context.options] - Additional rendering options. * @returns {string} HTML string for the route search result. */ static renderRouteResult(result, index, context = {}) { const { options = {} } = context; const routerId = result.routerId; const fontAwesomeIcon = result.fontAwesomeIcon; const imgElement = result.imgElement; let iconHtml = ''; // For route results from history, reconstruct icons from DOM if (!fontAwesomeIcon && !imgElement && routerId) { const routeBtn = s(`.main-btn-${routerId}`); if (routeBtn) { const icon = getAllChildNodes(routeBtn).find((e) => { return e.classList && Array.from(e.classList).find((e) => e.match('fa-') && !e.match('fa-grip-vertical')); }); const img = getAllChildNodes(routeBtn).find((e) => { return ( e.classList && Array.from(e.classList).find((e) => options.searchCustomImgClass ? e.match(options.searchCustomImgClass) : e.match('img-btn-square-menu'), ) ); }); if (img) { iconHtml = img.outerHTML; } else if (icon) { iconHtml = icon.outerHTML; } } } else { // For fresh search results, use provided DOM elements if (imgElement) { iconHtml = imgElement.outerHTML; } else if (fontAwesomeIcon) { iconHtml = fontAwesomeIcon.outerHTML; } } const translatedText = Translate.instance(routerId); return html` <div class="search-result-item search-result-route" data-result-id="${routerId}" data-result-type="route" data-result-index="${index}" data-provider-id="default-routes" > <div class="search-result-icon">${iconHtml}</div> <div class="search-result-content"> <div class="search-result-title">${translatedText}</div> </div> </div> `; } /** * Attaches click event handlers to all rendered search results. * Routes trigger menu button clicks; custom providers call their onClick handlers. * @memberof SearchBoxClient.SearchBox * @param {Array<object>} results - Array of search results. * @param {string} containerId - Results container element ID or class name. * @param {object} [context={}] - Context object with callbacks. * @param {Function} [context.onResultClick] - Callback invoked after any result is clicked. * @returns {void} */ static attachClickHandlers(results, containerId, context = {}) { results.forEach((result, index) => { const element = s(`[data-result-index="${index}"]`); if (!element) return; element.onclick = (e) => { e.preventDefault(); e.stopPropagation(); // Track result in persistent history for all result types this.RecentResults.add(result); const provider = this.providers.find((p) => p.id === result.providerId); if (result.type === 'route') { // Default route behavior - click the menu button const btnSelector = `.main-btn-${result.routerId}`; if (s(btnSelector)) { s(btnSelector).click(); } } else if (provider && provider.onClick) { // Custom provider click handler provider.onClick(result, context); } // Dismiss search box if callback provided if (context.onResultClick) { context.onResultClick(result); } }; }); } /** * Scrolls an element into view within a scrollable container if needed. * Performance-critical for keyboard navigation - uses direct scrollTop manipulation * instead of smooth scrolling to reduce overhead and ensure instant visibility. * * ROBUST IMPLEMENTATION: * - Auto-detects the actual scrollable parent container * - Uses getBoundingClientRect() for accurate viewport-aware positioning * - Handles complex DOM structures (modals, positioned elements, transforms) * - Includes fallback to native scrollIntoView() if custom logic fails * * Algorithm: * 1. Find actual scrollable container (may be parent of passed container) * 2. Calculate element position relative to container's visible area * 3. Determine scroll adjustment needed (up, down, or none) * 4. Apply scroll adjustment * 5. Verify visibility and use native scrollIntoView as fallback if needed * * @memberof SearchBoxClient.SearchBox * @param {HTMLElement} element - The element to scroll into view. * @param {HTMLElement} container - The scrollable container (or parent of scrollable). * @returns {void} */ static scrollIntoViewIfNeeded(element, container) { if (!element || !container) return; // CRITICAL FIX: Find the actual scrollable container // The passed container might not be scrollable; we need to find the parent that is let scrollableContainer = container; // Check if current container is scrollable const isScrollable = (el) => { if (!el) return false; const hasScroll = el.scrollHeight > el.clientHeight; const overflowY = window.getComputedStyle(el).overflowY; return hasScroll && (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay'); }; // If container is not scrollable, traverse up to find scrollable parent if (!isScrollable(container)) { let parent = container.parentElement; while (parent && parent !== document.body) { if (isScrollable(parent)) { scrollableContainer = parent; break; } parent = parent.parentElement; } } // ROBUST POSITION CALCULATION // Get element's position relative to scrollable container using getBoundingClientRect // This handles all edge cases including transformed elements, scrolled parents, etc. const elementRect = element.getBoundingClientRect(); const containerRect = scrollableContainer.getBoundingClientRect(); // Calculate element position relative to container's visible area const elementTopRelative = elementRect.top - containerRect.top; const elementBottomRelative = elementRect.bottom - containerRect.top; const containerVisibleHeight = scrollableContainer.clientHeight; // Add padding to avoid elements being exactly at edges (better UX) const padding = 10; // Determine scroll adjustment needed let scrollAdjustment = 0; // Element is ABOVE visible area if (elementTopRelative < padding) { // Need to scroll up scrollAdjustment = elementTopRelative - padding; } // Element is BELOW visible area else if (elementBottomRelative > containerVisibleHeight - padding) { // Need to scroll down scrollAdjustment = elementBottomRelative - containerVisibleHeight + padding; } // Apply scroll adjustment if needed if (scrollAdjustment !== 0) { scrollableContainer.scrollTop += scrollAdjustment; } // FALLBACK: If custom scroll didn't work, use native scrollIntoView // This ensures visibility even if our calculation fails setTimeout(() => { const rectCheck = element.getBoundingClientRect(); const containerRectCheck = scrollableContainer.getBoundingClientRect(); const stillAbove = rectCheck.top < containerRectCheck.top; const stillBelow = rectCheck.bottom > containerRectCheck.bottom; if (stillAbove || stillBelow) { element.scrollIntoView({ behavior: 'auto', block: stillAbove ? 'start' : 'end', inline: 'nearest', }); } }, 0); } /** * Gets base CSS styles for SearchBox with theme-aware styling. * Uses subThemeManager colors for consistent theming across light and dark modes. * Styles include search result items, icons, tags, and active states. * @memberof SearchBoxClient.SearchBox * @returns {string} CSS string containing all base SearchBox styles. */ static getBaseStyles = () => { // Get theme color from subThemeManager const themeColor = darkTheme ? subThemeManager.darkColor : subThemeManager.lightColor; const hasThemeColor = themeColor && themeColor !== null; // Calculate theme-based colors let activeBg, activeBorder, hoverBg, iconColor, tagBg, tagColor, tagBorder; if (darkTheme) { // Dark theme styling - solid white icons for better visibility iconColor = '#ffffff'; if (hasThemeColor) { activeBg = darkenHex(themeColor, 0.7); activeBorder = lightenHex(themeColor, 0.4); hoverBg = `${darkenHex(themeColor, 0.8)}33`; // 20% opacity tagBg = darkenHex(themeColor, 0.6); tagColor = lightenHex(themeColor, 0.7); tagBorder = lightenHex(themeColor, 0.3); } else { activeBg = '#2a2a2a'; activeBorder = '#444'; hoverBg = 'rgba(255, 255, 255, 0.05)'; tagBg = '#333'; tagColor = '#aaa'; tagBorder = '#555'; } } else { // Light theme styling - solid black icons for better visibility iconColor = '#000000'; if (hasThemeColor) { activeBg = lightenHex(themeColor, 0.85); activeBorder = lightenHex(themeColor, 0.5); hoverBg = `${lightenHex(themeColor, 0.9)}33`; // 20% opacity tagBg = lightenHex(themeColor, 0.8); tagColor = darkenHex(themeColor, 0.3); tagBorder = lightenHex(themeColor, 0.6); } else { activeBg = '#f0f0f0'; activeBorder = '#ccc'; hoverBg = 'rgba(0, 0, 0, 0.05)'; tagBg = '#e8e8e8'; tagColor = '#555'; tagBorder = '#d0d0d0'; } } return css` /* Search result items - simplified, consistent borders */ .search-result-item { display: flex; align-items: center; gap: 10px; padding: 12px 14px; margin: 4px 0; cursor: pointer; border-radius: 4px; transition: all 0.15s ease; border: 1px solid transparent; background: transparent; min-height: 44px; box-sizing: border-box; width: 100%; max-width: 100%; } .search-result-item:hover { background: ${hoverBg}; border-color: ${activeBorder}44; } .search-result-item.active-search-result, .search-result-item.main-btn-menu-active { background: ${activeBg} !important; border: 1px solid ${activeBorder} !important; box-shadow: 0 0 0 1px ${activeBorder}66 !important; } .search-result-route { padding: 10px 12px; margin: 2px; text-align: left; min-height: 40px; } .search-result-icon { display: flex; align-items: center; justify-content: center; min-width: 28px; min-height: 28px; font-size: 16px; color: ${iconColor} !important; } .search-result-icon i { color: ${iconColor} !important; } .search-result-icon .fa, .search-result-icon .fas, .search-result-icon .far, .search-result-icon .fab { color: ${iconColor} !important; } .search-result-icon img { width: 25px; height: 25px; } .search-result-content { flex: 1; min-width: 0; } .search-result-title { font-size: 14px; font-weight: 500; margin-bottom: 2px; line-height: 1.4; } .search-result-subtitle { font-size: 12px; color: ${darkTheme ? '#999' : '#666'}; margin-top: 2px; } /* Tags/Badges - themed with subThemeManager colors */ .search-result-tag, .search-result-badge { display: inline-block; padding: 2px 8px; margin: 2px 4px 2px 0; font-size: 11px; border-radius: 3px; background: ${tagBg}; color: ${tagColor}; border: 1px solid ${tagBorder}; white-space: nowrap; } .search-result-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; } /* Active item tags have stronger accent */ .search-result-item.active-search-result .search-result-tag, .search-result-item.active-search-result .search-result-badge { background: ${hasThemeColor ? (darkTheme ? darkenHex(themeColor, 0.5) : lightenHex(themeColor, 0.75)) : tagBg}; border-color: ${activeBorder}; color: ${hasThemeColor ? (darkTheme ? lightenHex(themeColor, 0.8) : darkenHex(themeColor, 0.4)) : tagColor}; } /* Wrapper for history items with delete button - maintains original width */ .search-result-history-item { position: relative; box-sizing: border-box; width: 100%; max-width: 100%; } .search-result-history-item .search-result-item { width: 100%; box-sizing: border-box; padding-right: 30px; /* Make room for delete button */ } /* Delete button - absolute positioned in top-right corner */ .search-result-delete-btn { position: absolute; top: 4px; right: 4px; background: none; border: none; color: #999; cursor: pointer; padding: 3px 6px; border-radius: 3px; transition: all 0.2s ease; opacity: 0; width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; font-size: 10px; z-index: 5; } .search-result-history-item:hover .search-result-delete-btn { opacity: 1; color: ${darkTheme ? '#ff8a8a' : '#e53935'}; background: ${darkTheme ? 'rgba(255, 107, 107, 0.2)' : 'rgba(211, 47, 47, 0.15)'}; } .search-result-delete-btn:hover { background: ${darkTheme ? 'rgba(255, 107, 107, 0.35)' : 'rgba(211, 47, 47, 0.25)'}; transform: scale(1.1); } .search-result-delete-btn:active { transform: scale(0.95); } /* Animation for removal */ .search-result-history-item.search-result-removing { opacity: 0; transform: translateX(20px); transition: all 0.2s ease; } `; }; /** * Injects base SearchBox styles into the document head. * Creates a style tag if it doesn't exist, ensuring styles are loaded once. * Automatically called when SearchBox is first used. * @memberof SearchBoxClient.SearchBox * @returns {void} */ static injectStyles() { const styleId = 'search-box-base-styles'; let styleTag = document.getElementById(styleId); if (!styleTag) { styleTag = document.createElement('style'); styleTag.id = styleId; document.head.appendChild(styleTag); logger.info('Injected SearchBox base styles'); } // Always update styles (for theme changes and subThemeManager color changes) styleTag.textContent = this.getBaseStyles(); // Register theme change handler if not already registered if (typeof ThemeEvents !== 'undefined' && !ThemeEvents['searchBoxBaseStyles']) { ThemeEvents['searchBoxBaseStyles'] = () => { const tag = document.getElementById(styleId); if (tag) { tag.textContent = this.getBaseStyles(); logger.info('Updated SearchBox styles for theme change'); } }; } } } export { SearchBox };