233 lines
9.7 KiB
JavaScript
233 lines
9.7 KiB
JavaScript
/**
|
||
* 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 };
|
||
})();
|