import { t, i18n } from '../i18n.js' import { escapeHTML } from '../utils/helpers.js' const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search' const DEBOUNCE_MS = 400 const MIN_QUERY_LENGTH = 2 class LocationPicker extends HTMLElement { constructor() { super() this.suggestions = [] this.selectedLocation = null this.loading = false this.debounceTimer = null this.isOpen = false } static get observedAttributes() { return ['value', 'placeholder', 'country'] } connectedCallback() { this.render() this.setupEventListeners() this.unsubscribe = i18n.subscribe(() => this.render()) } disconnectedCallback() { if (this.unsubscribe) this.unsubscribe() if (this.debounceTimer) clearTimeout(this.debounceTimer) document.removeEventListener('click', this.handleOutsideClick) } get value() { return this.selectedLocation } set value(location) { this.selectedLocation = location const input = this.querySelector('.location-input') if (input && location) { input.value = this.formatLocationDisplay(location) } } render() { const placeholder = this.getAttribute('placeholder') || t('create.locationPlaceholder') || 'Stadt, PLZ oder Adresse' const currentValue = this.selectedLocation ? this.formatLocationDisplay(this.selectedLocation) : '' this.innerHTML = /* html */`
${this.loading ? '' : ''}
${this.renderSuggestions()}
` this.setupEventListeners() } renderSuggestions() { if (this.suggestions.length === 0) return '' return this.suggestions.map((loc, index) => /* html */`
📍
${escapeHTML(loc.displayName)} ${escapeHTML(loc.detail)}
`).join('') } setupEventListeners() { const input = this.querySelector('.location-input') const dropdown = this.querySelector('.suggestions-dropdown') input?.addEventListener('input', (e) => this.handleInput(e.target.value)) input?.addEventListener('focus', () => this.handleFocus()) input?.addEventListener('keydown', (e) => this.handleKeydown(e)) dropdown?.querySelectorAll('.suggestion-item').forEach(item => { item.addEventListener('click', () => { const index = parseInt(item.dataset.index) this.selectSuggestion(index) }) }) this.handleOutsideClick = (e) => { if (!this.contains(e.target)) { this.closeSuggestions() } } document.addEventListener('click', this.handleOutsideClick) } handleInput(query) { if (this.debounceTimer) clearTimeout(this.debounceTimer) if (query.length < MIN_QUERY_LENGTH) { this.suggestions = [] this.isOpen = false this.updateDropdown() return } this.debounceTimer = setTimeout(() => { this.searchLocations(query) }, DEBOUNCE_MS) } handleFocus() { if (this.suggestions.length > 0) { this.isOpen = true this.updateDropdown() } } handleKeydown(e) { if (e.key === 'Escape') { this.closeSuggestions() } else if (e.key === 'ArrowDown' && this.isOpen) { e.preventDefault() this.focusNextSuggestion(1) } else if (e.key === 'ArrowUp' && this.isOpen) { e.preventDefault() this.focusNextSuggestion(-1) } else if (e.key === 'Enter' && this.isOpen) { e.preventDefault() const focused = this.querySelector('.suggestion-item.focused') if (focused) { const index = parseInt(focused.dataset.index) this.selectSuggestion(index) } } } focusNextSuggestion(direction) { const items = this.querySelectorAll('.suggestion-item') if (items.length === 0) return const currentFocused = this.querySelector('.suggestion-item.focused') let nextIndex = 0 if (currentFocused) { const currentIndex = parseInt(currentFocused.dataset.index) nextIndex = currentIndex + direction if (nextIndex < 0) nextIndex = items.length - 1 if (nextIndex >= items.length) nextIndex = 0 currentFocused.classList.remove('focused') } items[nextIndex]?.classList.add('focused') } async searchLocations(query) { this.loading = true this.updateLoadingState() try { const countryCode = this.getAttribute('country') || '' const params = new URLSearchParams({ q: query, format: 'json', addressdetails: '1', limit: '6', 'accept-language': document.documentElement.lang || 'de' }) if (countryCode) { params.set('countrycodes', countryCode) } const response = await fetch(`${NOMINATIM_URL}?${params}`, { headers: { 'User-Agent': 'kashilo.com/1.0' } }) if (!response.ok) throw new Error('Nominatim request failed') const results = await response.json() this.suggestions = results.map(r => this.parseNominatimResult(r)) this.isOpen = this.suggestions.length > 0 } catch (error) { console.error('Location search failed:', error) this.suggestions = [] this.isOpen = false } finally { this.loading = false this.updateDropdown() this.updateLoadingState() } } parseNominatimResult(result) { const addr = result.address || {} const city = addr.city || addr.town || addr.village || addr.municipality || addr.county || '' const postalCode = addr.postcode || '' const country = addr.country || '' const countryCode = addr.country_code?.toUpperCase() || '' let displayName = city if (postalCode && city) { displayName = `${postalCode} ${city}` } else if (postalCode) { displayName = postalCode } const detail = country const region = addr.state || addr.county || '' return { displayName, detail, name: city, postalCode, region, country, countryCode, lat: parseFloat(result.lat), lng: parseFloat(result.lon), raw: result } } selectSuggestion(index) { const location = this.suggestions[index] if (!location) return this.selectedLocation = location const input = this.querySelector('.location-input') if (input) { input.value = this.formatLocationDisplay(location) } this.closeSuggestions() this.dispatchEvent(new CustomEvent('location-select', { bubbles: true, detail: location })) } formatLocationDisplay(location) { if (!location) return '' const parts = [] if (location.postalCode) parts.push(location.postalCode) if (location.name) parts.push(location.name) if (location.countryCode) parts.push(location.countryCode) return parts.join(', ') } closeSuggestions() { this.isOpen = false this.updateDropdown() } updateDropdown() { const dropdown = this.querySelector('.suggestions-dropdown') if (dropdown) { dropdown.className = `suggestions-dropdown ${this.isOpen && this.suggestions.length > 0 ? 'open' : ''}` dropdown.innerHTML = this.renderSuggestions() dropdown.querySelectorAll('.suggestion-item').forEach(item => { item.addEventListener('click', () => { const index = parseInt(item.dataset.index) this.selectSuggestion(index) }) }) } } updateLoadingState() { const wrapper = this.querySelector('.input-wrapper') if (!wrapper) return let indicator = wrapper.querySelector('.loading-indicator') if (this.loading && !indicator) { indicator = document.createElement('span') indicator.className = 'loading-indicator' wrapper.appendChild(indicator) } else if (!this.loading && indicator) { indicator.remove() } } getLocationData() { if (!this.selectedLocation) return null return { name: this.selectedLocation.name, postal_code: this.selectedLocation.postalCode, country: this.selectedLocation.countryCode, lat: this.selectedLocation.lat, lng: this.selectedLocation.lng } } } customElements.define('location-picker', LocationPicker) const style = document.createElement('style') style.textContent = /* css */` location-picker { display: block; position: relative; } location-picker .location-picker { position: relative; } location-picker .input-wrapper { position: relative; } location-picker .location-input { width: 100%; padding-right: 36px; } location-picker .loading-indicator { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; border: 2px solid var(--color-border); border-top-color: var(--color-primary); border-radius: 50%; animation: location-spinner 0.8s linear infinite; } @keyframes location-spinner { to { transform: translateY(-50%) rotate(360deg); } } location-picker .suggestions-dropdown { position: absolute; top: 100%; left: 0; right: 0; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); z-index: 100; max-height: 280px; overflow-y: auto; display: none; margin-top: 4px; } location-picker .suggestions-dropdown.open { display: block; } location-picker .suggestion-item { display: flex; align-items: center; gap: var(--space-sm); padding: var(--space-sm) var(--space-md); cursor: pointer; transition: background-color var(--transition-fast); } location-picker .suggestion-item:hover, location-picker .suggestion-item.focused { background: var(--color-bg-secondary); } location-picker .suggestion-icon { flex-shrink: 0; font-size: 1rem; } location-picker .suggestion-text { display: flex; flex-direction: column; min-width: 0; } location-picker .suggestion-name { font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } location-picker .suggestion-detail { font-size: var(--font-size-xs); color: var(--color-text-muted); } ` document.head.appendChild(style) export { LocationPicker }