324 lines
9.2 KiB
Vue
324 lines
9.2 KiB
Vue
<template>
|
|
<div class="relative">
|
|
<label v-if="label" class="block text-sm font-medium text-slate-300 mb-1">{{ label }}</label>
|
|
<div class="relative">
|
|
<input
|
|
ref="inputRef"
|
|
:value="displayText"
|
|
@input="onInput"
|
|
@focus="onFocus"
|
|
@blur="onBlur"
|
|
@keydown.down.prevent="highlightNext"
|
|
@keydown.up.prevent="highlightPrev"
|
|
@keydown.enter.prevent="selectHighlighted"
|
|
@keydown.escape="closeDropdown"
|
|
:placeholder="placeholder"
|
|
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
|
|
/>
|
|
<!-- Loading spinner -->
|
|
<svg
|
|
v-if="searching"
|
|
class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-slate-400"
|
|
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
<!-- Clear button -->
|
|
<button
|
|
v-if="selectedItem && !searching"
|
|
@click="clearSelection"
|
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white transition"
|
|
type="button"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Dropdown -->
|
|
<div
|
|
v-if="showDropdown && filteredItems.length > 0"
|
|
class="absolute z-50 mt-1 w-full bg-slate-800 border border-slate-600 rounded-lg shadow-xl max-h-60 overflow-y-auto"
|
|
>
|
|
<button
|
|
v-for="(item, index) in filteredItems"
|
|
:key="item.id"
|
|
@mousedown.prevent="selectItem(item)"
|
|
class="w-full text-left px-3 py-2 text-sm transition flex items-center gap-2"
|
|
:class="index === highlightedIndex
|
|
? 'bg-indigo-500/20 text-white'
|
|
: 'text-slate-300 hover:bg-slate-700'"
|
|
>
|
|
<span class="font-mono text-indigo-400">{{ item.zip_code }}</span>
|
|
<span>{{ item.city }}</span>
|
|
<span class="text-xs text-slate-500 ml-auto">{{ item.country_code }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- No results -->
|
|
<div
|
|
v-if="showDropdown && query.length >= minChars && filteredItems.length === 0 && !searching"
|
|
class="absolute z-50 mt-1 w-full bg-slate-800 border border-slate-600 rounded-lg shadow-xl p-3 text-sm text-slate-400"
|
|
>
|
|
Nincs találat
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
/**
|
|
* SmartLocationInput.vue
|
|
* ======================
|
|
*
|
|
* Autocomplete/combobox component for selecting a city/zip code pair.
|
|
* Calls the backend GET /api/v1/locations/autocomplete endpoint.
|
|
*
|
|
* Usage:
|
|
* <SmartLocationInput v-model="selected" />
|
|
*
|
|
* The v-model emits the selected GeoPostalCode object:
|
|
* { id: number, zip_code: string, city: string, country_code: string }
|
|
*
|
|
* Props:
|
|
* modelValue: The currently selected location (null or the object)
|
|
* label: Optional label text above the input
|
|
* placeholder: Placeholder text
|
|
* minChars: Minimum characters before triggering search (default: 2)
|
|
* countryCode: Filter by country code (default: 'HU')
|
|
* debounceMs: Debounce delay in ms (default: 300)
|
|
*/
|
|
|
|
import { ref, computed, watch, nextTick } from 'vue'
|
|
|
|
interface LocationItem {
|
|
id: number
|
|
zip_code: string
|
|
city: string
|
|
country_code: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<{
|
|
modelValue?: LocationItem | null
|
|
label?: string
|
|
placeholder?: string
|
|
minChars?: number
|
|
countryCode?: string
|
|
debounceMs?: number
|
|
}>(), {
|
|
modelValue: null,
|
|
label: '',
|
|
placeholder: 'Kezdj el gépelni (irányítószám vagy város)...',
|
|
minChars: 2,
|
|
countryCode: 'HU',
|
|
debounceMs: 300,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: LocationItem | null]
|
|
'select': [value: LocationItem]
|
|
}>()
|
|
|
|
// ── State ──────────────────────────────────────────────
|
|
const inputRef = ref<HTMLInputElement | null>(null)
|
|
const query = ref('')
|
|
const selectedItem = ref<LocationItem | null>(props.modelValue)
|
|
const items = ref<LocationItem[]>([])
|
|
const searching = ref(false)
|
|
const showDropdown = ref(false)
|
|
const highlightedIndex = ref(0)
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
// ── Computed ───────────────────────────────────────────
|
|
|
|
/**
|
|
* The display text shown in the input.
|
|
* If a location is selected, shows "zip_code city".
|
|
* Otherwise shows the raw query text.
|
|
*/
|
|
const displayText = computed(() => {
|
|
if (selectedItem.value) {
|
|
return `${selectedItem.value.zip_code} ${selectedItem.value.city}`
|
|
}
|
|
return query.value
|
|
})
|
|
|
|
/**
|
|
* Filtered items based on the current query.
|
|
* The backend already filters, but we keep client-side filtering
|
|
* for instant feedback when the user types more after results load.
|
|
*/
|
|
const filteredItems = computed(() => {
|
|
if (!query.value || query.value.length < props.minChars) return []
|
|
const q = query.value.toLowerCase()
|
|
return items.value.filter(
|
|
(item) =>
|
|
item.zip_code.toLowerCase().includes(q) ||
|
|
item.city.toLowerCase().includes(q)
|
|
)
|
|
})
|
|
|
|
// ── Watch modelValue from parent ───────────────────────
|
|
watch(
|
|
() => props.modelValue,
|
|
(val) => {
|
|
selectedItem.value = val
|
|
}
|
|
)
|
|
|
|
// ── Methods ────────────────────────────────────────────
|
|
|
|
/**
|
|
* Handle input events from the text field.
|
|
* Starts debounced search when the user types.
|
|
*/
|
|
function onInput(event: Event) {
|
|
const target = event.target as HTMLInputElement
|
|
query.value = target.value
|
|
|
|
// If the user is typing, clear the selected item
|
|
if (selectedItem.value) {
|
|
selectedItem.value = null
|
|
emit('update:modelValue', null)
|
|
}
|
|
|
|
// Debounced search
|
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
if (query.value.length >= props.minChars) {
|
|
debounceTimer = setTimeout(() => {
|
|
searchLocations(query.value)
|
|
}, props.debounceMs)
|
|
} else {
|
|
items.value = []
|
|
showDropdown.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Call the backend autocomplete API.
|
|
*/
|
|
async function searchLocations(searchQuery: string) {
|
|
if (!searchQuery || searchQuery.length < props.minChars) return
|
|
searching.value = true
|
|
try {
|
|
const res = await $fetch('/api/v1/locations/autocomplete', {
|
|
params: {
|
|
q: searchQuery,
|
|
country_code: props.countryCode,
|
|
limit: 15,
|
|
},
|
|
headers: getHeaders(),
|
|
})
|
|
items.value = (res as LocationItem[]) || []
|
|
highlightedIndex.value = 0
|
|
showDropdown.value = items.value.length > 0
|
|
} catch (err: any) {
|
|
console.error('Location autocomplete error:', err)
|
|
items.value = []
|
|
} finally {
|
|
searching.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get auth headers for API calls.
|
|
*/
|
|
function getHeaders(): Record<string, string> {
|
|
const token = useCookie('access_token').value
|
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
|
return headers
|
|
}
|
|
|
|
/**
|
|
* Select an item from the dropdown.
|
|
*/
|
|
function selectItem(item: LocationItem) {
|
|
selectedItem.value = item
|
|
query.value = ''
|
|
showDropdown.value = false
|
|
emit('update:modelValue', item)
|
|
emit('select', item)
|
|
}
|
|
|
|
/**
|
|
* Clear the current selection.
|
|
*/
|
|
function clearSelection() {
|
|
selectedItem.value = null
|
|
query.value = ''
|
|
items.value = []
|
|
showDropdown.value = false
|
|
emit('update:modelValue', null)
|
|
nextTick(() => {
|
|
inputRef.value?.focus()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Close the dropdown.
|
|
*/
|
|
function closeDropdown() {
|
|
showDropdown.value = false
|
|
}
|
|
|
|
/**
|
|
* Handle focus - show dropdown if there are results.
|
|
*/
|
|
function onFocus() {
|
|
if (filteredItems.value.length > 0) {
|
|
showDropdown.value = true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle blur - close dropdown with a delay to allow click.
|
|
*/
|
|
function onBlur() {
|
|
// Delay to allow mousedown on dropdown items
|
|
setTimeout(() => {
|
|
showDropdown.value = false
|
|
}, 200)
|
|
}
|
|
|
|
/**
|
|
* Highlight the next item in the dropdown.
|
|
*/
|
|
function highlightNext() {
|
|
if (filteredItems.value.length > 0) {
|
|
highlightedIndex.value = (highlightedIndex.value + 1) % filteredItems.value.length
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Highlight the previous item in the dropdown.
|
|
*/
|
|
function highlightPrev() {
|
|
if (filteredItems.value.length > 0) {
|
|
highlightedIndex.value = (highlightedIndex.value - 1 + filteredItems.value.length) % filteredItems.value.length
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Select the currently highlighted item.
|
|
*/
|
|
function selectHighlighted() {
|
|
if (filteredItems.value.length > 0 && highlightedIndex.value >= 0) {
|
|
selectItem(filteredItems.value[highlightedIndex.value])
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.overflow-y-auto::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
.overflow-y-auto::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
.overflow-y-auto::-webkit-scrollbar-thumb {
|
|
background-color: rgba(100, 116, 139, 0.5);
|
|
border-radius: 3px;
|
|
}
|
|
</style>
|