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.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 }