Import initial : code en production sur Hetzner
Some checks are pending
CI / tests (push) Waiting to run
CI / deploiement (push) Blocked by required conditions

This commit is contained in:
Adam Belkacemi 2026-09-07 18:21:38 +00:00
commit cf6ee64e31
43 changed files with 14404 additions and 0 deletions

View file

@ -0,0 +1,133 @@
<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>