Files
pta/assets/js/address-autocomplete.js

233 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Autocomplétion d'adresse (`PTA.modules.addressAutocomplete`), page Agents.
*
* Branche chaque conteneur `[data-address-autocomplete]` sur l'API Base Adresse
* Nationale, relayée par api/search_address.php — le navigateur ne peut pas
* appeler la BAN directement, la CSP le lui interdit.
*
* PRINCIPE : la liste PROPOSE, elle n'impose pas. Le champ reste un texte libre
* et rien n'est bloqué à l'enregistrement : un lieu-dit ou une résidence absents
* de la BAN doivent rester saisissables. Un échec du service distant n'affiche
* qu'une note, jamais une erreur bloquante.
*
* Le module est générique : il ne connaît aucun identifiant de champ, seulement
* la structure de conteneur posée dans le partiel (textarea ou input + liste
* `[data-address-suggestions]` + note `[data-address-status]`). Il peut donc
* être réutilisé tel quel sur un autre écran portant un champ d'adresse.
*/
(() => {
'use strict';
const PTA = window.PTA;
if (!PTA) throw new Error('PTA core doit être chargé avant address-autocomplete.js');
const { utils } = PTA;
const { readJsonResponse, apiErrorMessage, escapeHtml } = utils;
/** Seuil aligné sur celui du serveur (AddressApiService::MIN_QUERY_LENGTH). */
const MIN_LENGTH = 3;
/** Attente après la dernière frappe : assez court pour suivre la saisie,
* assez long pour ne pas lancer une requête par caractère. */
const DEBOUNCE_MS = 300;
/** Une adresse trouvée est décrite par son numéro/voie puis son contexte. */
function suggestionMarkup(suggestion, index, activeIndex) {
const nom = suggestion.nom || suggestion.label;
const detail = [suggestion.code_postal, suggestion.commune]
.filter(Boolean)
.join(' ');
const contexte = suggestion.contexte ? ` — ${suggestion.contexte}` : '';
return `
<li class="address-suggestion${index === activeIndex ? ' is-active' : ''}"
role="option" id="address-suggestion-${index}"
aria-selected="${index === activeIndex ? 'true' : 'false'}"
data-index="${index}">
<strong>${escapeHtml(nom)}</strong>
<small>${escapeHtml(detail)}${escapeHtml(contexte)}</small>
</li>`;
}
/**
* Installe l'autocomplétion sur un conteneur.
* Chaque conteneur garde son propre état : deux champs d'adresse peuvent
* coexister sur la page (création et modification) sans interférer.
*/
function attach(container) {
if (container.dataset.addressBound === '1') return;
container.dataset.addressBound = '1';
const field = container.querySelector('textarea, input[type="text"]');
const list = container.querySelector('[data-address-suggestions]');
const status = container.querySelector('[data-address-status]');
if (!field || !list) return;
let suggestions = [];
let activeIndex = -1;
let timer = null;
/** Requête en vol, annulée dès qu'une frappe la rend obsolète : sans
* cela une réponse lente écraserait une réponse plus récente. */
let controller = null;
/** Dernière valeur retenue dans la liste, pour dire à l'utilisateur que
* son adresse est bien une adresse connue de la BAN. */
let validatedValue = '';
function setStatus(text = '', type = '') {
if (!status) return;
status.textContent = text;
status.className = `address-autocomplete-status${type ? ` address-status-${type}` : ''}`;
}
function close() {
list.hidden = true;
list.innerHTML = '';
suggestions = [];
activeIndex = -1;
field.setAttribute('aria-expanded', 'false');
field.removeAttribute('aria-activedescendant');
}
function render() {
if (suggestions.length === 0) {
close();
return;
}
list.innerHTML = suggestions
.map((suggestion, index) => suggestionMarkup(suggestion, index, activeIndex))
.join('');
list.hidden = false;
field.setAttribute('aria-expanded', 'true');
if (activeIndex >= 0) {
field.setAttribute('aria-activedescendant', `address-suggestion-${activeIndex}`);
} else {
field.removeAttribute('aria-activedescendant');
}
}
/** Retient une proposition : le champ prend l'adresse complète. */
function choose(index) {
const suggestion = suggestions[index];
if (!suggestion) return;
field.value = suggestion.label;
validatedValue = suggestion.label;
close();
setStatus('Adresse vérifiée auprès de la Base Adresse Nationale.', 'ok');
field.focus();
// Les autres modules lisent la valeur au moment de l'envoi, mais un
// champ rempli par programme n'émet aucun événement : on le fait,
// pour ne pas dépendre de la façon dont ils écoutent.
field.dispatchEvent(new Event('input', { bubbles: true }));
field.dispatchEvent(new Event('change', { bubbles: true }));
}
function move(step) {
if (suggestions.length === 0) return;
activeIndex = (activeIndex + step + suggestions.length) % suggestions.length;
render();
}
async function search(query) {
controller?.abort();
controller = new AbortController();
setStatus('Recherche d’adresses…');
try {
const params = new URLSearchParams({ q: query, limit: '5' });
const response = await fetch(`api/search_address.php?${params.toString()}`, {
headers: { Accept: 'application/json' },
signal: controller.signal,
});
const data = await readJsonResponse(response);
if (!response.ok) {
throw new Error(apiErrorMessage(data, 'Recherche d’adresse indisponible.'));
}
suggestions = Array.isArray(data.suggestions) ? data.suggestions : [];
activeIndex = -1;
render();
setStatus(
suggestions.length === 0
? 'Aucune adresse trouvée : la saisie manuelle reste possible.'
: 'Choisissez une adresse dans la liste, ou continuez la saisie.'
);
} catch (error) {
if (error.name === 'AbortError') return;
close();
// Jamais bloquant : le service peut être injoignable sans que
// cela empêche d'enregistrer une fiche agent.
setStatus(error.message, 'warning');
}
}
function scheduleSearch() {
window.clearTimeout(timer);
const query = field.value.trim();
if (query.length < MIN_LENGTH) {
controller?.abort();
close();
setStatus('');
return;
}
if (query === validatedValue) {
close();
setStatus('Adresse vérifiée auprès de la Base Adresse Nationale.', 'ok');
return;
}
timer = window.setTimeout(() => search(query), DEBOUNCE_MS);
}
field.setAttribute('autocomplete', 'off');
field.setAttribute('role', 'combobox');
field.setAttribute('aria-autocomplete', 'list');
field.setAttribute('aria-expanded', 'false');
if (list.id) field.setAttribute('aria-controls', list.id);
field.addEventListener('input', scheduleSearch);
field.addEventListener('keydown', (event) => {
if (list.hidden) {
// Flèche bas sur un champ déjà rempli : rouvrir la recherche
// sans obliger à retoucher le texte.
if (event.key === 'ArrowDown' && field.value.trim().length >= MIN_LENGTH) {
event.preventDefault();
search(field.value.trim());
}
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
move(1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
move(-1);
} else if (event.key === 'Enter' && activeIndex >= 0) {
// Entrée ne vaut sélection QUE si une proposition est surlignée :
// sinon le champ est une zone de texte, la frappe lui appartient.
event.preventDefault();
choose(activeIndex);
} else if (event.key === 'Escape') {
close();
}
});
// mousedown plutôt que click : le clic ferait perdre le focus au champ
// avant que la sélection ne soit traitée.
list.addEventListener('mousedown', (event) => {
const option = event.target.closest('.address-suggestion');
if (!option) return;
event.preventDefault();
choose(Number(option.dataset.index));
});
field.addEventListener('blur', () => {
window.setTimeout(close, 120);
});
}
/**
* Branche tous les conteneurs présents. Rappelable : la fiche de
* modification est dans une fenêtre déjà rendue, mais un écran futur
* pourrait injecter son champ après coup.
*/
function init() {
document.querySelectorAll('[data-address-autocomplete]').forEach(attach);
}
PTA.modules.addressAutocomplete = { init, refresh: init };
})();