Spaces:
Running
Running
/** | |
* AutoComplete Manager Module | |
* Handles intelligent auto-suggestions and field auto-completion | |
*/ | |
export class AutoCompleteManager { | |
constructor(apiClient, uiManager) { | |
this.apiClient = apiClient; | |
this.uiManager = uiManager; | |
this.searchTimeouts = {}; | |
this.activeDropdowns = new Set(); | |
this.selectedIndex = -1; | |
this.availableTreeCodes = []; | |
} | |
async initialize() { | |
try { | |
this.availableTreeCodes = await this.apiClient.loadTreeCodes(); | |
this.setupAutocomplete('localName', 'tree-suggestions'); | |
this.setupAutocomplete('scientificName', 'tree-suggestions'); | |
this.setupAutocomplete('commonName', 'tree-suggestions'); | |
this.setupAutocomplete('treeCode', 'tree-codes'); | |
} catch (error) { | |
console.error('Error initializing auto-suggestions:', error); | |
} | |
} | |
setupAutocomplete(fieldId, apiType) { | |
const input = document.getElementById(fieldId); | |
if (!input) return; | |
this.createAutocompleteContainer(input, fieldId); | |
this.attachEventListeners(input, fieldId, apiType); | |
} | |
createAutocompleteContainer(input, fieldId) { | |
if (input.parentElement.classList.contains('autocomplete-container')) return; | |
const container = document.createElement('div'); | |
container.className = 'autocomplete-container'; | |
input.parentNode.insertBefore(container, input); | |
container.appendChild(input); | |
const dropdown = document.createElement('div'); | |
dropdown.className = 'autocomplete-dropdown'; | |
dropdown.id = `${fieldId}-dropdown`; | |
container.appendChild(dropdown); | |
} | |
attachEventListeners(input, fieldId, apiType) { | |
input.addEventListener('input', (e) => this.handleInputChange(e, apiType)); | |
input.addEventListener('keydown', (e) => this.handleKeyDown(e, fieldId)); | |
input.addEventListener('blur', (e) => this.handleInputBlur(e, fieldId)); | |
input.addEventListener('focus', (e) => this.handleInputFocus(e, fieldId, apiType)); | |
} | |
async handleInputChange(event, apiType) { | |
const input = event.target; | |
const query = input.value.trim(); | |
const fieldId = input.id; | |
this.clearSearchTimeout(fieldId); | |
if (query.length < 2) { | |
this.hideDropdown(fieldId); | |
return; | |
} | |
this.showLoadingState(fieldId); | |
this.debounceSearch(fieldId, query, apiType); | |
} | |
clearSearchTimeout(fieldId) { | |
if (this.searchTimeouts[fieldId]) { | |
clearTimeout(this.searchTimeouts[fieldId]); | |
} | |
} | |
debounceSearch(fieldId, query, apiType) { | |
this.searchTimeouts[fieldId] = setTimeout(async () => { | |
try { | |
const suggestions = await this.fetchSuggestions(query, apiType, fieldId); | |
this.showSuggestions(fieldId, suggestions, query); | |
} catch (error) { | |
console.error('Error fetching suggestions:', error); | |
this.hideDropdown(fieldId); | |
} | |
}, 300); | |
} | |
async fetchSuggestions(query, apiType, fieldId) { | |
if (apiType === 'tree-codes') { | |
return this.filterTreeCodes(query); | |
} else { | |
return this.searchTreeSuggestions(query, fieldId); | |
} | |
} | |
filterTreeCodes(query) { | |
return this.availableTreeCodes | |
.filter(code => code.toLowerCase().includes(query.toLowerCase())) | |
.slice(0, 10) | |
.map(code => ({ | |
primary: code, | |
secondary: 'Tree Reference Code', | |
type: 'code' | |
})); | |
} | |
async searchTreeSuggestions(query, fieldId) { | |
const suggestions = await this.apiClient.searchTreeSuggestions(query, 10); | |
return suggestions.map(suggestion => ({ | |
primary: this.getPrimaryText(suggestion, fieldId), | |
secondary: this.getSecondaryText(suggestion, fieldId), | |
badges: this.getBadges(suggestion), | |
data: suggestion | |
})); | |
} | |
getPrimaryText(suggestion, fieldId) { | |
const fieldMap = { | |
'localName': suggestion.local_name, | |
'scientificName': suggestion.scientific_name, | |
'commonName': suggestion.common_name | |
}; | |
return fieldMap[fieldId] || | |
suggestion.local_name || | |
suggestion.scientific_name || | |
suggestion.common_name; | |
} | |
getSecondaryText(suggestion, fieldId) { | |
const parts = []; | |
if (fieldId !== 'localName' && suggestion.local_name) { | |
parts.push(`Local: ${suggestion.local_name}`); | |
} | |
if (fieldId !== 'scientificName' && suggestion.scientific_name) { | |
parts.push(`Scientific: ${suggestion.scientific_name}`); | |
} | |
if (fieldId !== 'commonName' && suggestion.common_name) { | |
parts.push(`Common: ${suggestion.common_name}`); | |
} | |
if (suggestion.tree_code) { | |
parts.push(`Code: ${suggestion.tree_code}`); | |
} | |
return parts.join(' • '); | |
} | |
getBadges(suggestion) { | |
const badges = []; | |
if (suggestion.tree_code) { | |
badges.push(suggestion.tree_code); | |
} | |
if (suggestion.fruiting_season) { | |
badges.push(`Season: ${suggestion.fruiting_season}`); | |
} | |
return badges; | |
} | |
showLoadingState(fieldId) { | |
const dropdown = document.getElementById(`${fieldId}-dropdown`); | |
if (!dropdown) return; | |
dropdown.innerHTML = '<div class="autocomplete-loading">Searching...</div>'; | |
dropdown.style.display = 'block'; | |
this.activeDropdowns.add(fieldId); | |
} | |
showSuggestions(fieldId, suggestions, query) { | |
const dropdown = document.getElementById(`${fieldId}-dropdown`); | |
if (!dropdown) return; | |
if (suggestions.length === 0) { | |
dropdown.innerHTML = '<div class="autocomplete-no-results">No matching suggestions found</div>'; | |
dropdown.style.display = 'block'; | |
this.activeDropdowns.add(fieldId); | |
return; | |
} | |
const html = this.buildSuggestionsHTML(suggestions, query, fieldId); | |
dropdown.innerHTML = html; | |
dropdown.style.display = 'block'; | |
this.activeDropdowns.add(fieldId); | |
this.selectedIndex = -1; | |
this.attachSuggestionClickHandlers(dropdown, suggestions); | |
} | |
buildSuggestionsHTML(suggestions, query, fieldId) { | |
return suggestions.map((suggestion, index) => ` | |
<div class="autocomplete-item" data-index="${index}" data-field="${fieldId}"> | |
<div class="autocomplete-primary">${this.highlightMatch(suggestion.primary, query)}</div> | |
${suggestion.secondary ? `<div class="autocomplete-secondary">${suggestion.secondary}</div>` : ''} | |
${this.buildBadgesHTML(suggestion.badges)} | |
</div> | |
`).join(''); | |
} | |
buildBadgesHTML(badges) { | |
if (!badges || badges.length === 0) return ''; | |
const badgeElements = badges.map(badge => `<span class="autocomplete-badge">${badge}</span>`).join(''); | |
return `<div>${badgeElements}</div>`; | |
} | |
highlightMatch(text, query) { | |
if (!query || !text) return text; | |
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); | |
return text.replace(regex, '<strong>$1</strong>'); | |
} | |
attachSuggestionClickHandlers(dropdown, suggestions) { | |
dropdown.querySelectorAll('.autocomplete-item').forEach(item => { | |
item.addEventListener('mousedown', (e) => this.handleSuggestionClick(e, suggestions)); | |
}); | |
} | |
handleSuggestionClick(event, suggestions) { | |
event.preventDefault(); | |
const item = event.target.closest('.autocomplete-item'); | |
const index = parseInt(item.dataset.index); | |
const fieldId = item.dataset.field; | |
const suggestion = suggestions[index]; | |
this.applySuggestion(fieldId, suggestion); | |
this.hideDropdown(fieldId); | |
} | |
applySuggestion(fieldId, suggestion) { | |
const input = document.getElementById(fieldId); | |
if (suggestion.type === 'code') { | |
input.value = suggestion.primary; | |
} else { | |
this.applySuggestionData(input, fieldId, suggestion); | |
this.autoFillRelatedFields(suggestion.data, fieldId); | |
} | |
input.dispatchEvent(new Event('input', { bubbles: true })); | |
} | |
applySuggestionData(input, fieldId, suggestion) { | |
const data = suggestion.data; | |
const fieldValueMap = { | |
'localName': data.local_name, | |
'scientificName': data.scientific_name, | |
'commonName': data.common_name | |
}; | |
input.value = fieldValueMap[fieldId] || suggestion.primary; | |
} | |
autoFillRelatedFields(data, excludeFieldId) { | |
const fields = { | |
'localName': data.local_name, | |
'scientificName': data.scientific_name, | |
'commonName': data.common_name, | |
'treeCode': data.tree_code | |
}; | |
Object.entries(fields).forEach(([fieldId, value]) => { | |
if (fieldId !== excludeFieldId && value) { | |
this.fillEmptyField(fieldId, value); | |
} | |
}); | |
} | |
fillEmptyField(fieldId, value) { | |
const input = document.getElementById(fieldId); | |
if (input && !input.value.trim()) { | |
input.value = value; | |
this.uiManager.highlightAutoFilledField(fieldId); | |
} | |
} | |
handleKeyDown(event, fieldId) { | |
const dropdown = document.getElementById(`${fieldId}-dropdown`); | |
if (!dropdown || dropdown.style.display === 'none') return; | |
const items = dropdown.querySelectorAll('.autocomplete-item'); | |
if (items.length === 0) return; | |
switch (event.key) { | |
case 'ArrowDown': | |
event.preventDefault(); | |
this.selectedIndex = Math.min(this.selectedIndex + 1, items.length - 1); | |
this.updateHighlight(items); | |
break; | |
case 'ArrowUp': | |
event.preventDefault(); | |
this.selectedIndex = Math.max(this.selectedIndex - 1, -1); | |
this.updateHighlight(items); | |
break; | |
case 'Enter': | |
event.preventDefault(); | |
if (this.selectedIndex >= 0 && items[this.selectedIndex]) { | |
items[this.selectedIndex].click(); | |
} | |
break; | |
case 'Escape': | |
event.preventDefault(); | |
this.hideDropdown(fieldId); | |
break; | |
} | |
} | |
updateHighlight(items) { | |
items.forEach((item, index) => { | |
item.classList.toggle('highlighted', index === this.selectedIndex); | |
}); | |
} | |
handleInputBlur(event, fieldId) { | |
setTimeout(() => this.hideDropdown(fieldId), 150); | |
} | |
handleInputFocus(event, fieldId, apiType) { | |
const input = event.target; | |
if (input.value.length >= 2) { | |
this.handleInputChange(event, apiType); | |
} | |
} | |
hideDropdown(fieldId) { | |
const dropdown = document.getElementById(`${fieldId}-dropdown`); | |
if (dropdown) { | |
dropdown.style.display = 'none'; | |
dropdown.innerHTML = ''; | |
this.activeDropdowns.delete(fieldId); | |
this.selectedIndex = -1; | |
} | |
} | |
hideAllDropdowns() { | |
this.activeDropdowns.forEach(fieldId => { | |
this.hideDropdown(fieldId); | |
}); | |
} | |
} | |