/** * Base search module that provides core search functionality * This module can be extended for specific search implementations */ /** * Initialize search functionality for any content type * @param {string} contentSelector - CSS selector for searchable items * @param {Object} options - Configuration options * @returns {Object} Alpine.js data object with search functionality */ export function initializeBaseSearch(contentSelector = '.searchable-item', options = {}) { const defaults = { nameAttribute: 'data-name', tagsAttribute: 'data-tags', categoryAttribute: 'data-category', additionalAttributes: [], noResultsMessage: 'No results found', allItemsMessage: 'Showing all items', resultCountMessage: (count) => `Found ${count} items`, itemLabel: 'items', debounceTime: 150 // ms to debounce search input }; const config = { ...defaults, ...options }; return { searchQuery: '', hasResults: true, visibleCount: 0, loading: false, focusedItemIndex: -1, debounceTimeout: null, init() { // Initialize the visible count this.visibleCount = document.querySelectorAll(contentSelector).length; this.setupWatchers(); this.setupKeyboardShortcuts(); // Handle theme changes window.addEventListener('theme-changed', () => { this.filterContent(this.searchQuery); }); }, setupWatchers() { this.$watch('searchQuery', (query) => { // Debounce search for better performance if (this.debounceTimeout) { clearTimeout(this.debounceTimeout); } this.debounceTimeout = setTimeout(() => { this.filterContent(query); }, config.debounceTime); }); }, setupKeyboardShortcuts() { document.addEventListener('keydown', (e) => { // '/' key focuses the search input if (e.key === '/' && document.activeElement.id !== 'app-search') { e.preventDefault(); document.getElementById('app-search').focus(); } // Escape key clears the search if (e.key === 'Escape' && this.searchQuery !== '') { this.searchQuery = ''; document.getElementById('app-search').focus(); this.focusedItemIndex = -1; this.clearItemFocus(); } // Arrow key navigation through results if (this.searchQuery && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { e.preventDefault(); this.handleArrowNavigation(e.key); } // Enter key selects the focused item if (e.key === 'Enter' && this.focusedItemIndex >= 0) { this.handleEnterSelection(); } }); }, handleArrowNavigation(key) { const visibleItems = this.getVisibleItems(); if (visibleItems.length === 0) return; // Update focused item index if (key === 'ArrowDown') { this.focusedItemIndex = Math.min(this.focusedItemIndex + 1, visibleItems.length - 1); } else { this.focusedItemIndex = Math.max(this.focusedItemIndex - 1, -1); } // Clear previous focus this.clearItemFocus(); // If we're back at -1, focus the search input if (this.focusedItemIndex === -1) { document.getElementById('app-search').focus(); return; } // Focus the new item const itemToFocus = visibleItems[this.focusedItemIndex]; this.focusItem(itemToFocus); }, handleEnterSelection() { const visibleItems = this.getVisibleItems(); if (visibleItems.length === 0) return; const selectedItem = visibleItems[this.focusedItemIndex]; const link = selectedItem.querySelector('a'); if (link) { link.click(); } }, getVisibleItems() { return Array.from(document.querySelectorAll(contentSelector)) .filter(item => item.style.display !== 'none'); }, clearItemFocus() { // Remove focus styling from all items document.querySelectorAll(`${contentSelector}.keyboard-focus`).forEach(item => { item.classList.remove('keyboard-focus'); }); }, focusItem(item) { // Add focus styling item.classList.add('keyboard-focus'); // Scroll into view with options for smooth scrolling const scrollBehavior = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'; item.scrollIntoView({ behavior: scrollBehavior, block: 'nearest' }); }, filterContent(query) { query = query.toLowerCase().trim(); let anyResults = false; let visibleCount = 0; // Process all content items document.querySelectorAll(contentSelector).forEach((item) => { const isMatch = this.isItemMatch(item, query); if (isMatch) { item.style.display = ''; anyResults = true; visibleCount++; } else { item.style.display = 'none'; } }); // Update category visibility if applicable this.updateCategoryVisibility(query); // Update parent containers if needed this.updateContainerVisibility(query); this.updateResultsStatus(query, anyResults, visibleCount); }, isItemMatch(item, query) { // If query is empty, show all items if (query === '') { return true; } // Get searchable attributes const name = (item.getAttribute(config.nameAttribute) || '').toLowerCase(); const tags = (item.getAttribute(config.tagsAttribute) || '').toLowerCase(); const category = (item.getAttribute(config.categoryAttribute) || '').toLowerCase(); // Check additional attributes if specified const additionalMatches = config.additionalAttributes.some(attr => { const value = (item.getAttribute(attr) || '').toLowerCase(); return value.includes(query); }); // Check if any attribute matches the query return name.includes(query) || tags.includes(query) || category.includes(query) || additionalMatches; }, updateCategoryVisibility(query) { // Only proceed if we have category sections const categorySections = document.querySelectorAll('.category-section'); if (categorySections.length === 0) return; // For each category section, check if it has any visible items categorySections.forEach((categorySection) => { const categoryId = categorySection.getAttribute('data-category'); const items = categorySection.querySelectorAll(contentSelector); // Count visible items in this category const visibleItems = Array.from(items).filter(item => item.style.display !== 'none' ).length; // If no visible items and we're searching, hide the category if (query !== '' && visibleItems === 0) { categorySection.style.display = 'none'; } else { categorySection.style.display = ''; } }); }, updateContainerVisibility(query) { // If there are container elements that should be hidden when empty const containers = document.querySelectorAll('.content-container'); if (containers.length > 0) { containers.forEach((container) => { const hasVisibleItems = Array.from( container.querySelectorAll(contentSelector) ).some((item) => item.style.display !== 'none'); if (query === '' || hasVisibleItems) { container.style.display = ''; } else { container.style.display = 'none'; } }); } }, updateResultsStatus(query, anyResults, count) { // Update results status this.hasResults = query === '' || anyResults; this.visibleCount = count; // Update screen reader status const statusEl = document.getElementById('search-status'); if (statusEl) { if (query === '') { statusEl.textContent = config.allItemsMessage; this.visibleCount = document.querySelectorAll(contentSelector).length; } else if (this.hasResults) { statusEl.textContent = config.resultCountMessage(count); } else { statusEl.textContent = config.noResultsMessage; } } } }; }