justin.deal/src/components/SearchScript.astro

266 lines
8.8 KiB
Plaintext
Raw Normal View History

2025-05-03 01:35:48 -07:00
---
---
<script is:inline>
/**
* Initialize search functionality for any content type
* @returns {Object} Alpine.js data object with search functionality
*/
function initializeSearch(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'
};
const config = { ...defaults, ...options };
return {
searchQuery: '',
hasResults: true,
visibleCount: 0,
init() {
// Initialize the visible count
this.visibleCount = document.querySelectorAll(contentSelector).length;
this.setupWatchers();
this.setupKeyboardShortcuts();
},
setupWatchers() {
this.$watch('searchQuery', (query) => {
this.filterContent(query);
});
},
setupKeyboardShortcuts() {
// Track the currently focused item index
this.focusedItemIndex = -1;
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();
const visibleItems = this.getVisibleItems();
if (visibleItems.length === 0) return;
// Update focused item index
if (e.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);
}
// Enter key selects the focused item
if (e.key === 'Enter' && this.focusedItemIndex >= 0) {
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');
item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
},
filterContent(query) {
query = query.toLowerCase();
let anyResults = false;
let visibleCount = 0;
// Process all content items
document.querySelectorAll(contentSelector).forEach((item) => {
// 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);
});
const isMatch = query === '' ||
name.includes(query) ||
tags.includes(query) ||
category.includes(query) ||
additionalMatches;
if (isMatch) {
item.style.display = '';
anyResults = true;
visibleCount++;
} else {
item.style.display = 'none';
}
});
// Update category visibility for homelab page
this.updateCategoryVisibility(query);
// Update parent containers if needed
this.updateContainerVisibility(query);
this.updateResultsStatus(query, anyResults, visibleCount);
},
updateCategoryVisibility(query) {
// Only proceed if we have category sections (homelab page)
const categorySections = document.querySelectorAll('.category-section');
if (categorySections.length === 0) return;
// For each category section, check if it has any visible app cards
categorySections.forEach((categorySection) => {
const categoryId = categorySection.getAttribute('data-category');
const appCards = categorySection.querySelectorAll('.app-card');
// Count visible app cards in this category
const visibleApps = Array.from(appCards).filter(card =>
card.style.display !== 'none'
).length;
// If no visible apps and we're searching, hide the category
if (query !== '' && visibleApps === 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;
}
}
}
};
}
// Register Alpine.js data components when Alpine is loaded
document.addEventListener('alpine:init', () => {
// Homelab search
window.Alpine.data('searchServices', () => {
return initializeSearch('.app-card', {
nameAttribute: 'data-app-name',
tagsAttribute: 'data-app-tags',
categoryAttribute: 'data-app-category',
noResultsMessage: 'No services found',
allItemsMessage: 'Showing all services',
resultCountMessage: (count) => `Found ${count} services`,
itemLabel: 'services'
});
});
// Blog search
window.Alpine.data('searchArticles', () => {
return initializeSearch('.article-item', {
nameAttribute: 'data-title',
tagsAttribute: 'data-tags',
additionalAttributes: ['data-description'],
noResultsMessage: 'No articles found',
allItemsMessage: 'Showing all articles',
resultCountMessage: (count) => `Found ${count} articles`,
itemLabel: 'articles'
});
});
// Projects search
window.Alpine.data('searchProjects', () => {
return initializeSearch('.project-item', {
nameAttribute: 'data-title',
tagsAttribute: 'data-tags',
additionalAttributes: ['data-description', 'data-github', 'data-live'],
noResultsMessage: 'No projects found',
allItemsMessage: 'Showing all projects',
resultCountMessage: (count) => `Found ${count} projects`,
itemLabel: 'projects'
});
});
});
</script>