/** * 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 `
  • ${escapeHtml(nom)} ${escapeHtml(detail)}${escapeHtml(contexte)}
  • `; } /** * 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 }; })();