Spaces:
Runtime error
Runtime error
| /** | |
| * 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); | |
| }); | |
| } | |
| } | |