133 lines
3.5 KiB
Vue
133 lines
3.5 KiB
Vue
|
|
<script setup lang="ts">
|
||
|
|
import type { Place } from '#shared/types'
|
||
|
|
|
||
|
|
defineProps<{ label: string, id: string }>()
|
||
|
|
const model = defineModel<Place | null>({ default: null })
|
||
|
|
|
||
|
|
const query = ref('')
|
||
|
|
const results = ref<Place[]>([])
|
||
|
|
const open = ref(false)
|
||
|
|
const active = ref(-1)
|
||
|
|
|
||
|
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||
|
|
/** Empeche une ecriture programmatique de relancer une recherche. */
|
||
|
|
let skipNext = false
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Le champ doit refleter le modele meme quand il change de l'exterieur :
|
||
|
|
* inversion depart/arrivee, trajet d'exemple, restauration d'etat.
|
||
|
|
* Sans cela le texte affiche ment sur ce qui est reellement selectionne.
|
||
|
|
*/
|
||
|
|
watch(model, (m) => {
|
||
|
|
const label = m?.label ?? ''
|
||
|
|
if (label === query.value) return
|
||
|
|
skipNext = true
|
||
|
|
query.value = label
|
||
|
|
}, { immediate: true })
|
||
|
|
|
||
|
|
watch(query, (q) => {
|
||
|
|
if (skipNext) {
|
||
|
|
skipNext = false
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Toute frappe invalide la selection : on ne calcule jamais un trajet
|
||
|
|
// vers un lieu que l'utilisateur n'a pas explicitement confirme.
|
||
|
|
if (model.value && q !== model.value.label) model.value = null
|
||
|
|
|
||
|
|
clearTimeout(timer)
|
||
|
|
if (q.trim().length < 3) {
|
||
|
|
results.value = []
|
||
|
|
open.value = false
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
timer = setTimeout(async () => {
|
||
|
|
results.value = await $fetch<Place[]>('/api/geocode', { query: { q } })
|
||
|
|
open.value = results.value.length > 0
|
||
|
|
active.value = -1
|
||
|
|
}, 250)
|
||
|
|
})
|
||
|
|
|
||
|
|
function select(place: Place) {
|
||
|
|
model.value = place
|
||
|
|
skipNext = place.label !== query.value
|
||
|
|
query.value = place.label
|
||
|
|
open.value = false
|
||
|
|
active.value = -1
|
||
|
|
}
|
||
|
|
|
||
|
|
function onKeydown(e: KeyboardEvent) {
|
||
|
|
if (!open.value) return
|
||
|
|
|
||
|
|
if (e.key === 'ArrowDown') {
|
||
|
|
e.preventDefault()
|
||
|
|
active.value = (active.value + 1) % results.value.length
|
||
|
|
}
|
||
|
|
else if (e.key === 'ArrowUp') {
|
||
|
|
e.preventDefault()
|
||
|
|
active.value = active.value <= 0 ? results.value.length - 1 : active.value - 1
|
||
|
|
}
|
||
|
|
else if (e.key === 'Enter' && active.value >= 0) {
|
||
|
|
e.preventDefault()
|
||
|
|
select(results.value[active.value]!)
|
||
|
|
}
|
||
|
|
else if (e.key === 'Escape') {
|
||
|
|
open.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Le delai laisse le mousedown de la liste s'executer avant la fermeture. */
|
||
|
|
function onBlur() {
|
||
|
|
setTimeout(() => { open.value = false }, 150)
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<div class="relative">
|
||
|
|
<label :for="id" class="text-xs font-medium uppercase tracking-widest text-ink-soft">
|
||
|
|
{{ label }}
|
||
|
|
</label>
|
||
|
|
|
||
|
|
<input
|
||
|
|
:id="id"
|
||
|
|
v-model="query"
|
||
|
|
type="text"
|
||
|
|
role="combobox"
|
||
|
|
autocomplete="off"
|
||
|
|
:aria-expanded="open"
|
||
|
|
:aria-controls="`${id}-list`"
|
||
|
|
:aria-activedescendant="active >= 0 ? `${id}-opt-${active}` : undefined"
|
||
|
|
placeholder="Ville, gare ou adresse"
|
||
|
|
class="mt-1 w-full border-b-2 bg-transparent py-2 text-lg outline-none"
|
||
|
|
:class="model ? 'border-bike' : 'border-ink'"
|
||
|
|
@keydown="onKeydown"
|
||
|
|
@focus="open = results.length > 0"
|
||
|
|
@blur="onBlur"
|
||
|
|
>
|
||
|
|
|
||
|
|
<ul
|
||
|
|
v-if="open"
|
||
|
|
:id="`${id}-list`"
|
||
|
|
role="listbox"
|
||
|
|
class="absolute z-20 mt-1 w-full border-2 border-ink bg-paper shadow-[4px_4px_0_0_var(--color-ink)]"
|
||
|
|
>
|
||
|
|
<li
|
||
|
|
v-for="(place, i) in results"
|
||
|
|
:id="`${id}-opt-${i}`"
|
||
|
|
:key="place.label"
|
||
|
|
role="option"
|
||
|
|
:aria-selected="i === active"
|
||
|
|
class="cursor-pointer px-3 py-2 text-sm"
|
||
|
|
:class="i === active ? 'bg-ink text-paper' : 'hover:bg-paper-2'"
|
||
|
|
@mousedown.prevent="select(place)"
|
||
|
|
>
|
||
|
|
{{ place.label }}
|
||
|
|
</li>
|
||
|
|
</ul>
|
||
|
|
|
||
|
|
<p class="sr-only" aria-live="polite">
|
||
|
|
{{ open ? `${results.length} lieux proposés` : '' }}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</template>
|