geoproject-app/public/site/plugins/map-editor/src/components/map/GeocodeSearch.vue
2026-01-28 16:33:48 +01:00

356 lines
7.7 KiB
Vue

<template>
<div class="geocode-search">
<div class="search-input-wrapper">
<input
ref="searchInput"
v-model="searchQuery"
type="text"
class="search-input"
placeholder="Rechercher une adresse..."
@input="handleInput"
@keydown.escape="clearSearch"
@keydown.enter.prevent="selectFirstResult"
@keydown.down.prevent="navigateResults(1)"
@keydown.up.prevent="navigateResults(-1)"
/>
<button
v-if="searchQuery"
type="button"
class="clear-button"
@click="clearSearch"
title="Effacer"
>
<k-icon type="cancel" />
</button>
<div v-if="isLoading" class="search-spinner">
<div class="spinner-icon"></div>
</div>
</div>
<!-- Results dropdown -->
<div v-if="showResults" class="results-dropdown">
<div v-if="error" class="error-message">
<k-icon type="alert" />
<span>{{ error }}</span>
</div>
<div v-else-if="results.length === 0 && !isLoading" class="no-results">
Aucun résultat trouvé
</div>
<div v-else class="results-list">
<div
v-for="(result, index) in results"
:key="result.id"
class="result-item"
:class="{ active: index === selectedIndex }"
@click="selectResult(result)"
@mouseenter="selectedIndex = index"
>
<div class="result-icon">
<k-icon type="pin" />
</div>
<div class="result-content">
<div class="result-name">{{ result.displayName }}</div>
<div class="result-coords">
{{ result.lat.toFixed(6) }}, {{ result.lon.toFixed(6) }}
</div>
</div>
</div>
</div>
<div v-if="results.length > 0" class="results-footer">
<small>Powered by OpenStreetMap Nominatim</small>
</div>
</div>
</div>
</template>
<script>
import { ref, watch } from 'vue';
import { geocode } from '../../utils/api/nominatim.js';
import { debounce } from '../../utils/helpers/debounce.js';
import { DEBOUNCE_DELAYS } from '../../utils/constants.js';
export default {
emits: ['select-location', 'center-map'],
setup(props, { emit }) {
const searchInput = ref(null);
const searchQuery = ref('');
const results = ref([]);
const isLoading = ref(false);
const error = ref(null);
const showResults = ref(false);
const selectedIndex = ref(-1);
// Debounced search function
const debouncedSearch = debounce(async (query) => {
if (!query || query.trim().length < 3) {
results.value = [];
showResults.value = false;
isLoading.value = false;
return;
}
isLoading.value = true;
error.value = null;
try {
const data = await geocode(query);
results.value = data;
showResults.value = true;
selectedIndex.value = -1;
} catch (err) {
error.value = 'Erreur lors de la recherche. Veuillez réessayer.';
results.value = [];
} finally {
isLoading.value = false;
}
}, DEBOUNCE_DELAYS.GEOCODING);
function handleInput() {
debouncedSearch(searchQuery.value);
}
function selectResult(result) {
emit('select-location', {
lat: result.lat,
lon: result.lon,
displayName: result.displayName,
});
searchQuery.value = result.displayName;
showResults.value = false;
}
function selectFirstResult() {
if (results.value.length > 0) {
selectResult(results.value[0]);
}
}
function navigateResults(direction) {
if (!showResults.value || results.value.length === 0) {
return;
}
selectedIndex.value += direction;
if (selectedIndex.value < 0) {
selectedIndex.value = results.value.length - 1;
} else if (selectedIndex.value >= results.value.length) {
selectedIndex.value = 0;
}
}
function clearSearch() {
searchQuery.value = '';
results.value = [];
showResults.value = false;
error.value = null;
selectedIndex.value = -1;
}
function focus() {
if (searchInput.value) {
searchInput.value.focus();
}
}
// Close dropdown when clicking outside
function handleClickOutside(event) {
if (!event.target.closest('.geocode-search')) {
showResults.value = false;
}
}
// Add/remove click outside listener
watch(showResults, (newValue) => {
if (newValue) {
setTimeout(() => {
document.addEventListener('click', handleClickOutside);
}, 100);
} else {
document.removeEventListener('click', handleClickOutside);
}
});
return {
searchInput,
searchQuery,
results,
isLoading,
error,
showResults,
selectedIndex,
handleInput,
selectResult,
selectFirstResult,
navigateResults,
clearSearch,
focus,
};
},
};
</script>
<style>
.geocode-search {
position: relative;
width: 100%;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.search-input {
width: 100%;
padding: 0.625rem 2.5rem 0.625rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--rounded-sm);
font-size: 0.875rem;
background: var(--color-white);
transition: border-color 0.2s;
color: #000;
}
.search-input:focus {
outline: none;
border-color: var(--color-focus);
box-shadow: 0 0 0 2px var(--color-focus-outline);
}
.clear-button {
position: absolute;
right: 0.5rem;
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: var(--color-text-light);
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.clear-button:hover {
color: var(--color-text);
}
.search-spinner {
position: absolute;
right: 0.75rem;
pointer-events: none;
}
.spinner-icon {
width: 16px;
height: 16px;
border: 2px solid var(--color-border);
border-top-color: var(--color-focus);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.results-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background: var(--color-white);
border: 1px solid var(--color-border);
border-radius: var(--rounded-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-height: 300px;
overflow-y: auto;
z-index: 1000;
}
.error-message {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
color: var(--color-negative);
font-size: 0.875rem;
}
.no-results {
padding: 1rem;
text-align: center;
color: var(--color-text-light);
font-size: 0.875rem;
}
.results-list {
display: flex;
flex-direction: column;
}
.result-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
cursor: pointer;
transition: background-color 0.2s;
border-bottom: 1px solid var(--color-background);
}
.result-item:last-child {
border-bottom: none;
}
.result-item:hover,
.result-item.active {
background: var(--color-focus-outline);
}
.result-icon {
flex-shrink: 0;
color: var(--color-text-light);
padding-top: 0.125rem;
}
.result-content {
flex: 1;
min-width: 0;
}
.result-name {
font-size: 0.875rem;
margin-bottom: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #000;
}
.result-coords {
font-size: 0.75rem;
color: var(--color-text-light);
font-family: monospace;
}
.results-footer {
padding: 0.5rem;
border-top: 1px solid var(--color-border);
text-align: center;
}
.results-footer small {
font-size: 0.7rem;
color: var(--color-text-light);
}
</style>