Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
Nuxt 4 + Supabase + Flightics API. Incluye búsqueda de vuelos, inspiraciones, watchlist, tracking de precios y mapa interactivo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
190 lines
5.2 KiB
Vue
190 lines
5.2 KiB
Vue
<script setup lang="ts">
|
|
const props = defineProps<{
|
|
modelValue: string
|
|
placeholder?: string
|
|
icon?: string
|
|
multiple?: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: string]
|
|
}>()
|
|
|
|
const { loadAirports, searchAirports } = useLocations()
|
|
const query = ref('')
|
|
const results = ref<ReturnType<typeof searchAirports>>([])
|
|
const open = ref(false)
|
|
const highlightIndex = ref(0)
|
|
|
|
// Selected codes as array
|
|
const selected = ref<string[]>([])
|
|
|
|
// Init from modelValue
|
|
function parseModelValue(v: string) {
|
|
return v.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
|
|
}
|
|
|
|
selected.value = parseModelValue(props.modelValue)
|
|
|
|
watch(() => props.modelValue, (v) => {
|
|
const parsed = parseModelValue(v)
|
|
if (parsed.join(',') !== selected.value.join(',')) {
|
|
selected.value = parsed
|
|
}
|
|
})
|
|
|
|
function emitValue() {
|
|
emit('update:modelValue', selected.value.join(','))
|
|
}
|
|
|
|
onMounted(() => loadAirports())
|
|
|
|
watch(query, (q) => {
|
|
results.value = searchAirports(q)
|
|
// Don't show already-selected airports
|
|
if (props.multiple) {
|
|
results.value = results.value.filter(a => !selected.value.includes(a.iata))
|
|
}
|
|
open.value = results.value.length > 0
|
|
highlightIndex.value = 0
|
|
})
|
|
|
|
function select(airport: (typeof results.value)[0]) {
|
|
if (props.multiple) {
|
|
if (!selected.value.includes(airport.iata)) {
|
|
selected.value.push(airport.iata)
|
|
}
|
|
query.value = ''
|
|
} else {
|
|
selected.value = [airport.iata]
|
|
query.value = airport.iata
|
|
}
|
|
emitValue()
|
|
open.value = false
|
|
results.value = []
|
|
}
|
|
|
|
function removeCode(code: string) {
|
|
selected.value = selected.value.filter(c => c !== code)
|
|
emitValue()
|
|
}
|
|
|
|
function onKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Backspace' && !query.value && props.multiple && selected.value.length) {
|
|
e.preventDefault()
|
|
selected.value.pop()
|
|
emitValue()
|
|
return
|
|
}
|
|
|
|
if (!open.value || results.value.length === 0) return
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
highlightIndex.value = Math.min(highlightIndex.value + 1, results.value.length - 1)
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
highlightIndex.value = Math.max(highlightIndex.value - 1, 0)
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
select(results.value[highlightIndex.value])
|
|
} else if (e.key === 'Escape') {
|
|
open.value = false
|
|
}
|
|
}
|
|
|
|
function onBlur() {
|
|
// If there's text typed and it looks like a code, add it
|
|
const val = query.value.trim().toUpperCase()
|
|
if (val && props.multiple) {
|
|
if (val.length >= 2) {
|
|
if (!selected.value.includes(val)) {
|
|
selected.value.push(val)
|
|
emitValue()
|
|
}
|
|
}
|
|
query.value = ''
|
|
} else if (val && !props.multiple) {
|
|
selected.value = [val]
|
|
query.value = val
|
|
emitValue()
|
|
}
|
|
setTimeout(() => { open.value = false; results.value = [] }, 150)
|
|
}
|
|
|
|
// For single mode, keep query in sync
|
|
watch(selected, (codes) => {
|
|
if (!props.multiple && codes.length) {
|
|
query.value = codes[0]
|
|
}
|
|
}, { deep: true })
|
|
</script>
|
|
|
|
<template>
|
|
<div class="relative">
|
|
<!-- Multiple mode: badges + input -->
|
|
<div
|
|
v-if="multiple"
|
|
class="flex flex-wrap items-center gap-1 rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1.5 focus-within:ring-2 focus-within:ring-primary-500 transition-shadow"
|
|
>
|
|
<UBadge
|
|
v-for="code in selected"
|
|
:key="code"
|
|
:label="code"
|
|
color="primary"
|
|
variant="subtle"
|
|
size="sm"
|
|
class="cursor-pointer"
|
|
@click="removeCode(code)"
|
|
>
|
|
<template #trailing>
|
|
<UIcon name="i-lucide-x" class="text-xs" />
|
|
</template>
|
|
</UBadge>
|
|
<input
|
|
v-model="query"
|
|
:placeholder="selected.length ? '' : placeholder || 'Buscar aeropuerto...'"
|
|
class="flex-1 min-w-24 bg-transparent outline-none text-sm py-0.5"
|
|
autocomplete="off"
|
|
@focus="results = searchAirports(query); open = results.length > 0"
|
|
@blur="onBlur"
|
|
@keydown="onKeydown"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Single mode -->
|
|
<UInput
|
|
v-else
|
|
v-model="query"
|
|
:placeholder="placeholder || 'Buscar aeropuerto...'"
|
|
:icon="icon || 'i-lucide-plane'"
|
|
autocomplete="off"
|
|
@focus="results = searchAirports(query); open = results.length > 0"
|
|
@blur="onBlur"
|
|
@keydown="onKeydown"
|
|
/>
|
|
|
|
<!-- Dropdown -->
|
|
<div
|
|
v-if="open && results.length"
|
|
class="absolute z-50 mt-1 w-full bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg max-h-60 overflow-y-auto"
|
|
>
|
|
<button
|
|
v-for="(apt, i) in results"
|
|
:key="apt.iata"
|
|
type="button"
|
|
class="w-full text-left px-3 py-2 flex items-center justify-between text-sm"
|
|
:class="i === highlightIndex ? 'bg-primary-50 dark:bg-primary-900/20' : 'hover:bg-neutral-100 dark:hover:bg-neutral-800'"
|
|
@mousedown.prevent="select(apt)"
|
|
@mouseenter="highlightIndex = i"
|
|
>
|
|
<span>
|
|
<span class="font-semibold">{{ apt.iata }}</span>
|
|
<span class="text-muted ml-2">{{ apt.name }}</span>
|
|
</span>
|
|
<span v-if="apt.city_name" class="text-xs text-muted">{{ apt.city_name }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|