Ajout des vérifications des types dans les formulaires (adresses, email, telephone)
This commit is contained in:
232
assets/js/address-autocomplete.js
Normal file
232
assets/js/address-autocomplete.js
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 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 };
|
||||
})();
|
||||
@@ -340,6 +340,10 @@
|
||||
document.querySelector('#edit-agent-profile-pta-comment').value = row.dataset.commentairePta || '';
|
||||
document.querySelector('#edit-agent-profile-structure').value = row.dataset.structureId || '';
|
||||
toggleDiplomaField(document.querySelector('#edit-agent-profile-qualified'), document.querySelector('#edit-agent-profile-diploma'));
|
||||
// Les champs viennent d'être remplis par programme, ce qui n'émet aucun
|
||||
// événement de saisie : sans ce rappel, les notes de format resteraient
|
||||
// celles de l'agent précédemment ouvert.
|
||||
PTA.modules.fieldFormat?.refresh?.();
|
||||
|
||||
const message = document.querySelector('#agent-profile-message');
|
||||
if (message) {
|
||||
|
||||
136
assets/js/field-format.js
Normal file
136
assets/js/field-format.js
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Contrôle de format à la saisie (`PTA.modules.fieldFormat`), page Agents.
|
||||
*
|
||||
* Vérifie l'adresse e-mail et le numéro de téléphone pendant la frappe, dans le
|
||||
* formulaire de création comme dans la fiche de modification.
|
||||
*
|
||||
* COPIE ASSUMÉE DES RÈGLES SERVEUR (App\Core\ContactFormat) : le projet n'a ni
|
||||
* bundler ni gabarit partagé PHP/JS, la duplication est le prix du retour
|
||||
* immédiat. Elle ne déplace PAS la décision — le serveur revérifie et refuse en
|
||||
* 422 quoi qu'affiche l'interface. Toute modification de ContactFormat doit être
|
||||
* reportée ici ; l'inverse ne suffit jamais.
|
||||
*
|
||||
* Le contrôle passe par `setCustomValidity()` plutôt que par un attribut
|
||||
* `pattern` : le numéro est comparé APRÈS retrait des séparateurs, ce qu'une
|
||||
* expression HTML ne sait pas faire. Le champ invalide bloque donc l'envoi du
|
||||
* formulaire par la validation native du navigateur, sans que les modules de
|
||||
* formulaire aient à s'en occuper.
|
||||
*
|
||||
* Les deux champs restent FACULTATIFS : vide vaut valide.
|
||||
*/
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const PTA = window.PTA;
|
||||
if (!PTA) throw new Error('PTA core doit être chargé avant field-format.js');
|
||||
|
||||
/** Miroir de ContactFormat::EMAIL_PATTERN. */
|
||||
const EMAIL_PATTERN = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*\.[A-Za-z]{2,}$/;
|
||||
/** Miroir de ContactFormat::PHONE_FR_PATTERN. */
|
||||
const PHONE_FR_PATTERN = /^(?:\+33|0033|0)[1-9]\d{8}$/;
|
||||
/** Miroir de ContactFormat::PHONE_INTERNATIONAL_PATTERN. */
|
||||
const PHONE_INTERNATIONAL_PATTERN = /^\+[1-9]\d{7,14}$/;
|
||||
/** Miroir de ContactFormat::PHONE_SEPARATORS. */
|
||||
const PHONE_SEPARATORS = /[\s.\-/()\u00A0\u202F]/g;
|
||||
|
||||
/** Champs déjà branchés, pour pouvoir les réévaluer sur demande. */
|
||||
const bound = new Map();
|
||||
|
||||
const RULES = {
|
||||
email: {
|
||||
test: (value) => EMAIL_PATTERN.test(value),
|
||||
invalide: 'Adresse e-mail invalide. Format attendu : prenom.nom@exemple.fr',
|
||||
valide: 'Adresse e-mail au bon format.',
|
||||
},
|
||||
phone: {
|
||||
test: (value) => {
|
||||
const compact = value.replace(PHONE_SEPARATORS, '');
|
||||
return PHONE_FR_PATTERN.test(compact) || PHONE_INTERNATIONAL_PATTERN.test(compact);
|
||||
},
|
||||
invalide: 'Numéro de téléphone invalide. Format attendu : 06 12 34 56 78 ou +33 6 12 34 56 78.',
|
||||
valide: 'Numéro de téléphone au bon format.',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Branche un champ sur sa règle.
|
||||
* L'état est recalculé à chaque frappe, mais n'est AFFICHÉ en erreur qu'une
|
||||
* fois le champ quitté ou l'envoi tenté : signaler « invalide » au deuxième
|
||||
* caractère tapé serait juste et insupportable.
|
||||
*/
|
||||
function attach(field) {
|
||||
if (bound.has(field)) return;
|
||||
const rule = RULES[field.dataset.format];
|
||||
if (!rule) return;
|
||||
|
||||
const status = field.parentElement?.querySelector('[data-format-status]')
|
||||
|| field.closest('.field')?.querySelector('[data-format-status]');
|
||||
let touched = false;
|
||||
|
||||
function evaluate() {
|
||||
const value = field.value.trim();
|
||||
// Champ facultatif : vide est valide, et rien n'est affiché.
|
||||
const empty = value === '';
|
||||
const ok = empty || rule.test(value);
|
||||
|
||||
field.setCustomValidity(ok ? '' : rule.invalide);
|
||||
field.classList.toggle('field-invalid', !ok && touched);
|
||||
field.classList.toggle('field-valid', ok && !empty);
|
||||
|
||||
if (!status) return;
|
||||
if (empty) {
|
||||
status.textContent = '';
|
||||
status.className = 'field-format-status';
|
||||
} else if (ok) {
|
||||
status.textContent = rule.valide;
|
||||
status.className = 'field-format-status field-format-ok';
|
||||
} else if (touched) {
|
||||
status.textContent = rule.invalide;
|
||||
status.className = 'field-format-status field-format-error';
|
||||
} else {
|
||||
status.textContent = '';
|
||||
status.className = 'field-format-status';
|
||||
}
|
||||
}
|
||||
|
||||
bound.set(field, () => {
|
||||
touched = false;
|
||||
evaluate();
|
||||
});
|
||||
|
||||
field.addEventListener('input', evaluate);
|
||||
field.addEventListener('blur', () => {
|
||||
touched = true;
|
||||
evaluate();
|
||||
});
|
||||
// Envoi refusé par la validation native : le champ fautif s'explique.
|
||||
field.addEventListener('invalid', () => {
|
||||
touched = true;
|
||||
evaluate();
|
||||
});
|
||||
// Après un form.reset(), aucun événement de saisie n'est émis : sans
|
||||
// cela la note du champ resterait celle de l'agent précédent.
|
||||
field.form?.addEventListener('reset', () => {
|
||||
window.setTimeout(() => {
|
||||
touched = false;
|
||||
evaluate();
|
||||
}, 0);
|
||||
});
|
||||
evaluate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Branche les champs porteurs de `data-format` et remet à zéro l'état de
|
||||
* ceux déjà branchés.
|
||||
*
|
||||
* Rappelable, et RAPPELÉ : la fiche de modification est remplie par
|
||||
* programme (module `administration`), ce qui n'émet aucun événement de
|
||||
* saisie — sans ce rappel, la note afficherait l'état de l'agent précédent.
|
||||
*/
|
||||
function init() {
|
||||
document.querySelectorAll('[data-format]').forEach(attach);
|
||||
bound.forEach((reset) => reset());
|
||||
}
|
||||
|
||||
PTA.modules.fieldFormat = { init, refresh: init };
|
||||
})();
|
||||
108
assets/style.css
108
assets/style.css
@@ -3286,3 +3286,111 @@ textarea:disabled {
|
||||
.dsi-token-row { flex-direction: column; align-items: flex-start; }
|
||||
.dsi-pagination { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
V7.25 - Autocomplétion d'adresse (Base Adresse Nationale)
|
||||
============================================================ */
|
||||
.address-autocomplete {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.address-autocomplete textarea,
|
||||
.address-autocomplete input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* La liste flotte au-dessus du formulaire : elle ne doit pas décaler les
|
||||
champs suivants pendant la frappe. */
|
||||
.address-suggestions {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 16rem;
|
||||
margin: 4px 0 0;
|
||||
padding: 4px;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
border: 1px solid #cfd8dc;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 24px rgba(23, 32, 51, 0.14);
|
||||
}
|
||||
|
||||
.address-suggestions[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.address-suggestion {
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.address-suggestion strong,
|
||||
.address-suggestion small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.address-suggestion small {
|
||||
margin-top: 0.1rem;
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.address-suggestion:hover,
|
||||
.address-suggestion.is-active {
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
.address-autocomplete-status {
|
||||
margin: 0;
|
||||
min-height: 1.05rem;
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.address-status-ok {
|
||||
color: #1b7f3b;
|
||||
}
|
||||
|
||||
.address-status-warning {
|
||||
color: #8a5a00;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
V7.26 - Contrôle de format des coordonnées (e-mail, téléphone)
|
||||
============================================================ */
|
||||
.field-format-status {
|
||||
margin: 0;
|
||||
min-height: 1.05rem;
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.field-format-ok {
|
||||
color: #1b7f3b;
|
||||
}
|
||||
|
||||
.field-format-error {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
/* L'erreur n'est marquée qu'après que le champ a été quitté : le module retire
|
||||
la classe tant que la saisie est en cours. */
|
||||
input.field-invalid {
|
||||
border-color: #b42318;
|
||||
background: #fff7f6;
|
||||
}
|
||||
|
||||
input.field-valid {
|
||||
border-color: #1b7f3b;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user