282 lines
8.1 KiB
JavaScript
282 lines
8.1 KiB
JavaScript
// Search Bar Component with Autocomplete
|
|
export class SearchBar {
|
|
constructor(onSearch, onSelect) {
|
|
this.onSearch = onSearch;
|
|
this.onSelect = onSelect;
|
|
this.searchInput = null;
|
|
this.suggestionsContainer = null;
|
|
this.allItems = [];
|
|
this.filteredItems = [];
|
|
this.selectedIndex = -1;
|
|
this.debounceTimer = null;
|
|
this.isOpen = false;
|
|
this.init();
|
|
}
|
|
|
|
async init() {
|
|
await this.loadAllItems();
|
|
this.createSearchBar();
|
|
}
|
|
|
|
async loadAllItems() {
|
|
try {
|
|
const response = await fetch('/modules');
|
|
const data = await response.json();
|
|
|
|
this.allItems = [];
|
|
|
|
// Add builtins
|
|
if (data.builtins && data.builtins.length > 0) {
|
|
data.builtins.forEach(item => {
|
|
this.allItems.push({
|
|
name: item.name,
|
|
fullName: item.full_name || `builtins.${item.name}`,
|
|
type: 'builtin',
|
|
display: item.name
|
|
});
|
|
});
|
|
}
|
|
|
|
// Add modules
|
|
if (data.modules && data.modules.length > 0) {
|
|
data.modules.forEach(item => {
|
|
this.allItems.push({
|
|
name: item.name,
|
|
fullName: item.name,
|
|
type: 'module',
|
|
display: item.name
|
|
});
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading items:', error);
|
|
this.allItems = [];
|
|
}
|
|
}
|
|
|
|
createSearchBar() {
|
|
const searchForm = document.querySelector('.search-form');
|
|
if (!searchForm) return;
|
|
|
|
// Create search input
|
|
const searchWrapper = document.createElement('div');
|
|
searchWrapper.className = 'search-bar-wrapper';
|
|
|
|
const searchIcon = document.createElement('span');
|
|
searchIcon.className = 'search-icon';
|
|
searchIcon.innerHTML = '🔍';
|
|
searchIcon.setAttribute('aria-hidden', 'true');
|
|
|
|
this.searchInput = document.createElement('input');
|
|
this.searchInput.type = 'text';
|
|
this.searchInput.className = 'search-bar-input';
|
|
this.searchInput.placeholder = 'Search Python objects, modules, builtins...';
|
|
this.searchInput.autocomplete = 'off';
|
|
this.searchInput.setAttribute('aria-label', 'Search Python documentation');
|
|
|
|
// Create suggestions dropdown
|
|
this.suggestionsContainer = document.createElement('div');
|
|
this.suggestionsContainer.className = 'search-suggestions';
|
|
this.suggestionsContainer.id = 'searchSuggestions';
|
|
|
|
searchWrapper.appendChild(searchIcon);
|
|
searchWrapper.appendChild(this.searchInput);
|
|
searchWrapper.appendChild(this.suggestionsContainer);
|
|
|
|
// Replace the old input group
|
|
const oldInputGroup = searchForm.querySelector('.input-group');
|
|
if (oldInputGroup) {
|
|
oldInputGroup.replaceWith(searchWrapper);
|
|
} else {
|
|
searchForm.insertBefore(searchWrapper, searchForm.firstChild);
|
|
}
|
|
|
|
this.setupEventListeners();
|
|
}
|
|
|
|
setupEventListeners() {
|
|
// Debounced search on input
|
|
this.searchInput.addEventListener('input', (e) => {
|
|
clearTimeout(this.debounceTimer);
|
|
const query = e.target.value.trim();
|
|
|
|
if (query.length === 0) {
|
|
this.hideSuggestions();
|
|
return;
|
|
}
|
|
|
|
this.debounceTimer = setTimeout(() => {
|
|
this.filterItems(query);
|
|
this.showSuggestions();
|
|
}, 150);
|
|
});
|
|
|
|
// Handle keyboard navigation
|
|
this.searchInput.addEventListener('keydown', (e) => {
|
|
if (!this.isOpen || this.filteredItems.length === 0) {
|
|
if (e.key === 'Enter' && this.searchInput.value.trim()) {
|
|
this.performSearch(this.searchInput.value.trim());
|
|
}
|
|
return;
|
|
}
|
|
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredItems.length - 1);
|
|
this.updateSelection();
|
|
break;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
|
|
this.updateSelection();
|
|
break;
|
|
case 'Enter':
|
|
e.preventDefault();
|
|
if (this.selectedIndex >= 0 && this.selectedIndex < this.filteredItems.length) {
|
|
this.selectItem(this.filteredItems[this.selectedIndex]);
|
|
} else if (this.searchInput.value.trim()) {
|
|
this.performSearch(this.searchInput.value.trim());
|
|
}
|
|
break;
|
|
case 'Escape':
|
|
this.hideSuggestions();
|
|
break;
|
|
}
|
|
});
|
|
|
|
// Close suggestions when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
if (!this.searchInput.contains(e.target) &&
|
|
!this.suggestionsContainer.contains(e.target)) {
|
|
this.hideSuggestions();
|
|
}
|
|
});
|
|
|
|
// Auto-search on blur if there's a value and user typed something
|
|
let hasTyped = false;
|
|
this.searchInput.addEventListener('input', () => {
|
|
hasTyped = true;
|
|
});
|
|
|
|
this.searchInput.addEventListener('blur', () => {
|
|
// Delay to allow click events on suggestions
|
|
setTimeout(() => {
|
|
const query = this.searchInput.value.trim();
|
|
if (query && hasTyped && !this.isOpen) {
|
|
this.performSearch(query);
|
|
}
|
|
hasTyped = false;
|
|
}, 200);
|
|
});
|
|
}
|
|
|
|
filterItems(query) {
|
|
const lowerQuery = query.toLowerCase();
|
|
this.filteredItems = this.allItems
|
|
.filter(item => {
|
|
const nameMatch = item.name.toLowerCase().includes(lowerQuery);
|
|
const fullNameMatch = item.fullName.toLowerCase().includes(lowerQuery);
|
|
return nameMatch || fullNameMatch;
|
|
})
|
|
.slice(0, 10); // Limit to 10 suggestions
|
|
|
|
this.selectedIndex = -1;
|
|
}
|
|
|
|
showSuggestions() {
|
|
if (this.filteredItems.length === 0) {
|
|
this.hideSuggestions();
|
|
return;
|
|
}
|
|
|
|
this.isOpen = true;
|
|
this.suggestionsContainer.innerHTML = '';
|
|
this.suggestionsContainer.classList.add('active');
|
|
|
|
this.filteredItems.forEach((item, index) => {
|
|
const suggestion = document.createElement('div');
|
|
suggestion.className = 'search-suggestion';
|
|
suggestion.dataset.index = index;
|
|
|
|
const icon = item.type === 'builtin' ? '⚡' : '📦';
|
|
suggestion.innerHTML = `
|
|
<span class="suggestion-icon">${icon}</span>
|
|
<span class="suggestion-name">${this.highlightMatch(item.name, this.searchInput.value)}</span>
|
|
<span class="suggestion-type">${item.type}</span>
|
|
`;
|
|
|
|
suggestion.addEventListener('click', () => {
|
|
this.selectItem(item);
|
|
});
|
|
|
|
suggestion.addEventListener('mouseenter', () => {
|
|
this.selectedIndex = index;
|
|
this.updateSelection();
|
|
});
|
|
|
|
this.suggestionsContainer.appendChild(suggestion);
|
|
});
|
|
}
|
|
|
|
highlightMatch(text, query) {
|
|
if (!query) return text;
|
|
const lowerText = text.toLowerCase();
|
|
const lowerQuery = query.toLowerCase();
|
|
const index = lowerText.indexOf(lowerQuery);
|
|
|
|
if (index === -1) return text;
|
|
|
|
const before = text.substring(0, index);
|
|
const match = text.substring(index, index + query.length);
|
|
const after = text.substring(index + query.length);
|
|
|
|
return `${before}<mark>${match}</mark>${after}`;
|
|
}
|
|
|
|
updateSelection() {
|
|
const suggestions = this.suggestionsContainer.querySelectorAll('.search-suggestion');
|
|
suggestions.forEach((suggestion, index) => {
|
|
if (index === this.selectedIndex) {
|
|
suggestion.classList.add('selected');
|
|
suggestion.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
} else {
|
|
suggestion.classList.remove('selected');
|
|
}
|
|
});
|
|
}
|
|
|
|
selectItem(item) {
|
|
this.searchInput.value = item.fullName;
|
|
this.hideSuggestions();
|
|
|
|
if (this.onSelect) {
|
|
this.onSelect(item.fullName);
|
|
}
|
|
|
|
this.performSearch(item.fullName);
|
|
}
|
|
|
|
performSearch(query) {
|
|
if (!query || query.trim().length === 0) return;
|
|
|
|
if (this.onSearch) {
|
|
this.onSearch(query.trim());
|
|
}
|
|
}
|
|
|
|
hideSuggestions() {
|
|
this.isOpen = false;
|
|
this.selectedIndex = -1;
|
|
this.suggestionsContainer.classList.remove('active');
|
|
this.suggestionsContainer.innerHTML = '';
|
|
}
|
|
|
|
setValue(value) {
|
|
if (this.searchInput) {
|
|
this.searchInput.value = value;
|
|
}
|
|
}
|
|
}
|
|
|