1/** 2 * Front Emploi â JavaScript minimal (vanilla, ZÃRO dépendance). 3 * 4 * Ãco-conception : pas de lib (ni Swiper ni jQuery), ~quelques Ko, chargé en 5 * footer uniquement sur les pages emploi. Progressive enhancement : sans JS, 6 * les champs restent des inputs normaux et le carrousel un simple scroll 7 * horizontal â rien n'est cassé. 8 * 9 * - Carousel : scroll-snap natif + flèches + points (accessible, clavier). 10 * - Combobox : autocomplete sur données PRÃCHARGÃES (window.LPE_EMPLOI.suggest), 11 * construites côté serveur à partir des offres publiées (aucune requête AJAX). 12 * 13 * @package lesprospetiteenfance 14 */ 15(function () { 16 "use strict"; 17 18 var reduceMotion = 19 window.matchMedia && 20 window.matchMedia("(prefers-reduced-motion: reduce)").matches; 21 var uid = 0; 22 23 /** Normalise (minuscules + sans accents) pour une recherche tolérante. */ 24 function norm(s) { 25 return (s || "") 26 .toString() 27 .toLowerCase() 28 .normalize("NFD") 29 .replace(/[Ì-ͯ]/g, ""); 30 } 31 32 /* =================================================================== 33 CAROUSEL â [data-carousel] { [data-carousel-track], -prev, -next, -dots } 34 =================================================================== */ 35 function initCarousel(root) { 36 var track = root.querySelector("[data-carousel-track]"); 37 if (!track) { 38 return; 39 } 40 var pager = root.querySelector("[data-carousel-pager]"); 41 var items = Array.prototype.slice.call(track.children); 42 if (!items.length) { 43 return; 44 } 45 46 root.classList.add("is-enhanced"); 47 48 var snapTimer; 49 50 function step() { 51 var cs = getComputedStyle(track); 52 var gap = parseInt(cs.columnGap || cs.gap || "0", 10) || 0; 53 return items[0].getBoundingClientRect().width + gap; 54 } 55 function perView() { 56 return Math.max(1, Math.round(track.clientWidth / step())); 57 } 58 function pageCount() { 59 return Math.max(1, Math.ceil(items.length / perView())); 60 } 61 function currentPage() { 62 return Math.min( 63 pageCount() - 1, 64 Math.round(track.scrollLeft / (step() * perView())), 65 ); 66 } 67 function goToPage(p) { 68 p = Math.max(0, Math.min(pageCount() - 1, p)); 69 var max = track.scrollWidth - track.clientWidth; 70 var left = Math.min(max, p * step() * perView()); 71 // scroll-snap mandatory peut interrompre un scrollTo « smooth » : on 72 // relâche le snap le temps de l'animation, puis on le rétablit (re-snap). 73 track.style.scrollSnapType = "none"; 74 track.scrollTo({ 75 left: left, 76 behavior: reduceMotion ? "auto" : "smooth", 77 }); 78 clearTimeout(snapTimer); 79 snapTimer = setTimeout( 80 function () { 81 track.style.scrollSnapType = ""; 82 }, 83 reduceMotion ? 0 : 450, 84 );
85 setActivePage(p); // surbrillance immédiate, sans attendre la fin du scroll 86 } 87 88 /* Pagination du carrousel = NOTRE composant .pagination : boîtes 89 numérotées (1..N) + « â » suivant, injectées par le JS. */ 90 function buildPager() { 91 if (!pager) { 92 return; 93 } 94 var n = pageCount(); 95 pager.innerHTML = ""; 96 if (n <= 1) { 97 pager.hidden = true; 98 return; 99 } 100 pager.hidden = false; 101 var i; 102 for (i = 0; i < n; i++) { 103 var b = document.createElement("button"); 104 b.type = "button"; 105 b.className = "item"; 106 b.textContent = i + 1; 107 b.setAttribute( 108 "aria-label", 109 "Aller au groupe " + (i + 1) + " sur " + n, 110 ); 111 (function (idx) { 112 b.addEventListener("click", function () { 113 goToPage(idx); 114 }); 115 })(i); 116 pager.appendChild(b); 117 } 118 var nx = document.createElement("button"); 119 nx.type = "button"; 120 nx.className = "item is-next"; 121 nx.innerHTML = "→"; 122 nx.setAttribute("aria-label", "Groupe suivant"); 123 nx.addEventListener("click", function () { 124 goToPage(Math.min(pageCount() - 1, currentPage() + 1)); 125 }); 126 pager.appendChild(nx); 127 } 128 129 function setActivePage(cp) { 130 if (!pager || pager.hidden) { 131 return; 132 } 133 var nums = pager.querySelectorAll(".item:not(.is-next)"); 134 Array.prototype.forEach.call(nums, function (it, i) { 135 it.classList.toggle("is-active", i === cp); 136 if (i === cp) { 137 it.setAttribute("aria-current", "true"); 138 } else { 139 it.removeAttribute("aria-current"); 140 } 141 }); 142 var nx = pager.querySelector(".is-next"); 143 if (nx) { 144 nx.disabled = cp >= pageCount() - 1; 145 } 146 } 147 148 function sync() {
149 setActivePage(currentPage()); 150 } 151 152 var raf; 153 function onScroll() { 154 if (raf) { 155 cancelAnimationFrame(raf); 156 } 157 raf = requestAnimationFrame(sync); 158 } 159 track.addEventListener("scroll", onScroll, { passive: true }); 160 161 var rt; 162 window.addEventListener("resize", function () { 163 clearTimeout(rt); 164 rt = setTimeout(function () { 165 buildPager(); 166 sync(); 167 }, 150); 168 }); 169 170 buildPager(); 171 sync(); 172 } 173 174 /* =================================================================== 175 CLEAR â croix d'effacement partagée par les comboboxes. 176 Injectée en JS (aucun markup à modifier). Visible dès qu'une valeur est 177 présente ; mousedown préventé pour ne pas déclencher le blur de l'input 178 (qui fermerait la liste / lancerait la validation stricte). 179 =================================================================== */ 180 function initClearButton(root, input, onClear) { 181 var btn = document.createElement("button"); 182 btn.type = "button"; 183 btn.className = "combobox-clear"; 184 btn.setAttribute("aria-label", "Effacer la saisie"); 185 btn.textContent = "Ã"; 186 root.appendChild(btn); 187 188 function sync() { 189 btn.hidden = input.value === ""; 190 } 191 btn.addEventListener("mousedown", function (e) { 192 e.preventDefault(); // garde le focus sur l'input 193 }); 194 btn.addEventListener("click", function () { 195 input.value = ""; 196 sync(); 197 input.focus(); 198 if (onClear) { 199 onClear(); 200 } 201 input.dispatchEvent(new Event("change", { bubbles: true })); 202 }); 203 input.addEventListener("input", sync); 204 input.addEventListener("change", sync); // choose() ne déclenche que change 205 sync(); 206 } 207 208 /* =================================================================== 209 COMBOBOX â autocomplete (WAI-ARIA), données préchargées. 210 Markup : <div class="combobox" data-combobox data-source="villes"> 211 <label class="field-search"><input ...></label> 212 <ul class="combobox-list" data-combobox-list></ul> 213 </div> 214 =================================================================== */ 215 function initCombobox(root) { 216 var input = root.querySelector("input"); 217 var list = root.querySelector("[data-combobox-list]"); 218 if (!input || !list) { 219 return; 220 } 221 var bag = window.LPE_EMPLOI && window.LPE_EMPLOI.suggest; 222 var sourceKey = root.getAttribute("data-source"); 223 var source = (bag && bag[sourceKey]) || []; 224 if (!source.length) { 225 return; 226 } 227 // Affichage par ordre alphabétique (français, accents gérés) pour TOUTES les 228 // sources SAUF « pays » (ordre dédié : francophones en tête, cf. 229 // lpe_emploi_pays). Copie triée â ne mute pas le bag partagé. 230 if (sourceKey !== "pays") { 231 source = source.slice().sort(function (a, b) { 232 return String(a).localeCompare(String(b), "fr", { 233 sensitivity: "base", 234 }); 235 }); 236 } 237 // Mode STRICT (opt-in [data-strict], ex. champ Pays) : la valeur soumise 238 // DOIT appartenir au référentiel (sinon validité native en échec). Les 239 // champs de recherche restent libres (pas de data-strict). 240 var strict = root.hasAttribute("data-strict"); 241 242 var listId = list.id || "lpe-cbx-" + ++uid; 243 list.id = listId; 244 list.setAttribute("role", "listbox"); 245 list.hidden = true; 246 247 input.setAttribute("role", "combobox"); 248 input.setAttribute("aria-autocomplete", "list"); 249 input.setAttribute("aria-expanded", "false"); 250 input.setAttribute("aria-controls", listId); 251 input.setAttribute("autocomplete", "off"); 252 253 // Plus de plafond rigide : le menu défile (CSS .combobox-list, 280px), donc 254 // la liste COMPLÃTE est visible (référentiels bornés : métiers, paysâ¦). 255 // data-empty-limit : champ VIDE â n'afficher que les N premières entrées 256 // (ex. pays = short-list francophone, le reste se révèle à la frappe). 257 var EMPTY_LIMIT = parseInt(root.getAttribute("data-empty-limit") || "0", 10) || 0; 258 var options = []; 259 var active = -1; 260 // « pristine » : l'input porte une valeur CHOISIE (ou préremplie) non encore 261 // éditée â au focus on montre TOUTE la liste (pas filtrée par cette valeur), 262 // la valeur courante surlignée et scrollée, pour parcourir les voisines. 263 var pristine = false;
264 265 function close() { 266 list.hidden = true; 267 input.setAttribute("aria-expanded", "false"); 268 input.removeAttribute("aria-activedescendant"); 269 active = -1; 270 } 271 272 function open() { 273 if (list.children.length) { 274 list.hidden = false; 275 input.setAttribute("aria-expanded", "true"); 276 } 277 } 278 279 function setActive(i) { 280 options.forEach(function (o, idx) { 281 o.classList.toggle("is-active", idx === i); 282 o.setAttribute("aria-selected", idx === i ? "true" : "false"); 283 }); 284 active = i; 285 if (i >= 0 && options[i]) { 286 input.setAttribute("aria-activedescendant", options[i].id); 287 options[i].scrollIntoView({ block: "nearest" }); 288 } else { 289 input.removeAttribute("aria-activedescendant"); 290 } 291 } 292 293 function choose(value) { 294 input.value = value; 295 pristine = true; // valeur choisie : le prochain focus rouvre la liste complète 296 if (strict) { 297 input.setCustomValidity(""); 298 } 299 close(); 300 input.dispatchEvent(new Event("change", { bubbles: true })); 301 } 302 303 /** Libellé canonique du référentiel pour une saisie (insensible casse/accents), sinon null. */ 304 function canonical(v) { 305 var t = norm(v); 306 for (var i = 0; i < source.length; i++) { 307 if (norm(source[i]) === t) { 308 return source[i]; 309 } 310 } 311 return null; 312 } 313 314 /** 315 * Affiche / retire un message d'erreur INLINE sur le champ (même rendu que 316 * les erreurs serveur : .field.has-error > .error-msg). Sans ça, le 317 * setCustomValidity() ne se voit qu'à la soumission (bulle native) â le 318 * champ était rouge sans explication. On ne touche pas à une éventuelle 319 * erreur serveur déjà présente. 320 */ 321 function setFieldError(msg) { 322 var field = input.closest(".field"); 323 if (!field) { 324 return; 325 } 326 var el = field.querySelector(".error-msg.js-combobox"); 327 if (msg) { 328 field.classList.add("has-error"); 329 if (!el) { 330 el = document.createElement("p"); 331 el.className = "error-msg js-combobox"; 332 field.appendChild(el); 333 } 334 el.textContent = msg; 335 } else if (el) { 336 el.remove(); 337 if (!field.querySelector(".error-msg")) { 338 field.classList.remove("has-error"); 339 } 340 } 341 } 342 343 /** 344 * Mode strict : canonicalise une saisie reconnue (ex. « belgique » â 345 * « Belgique ») ou invalide une valeur hors référentiel (ex. « Fr »). Vide 346 * autorisé (â défaut côté serveur). Le message s'affiche inline au blur. 347 */ 348 function enforce() { 349 if (!strict) { 350 return; 351 } 352 var v = input.value.trim(); 353 if (v === "") { 354 input.setCustomValidity(""); 355 setFieldError(null); 356 return; 357 } 358 var c = canonical(v); 359 if (c) { 360 input.value = c; 361 pristine = true; // valeur reconnue : se comporte comme une sélection 362 input.setCustomValidity(""); 363 setFieldError(null); 364 } else { 365 var msg = 366 root.getAttribute("data-strict-msg") || 367 "Choisissez une valeur dans la liste."; 368 input.setCustomValidity(msg); 369 setFieldError(msg); 370 } 371 } 372 373 function render() { 374 var q = norm(input.value); 375 var matches; 376 if (pristine) { 377 // valeur choisie non éditée : liste COMPLÃTE (parc
377ourir les voisines) 378 matches = source.slice(); 379 } else if (q === "") { 380 // champ vide : liste complète, ou short-list si data-empty-limit (pays) 381 matches = 382 EMPTY_LIMIT > 0 ? source.slice(0, EMPTY_LIMIT) : source.slice(); 383 } else { 384 matches = source.filter(function (v) { 385 return norm(v).indexOf(q) !== -1; 386 }); 387 } 388 389 var current = norm(input.value); // valeur actuellement retenue (marquée â) 390 list.innerHTML = ""; 391 options = []; 392 var currentEl = null; 393 matches.forEach(function (v, i) { 394 var li = document.createElement("li"); 395 li.className = "combobox-option"; 396 li.id = listId + "-opt-" + i; 397 li.setAttribute("role", "option"); 398 li.setAttribute("aria-selected", "false"); 399 li.textContent = v; 400 if (current !== "" && norm(v) === current) { 401 li.classList.add("is-current"); 402 currentEl = li; 403 } 404 li.addEventListener("mousedown", function (e) { 405 e.preventDefault(); // garde le focus, évite le blur avant le click 406 choose(v); 407 }); 408 list.appendChild(li); 409 options.push(li); 410 }); 411 if (matches.length) { 412 open(); 413 if (currentEl) { 414 currentEl.scrollIntoView({ block: "nearest" }); // amène la valeur choisie dans la vue 415 } 416 } else { 417 close(); 418 }
419 setActive(-1); 420 } 421 422 // Valeur préremplie (édition) figurant au référentiel â démarre en « pristine » 423 // pour que le 1er focus affiche la liste complète scrollée sur cette valeur. 424 if (canonical(input.value)) { 425 pristine = true; 426 } 427 428 input.addEventListener("input", function () { 429 pristine = false; // l'utilisateur édite : on repasse en mode filtre 430 if (strict) { 431 input.setCustomValidity(""); // ne pas harceler pendant la frappe 432 } 433 render(); 434 }); 435 input.addEventListener("focus", render); 436 input.addEventListener("blur", function () { 437 setTimeout(function () { 438 close(); 439 enforce(); // canonicalise une saisie reconnue / invalide hors liste 440 }, 120); 441 }); 442 input.addEventListener("keydown", function (e) { 443 if (e.key === "ArrowDown") { 444 e.preventDefault(); 445 if (list.hidden) { 446 render(); 447 } 448 if (options.length) {
449 setActive((active + 1) % options.length); 450 } 451 } else if (e.key === "ArrowUp") { 452 e.preventDefault(); 453 if (options.length) { 454 setActive((active - 1 + options.length) % options.length); 455 } 456 } else if (e.key === "Enter") { 457 if (!list.hidden && active >= 0 && options[active]) { 458 e.preventDefault(); 459 choose(options[active].textContent); 460 } 461 } else if (e.key === "Tab") { 462 // Motif APG : Tab valide l'option surlignée puis laisse le focus 463 // passer au champ suivant (la liste n'est PAS tabulable â la 464 // navigation dedans se fait aux flèches). Pas de preventDefault. 465 if (!list.hidden && active >= 0 && options[active]) { 466 choose(options[active].textContent); 467 } 468 } else if (e.key === "Escape") { 469 close(); 470 } 471 }); 472 473 initClearButton(root, input, function () { 474 pristine = false; 475 if (strict) { 476 input.setCustomValidity(""); 477 setFieldError(null); 478 } 479 render(); // champ vidé + focus conservé : rouvre la liste complète 480 }); 481 } 482 483 /* =================================================================== 484 PLACES â autocomplete localisation (villes + CP + départements). 485 Markup : <div class="combobox places" data-places> 486 <label class="field-search"><input name="localite"></label> 487 <ul class="combobox-list" data-combobox-list></ul> 488 </div> 489 Index villeâCP chargé en LAZY depuis window.LPE_EMPLOI.placesUrl 490 (places.json, same-origin) à la 1re interaction, partagé entre tous les 491 champs et mis en cache. Les départements (codeânom) sont inlinés 492 (window.LPE_EMPLOI.departements). Le filtrage serveur (LPE_Emploi_Geo) 493 réinterprète la valeur soumise : ville â LIKE, CP/département â REGEXP ancré. 494 Sans JS : input texte normal, validation/filtrage serveur tolérant. 495 =================================================================== */ 496 var placesPromise = null; // index partagé (chargé une fois) 497 498 /** Charge et prépare l'index des villes (parallel arrays pour un filtre rapide). */ 499 function loadPlaces() { 500 if (placesPromise) { 501 return placesPromise; 502 } 503 var url = (window.LPE_EMPLOI && LPE_EMPLOI.placesUrl) || ""; 504 if (!url) { 505 placesPromise = Promise.resolve(null); 506 return placesPromise; 507 } 508 placesPromise = fetch(url) 509 .then(function (r) { 510 return r.ok ? r.json() : null; 511 }) 512 .then(function (data) { 513 var rows = (data && data.v) || []; 514 var names = new Array(rows.length); 515 var norms = new Array(rows.length); 516 var cps = new Array(rows.length); 517 for (var i = 0; i < rows.length; i++) { 518 names[i] = rows[i][0]; 519 norms[i] = norm(rows[i][0]); 520 cps[i] = rows[i][1]; 521 } 522 return { names: names, norms: norms, cps: cps }; 523 }) 524 .catch(function () { 525 return null; 526 }); 527 return placesPromise; 528 } 529 530 /** Liste des départements [code, nom, normNom] depuis l'inline localisé. */ 531 function departements() { 532 var src = (window.LPE_EMPLOI && LPE_EMPLOI.departements) || {}; 533 return Object.keys(src).map(function (code) { 534 return [code, src[code], norm(src[code])]; 535 }); 536 } 537 538 /** Code département affichable depuis un CP (DOM en 97x, métropole en 2 chiffres). */ 539 function deptFromCp(cp) { 540 cp = String(cp); 541 if (cp.indexOf("97") === 0 || cp.indexOf("98") === 0) { 542 return cp.slice(0, 3); 543 } 544 return cp.slice(0, 2); 545 } 546 547 function initPlaces(root) { 548 var input = root.querySelector("input"); 549 var list = root.querySelector("[data-combobox-list]"); 550 if (!input || !list) { 551 return; 552 } 553 var deps = departements(); 554 var index = null; // rempli au 1er chargement (lazy) 555 556 var listId = list.id || "lpe-places-" + ++uid; 557 list.id = listId; 558 list.setAttribute("role", "listbox"); 559 list.hidden = true; 560 input.setAttribute("role", "combobox"); 561 input.setAttribute("aria-autocomplete", "list"); 562 input.setAttribute("aria-expanded", "false"); 563 input.setAttribute("aria-controls", listId); 564 input.setAttribute("autocomplete", "off"); 565 566 var MAX = 8; 567 var SCAN = 60; // borne de scan (on s'arrête une fois assez de candidats) 568 var options = []; 569 var active = -1; 570 571 function close() { 572 list.hidden = true; 573 input.setAttribute("aria-expanded", "false"); 574 input.removeAttribute("aria-activedescendant"); 575 active = -1; 576 } 577 function open() { 578 if (list.children.length) { 579 list.hidden = false;
580 input.setAttribute("aria-expanded", "true"); 581 } 582 } 583 function setActive(i) { 584 options.forEach(function (o, idx) { 585 o.classList.toggle("is-active", idx === i); 586 o.setAttribute("aria-selected", idx === i ? "true" : "false"); 587 }); 588 active = i; 589 if (i >= 0 && options[i]) { 590 input.setAttribute("aria-activedescendant", options[i].id); 591 options[i].scrollIntoView({ block: "nearest" }); 592 } else { 593 input.removeAttribute("aria-activedescendant"); 594 } 595 } 596 function choose(value) { 597 input.value = value; 598 close(); 599 input.dispatchEvent(new Event("change", { bubbles: true })); 600 } 601 602 /** Construit la liste de suggestions {value,main,type} pour la requête. */ 603 function suggest(raw) { 604 var q = norm(raw); 605 if (q === "") { 606 return []; 607 } 608 var digits = raw.replace(/\D/g, ""); 609 var startsDigit = /^[0-9]/.test(raw.trim()); 610 var out = []; 611 612 // Départements : par code (préfixe) ou par nom (sous-chaîne). 613 deps.forEach(function (d) { 614 if (out.length >= SCAN) { 615 return; 616 } 617 var byCode = 618 digits !== "" && d[0].toLowerCase().indexOf(digits) === 0; 619 var byName = q.length >= 2 && d[2].indexOf(q) !== -1; 620 if (byCode || byName) { 621 out.push({ 622 value: d[1] + " (" + d[0] + ")", 623 main: d[1], 624 type: "Département · " + d[0], 625 }); 626 } 627 }); 628 629 if (index) { 630 // Codes postaux (si la saisie contient des chiffres) : préfixe de CP. 631 if (digits.length >= 2) { 632 for ( 633 var i = 0; 634 i < index.cps.length && out.length < SCAN; 635 i++ 636 ) { 637 var cps = index.cps[i]; 638 for (var j = 0; j < cps.length; j++) { 639 if (cps[j].indexOf(digits) === 0) { 640 out.push({ 641 value: cps[j], 642 main: cps[j], 643 type: index.names[i], 644 }); 645 break; // une entrée par ville suffit pour ce préfixe 646 } 647 } 648 } 649 } 650 // Villes : sous-chaîne sur le nom normalisé. 651 if (!startsDigit && q.length >= 2) { 652 for ( 653 var k = 0; 654 k < index.norms.length && out.length < SCAN; 655 k++ 656 ) { 657 if (index.norms[k].indexOf(q) !== -1) { 658 var dep = deptFromCp(index.cps[k][0]); 659 out.push({ 660 value: index.names[k], 661 main: index.names[k], 662 type: dep, 663 }); 664 } 665 } 666 } 667 } 668 669 // Priorité aux correspondances qui COMMENCENT par la requête. 670 out.sort(function (a, b) { 671 return startsRank(a, q, digits) - startsRank(b, q, digits); 672 }); 673 return out.slice(0, MAX); 674 } 675 676 /** 0 si la suggestion commence par la requête, 1 sinon (tri stable conservé sinon). */ 677 function startsRank(s, q, digits) { 678 var m = norm(s.main); 679 if ( 680 digits !== "" && 681 /^[0-9]/.test(s.main) && 682 s.main.indexOf(digits) === 0 683 ) { 684 return 0; 685 } 686 return m.indexOf(q) === 0 ? 0 : 1; 687 } 688 689 function render() { 690 var items = suggest(input.value); 691 list.innerHTML = ""; 692 options = [];
693 items.forEach(function (it, i) { 694 var li = document.createElement("li"); 695 li.className = "combobox-option"; 696 li.id = listId + "-opt-" + i; 697 li.setAttribute("role", "option"); 698 li.setAttribute("aria-selected", "false"); 699 li.dataset.value = it.value; 700 var main = document.createElement("span"); 701 main.className = "opt-main"; 702 main.textContent = it.main; 703 li.appendChild(main); 704 if (it.type) { 705 var type = document.createElement("span"); 706 type.className = "opt-type"; 707 type.textContent = it.type; 708 li.appendChild(type); 709 } 710 li.addEventListener("mousedown", function (e) { 711 e.preventDefault(); 712 choose(it.value); 713 }); 714 list.appendChild(li); 715 options.push(li); 716 }); 717 if (items.length) { 718 open(); 719 } else { 720 close(); 721 }
722 setActive(-1); 723 } 724 725 // 1re interaction : charge l'index puis (re)rend ; ensuite render direct. 726 function ensureThenRender() { 727 if (index) { 728 render(); 729 return; 730 } 731 loadPlaces().then(function (idx) { 732 index = idx; 733 if (document.activeElement === input) { 734 render(); 735 } 736 }); 737 } 738 739 input.addEventListener("input", ensureThenRender); 740 input.addEventListener("focus", ensureThenRender); 741 input.addEventListener("blur", function () { 742 setTimeout(close, 120); 743 }); 744 input.addEventListener("keydown", function (e) { 745 if (e.key === "ArrowDown") { 746 e.preventDefault(); 747 if (list.hidden) { 748 render(); 749 } 750 if (options.length) { 751 setActive((active + 1) % options.length); 752 } 753 } else if (e.key === "ArrowUp") { 754 e.preventDefault(); 755 if (options.length) {
756 setActive((active - 1 + options.length) % options.length); 757 } 758 } else if (e.key === "Enter") { 759 if (!list.hidden && active >= 0 && options[active]) { 760 e.preventDefault(); 761 choose(options[active].dataset.value); 762 } 763 } else if (e.key === "Tab") { 764 // Motif APG : Tab valide l'option surlignée avant de quitter le champ. 765 if (!list.hidden && active >= 0 && options[active]) { 766 choose(options[active].dataset.value); 767 } 768 } else if (e.key === "Escape") { 769 close(); 770 } 771 }); 772 773 initClearButton(root, input, close); // champ vidé : plus rien à suggérer 774 } 775 776 /* =================================================================== 777 SHARE â bouton de partage (Web Share API + repli presse-papiers) 778 =================================================================== */ 779 function initShare(btn) { 780 btn.addEventListener("click", function () { 781 var url = 782 btn.getAttribute("data-share-url") || window.location.href; 783 var title = btn.getAttribute("data-share-title") || document.title; 784 if (navigator.share) { 785 navigator 786 .share({ title: title, url: url }) 787 .catch(function () {}); 788 } else if (navigator.clipboard) { 789 navigator.clipboard 790 .writeText(url) 791 .then(function () { 792 btn.classList.add("is-copied"); 793 setTimeout(function () { 794 btn.classList.remove("is-copied"); 795 }, 1600); 796 }) 797 .catch(function () {}); 798 } 799 }); 800 } 801 802 /* =================================================================== 803 TRACKING â events Matomo (spec T3) par délégation. 804 Tout élément portant [data-mtm-event] pousse un event à son clic : 805 catégorie fixe « Emploi », action = data-mtm-event, nom = data-mtm-name. 806 No-op silencieux si Matomo n'est pas chargé (_paq absent). 807 =================================================================== */ 808 function initTracking() { 809 document.addEventListener("click", function (e) { 810 var el = e.target.closest ? e.target.closest("[data-mtm-event]") : null; 811 if (!el || !window._paq) { 812 return; 813 } 814 window._paq.push([ 815 "trackEvent", 816 "Emploi", 817 el.getAttribute("data-mtm-event"), 818 el.getAttribute("data-mtm-name") || "", 819 ]); 820 }); 821 } 822 823 /* =================================================================== 824 Init 825 =================================================================== */ 826 function ready(fn) { 827 if (document.readyState !== "loading") { 828 fn(); 829 } else { 830 document.addEventListener("DOMContentLoaded", fn); 831 } 832 } 833 834 /* =================================================================== 835 TAGSELECT â [data-tagselect] > select[multiple] 836 UI à tags retirables sur un <select multiple> natif (fallback sans JS). 837 [data-free] : saisie libre (Entrée crée l'option â ex. langues). 838 =================================================================== */ 839 function initTagselect(root) { 840 var select = root.querySelector("select"); 841 if (!select) { 842 return; 843 } 844 var free = root.hasAttribute("data-free"); 845 var placeholder = root.getAttribute("data-placeholder") || ""; 846 847 root.classList.add("is-enhanced"); 848 select.setAttribute("aria-hidden", "true"); 849 select.tabIndex = -1; 850 851 var control = document.createElement("div"); 852 control.className = "control"; 853 var chips = document.createElement("span"); 854 chips.className = "chips"; 855 var input = document.createElement("input"); 856 input.type = "text"; 857 input.className = "entry"; 858 input.placeholder = placeholder; 859 input.setAttribute("role", "combobox"); 860 input.setAttribute("aria-expanded", "false"); 861 input.setAttribute("aria-autocomplete", "list"); 862 var list = document.createElement("ul"); 863 list.className = "options"; 864 list.id = "tagselect-" + ++uid; 865 list.hidden = true; 866 input.setAttribute("aria-controls", list.id); 867 868 control.appendChild(chips); 869 control.appendChild(input); 870 root.appendChild(control); 871 root.appendChild(list); 872 873 function options() { 874 return Array.prototype.slice.call(select.options); 875 } 876 877 function renderChips() { 878 chips.innerHTML = "";
879 options().forEach(function (opt) { 880 if (!opt.selected) { 881 return; 882 } 883 var chip = document.createElement("span"); 884 chip.className = "chip"; 885 chip.appendChild(document.createTextNode(opt.text)); 886 var x = document.createElement("button"); 887 x.type = "button"; 888 x.className = "remove"; 889 x.setAttribute("aria-label", "Retirer " + opt.text); 890 x.innerHTML = "×"; 891 x.addEventListener("click", function () { 892 if (free) { 893 opt.remove(); 894 } else { 895 opt.selected = false; 896 } 897 renderChips(); 898 renderList(); 899 input.focus(); 900 }); 901 chip.appendChild(x); 902 chips.appendChild(chip); 903 }); 904 } 905 906 function renderList() { 907 var q = norm(input.value.trim()); 908 list.innerHTML = ""; 909 if (free) { 910 list.hidden = true; 911 input.setAttribute("aria-expanded", "false"); 912 return; 913 } 914 var matches = options().filter(function (opt) { 915 return ( 916 !opt.selected && (!q || norm(opt.text).indexOf(q) !== -1) 917 ); 918 }); 919 matches.slice(0, 12).forEach(function (opt) { 920 var li = document.createElement("li"); 921 li.setAttribute("role", "option"); 922 li.tabIndex = -1; 923 li.textContent = opt.text; 924 li.addEventListener("mousedown", function (e) { 925 e.preventDefault(); // garde le focus sur l'input 926 opt.selected = true; 927 input.value = ""; 928 renderChips(); 929 renderList(); 930 }); 931 list.appendChild(li); 932 }); 933 list.hidden = !matches.length || document.activeElement !== input; 934 input.setAttribute("aria-expanded", list.hidden ? "false" : "true"); 935 } 936 937 function addFreeTag(text) { 938 text = text.trim().replace(/\s+/g, " "); 939 if (!text || text.length > 60) { 940 return; 941 } 942 var exists = options().some(function (opt) { 943 return norm(opt.text) === norm(text); 944 }); 945 if (!exists) { 946 var opt = new Option(text, text, true, true); 947 select.appendChild(opt); 948 } 949 input.value = ""; 950 renderChips(); 951 } 952 953 input.addEventListener("input", renderList); 954 input.addEventListener("focus", renderList); 955 input.addEventListener("blur", function () { 956 window.setTimeout(function () { 957 list.hidden = true; 958 input.setAttribute("aria-expanded", "false"); 959 }, 120); 960 }); 961 input.addEventListener("keydown", function (e) { 962 if (e.key === "Enter") { 963 if (free) { 964 e.preventDefault(); 965 addFreeTag(input.value); 966 } else if (!list.hidden && list.firstChild) { 967 e.preventDefault(); 968 var evt = document.createEvent("MouseEvents"); 969 evt.initEvent("mousedown", true, true); 970 list.firstChild.dispatchEvent(evt); 971 } 972 } else if (e.key === "Backspace" && input.value === "") { 973 var selected = options().filter(function (opt) { 974 return opt.selected; 975 }); 976 var last = selected[selected.length - 1]; 977 if (last) { 978 if (free) { 979 last.remove(); 980 } else { 981 last.selected = false; 982 } 983 renderChips(); 984 renderList(); 985 } 986 } else if (e.key === "Escape") { 987 list.hidden = true; 988 input.setAttribute("aria-expanded", "false"); 989 } 990 }); 991 control.addEventListener("click", function (e) { 992 if (e.target === control || e.target === chips) { 993 input.focus(); 994 } 995 }); 996 997 renderChips(); 998 } 999 1000 /* =================================================================== 1001 MOT DE PASSE â .pwd >
1001 input + [data-pwd-toggle] (afficher / masquer) 1002 =================================================================== */ 1003 function initPwdToggle(btn) { 1004 var input = btn.parentNode.querySelector("input"); 1005 if (!input) { 1006 return; 1007 } 1008 btn.addEventListener("click", function () { 1009 var show = input.type === "password"; 1010 input.type = show ? "text" : "password"; 1011 btn.setAttribute("aria-pressed", show ? "true" : "false"); 1012 btn.setAttribute( 1013 "aria-label", 1014 show ? "Masquer le mot de passe" : "Afficher le mot de passe", 1015 ); 1016 input.focus(); 1017 }); 1018 } 1019 1020 /* =================================================================== 1021 REVEAL â [data-reveal-when="id"] (+ [data-reveal-value]) : affiche le bloc 1022 quand le champ contrôleur est rempli (ou vaut la valeur donnée). Sans JS, 1023 le bloc reste visible (progressive enhancement) ; ex. « Précisez votre 1024 secteur » (si « Autre ») et « Complément d'adresse » (si adresse remplie). 1025 =================================================================== */ 1026 function initReveal(box) { 1027 var ctrl = document.getElementById( 1028 box.getAttribute("data-reveal-when"), 1029 ); 1030 if (!ctrl) { 1031 return; 1032 } 1033 var wanted = box.getAttribute("data-reveal-value"); 1034 // Champ rendu obligatoire UNIQUEMENT quand le bloc est visible 1035 // (ex. « Précisez votre secteur » si secteur = Autre) : required + 1036 // aria-required posés dynamiquement, astérisque ajoutée au label. 1037 var requireWhenShown = box.hasAttribute("data-reveal-required"); 1038 var field = box.querySelector("input, select, textarea"); 1039 function shouldShow() { 1040 var v = (ctrl.value || "").trim(); 1041 if (wanted !== null) { 1042 return v === wanted; 1043 } 1044 if (v !== "") { 1045 return true; 1046 } 1047 // Réaffichage après erreur : garder visible si le bloc porte une valeur. 1048 return !!(field && (field.value || "").trim()); 1049 } 1050 function sync() { 1051 var show = shouldShow(); 1052 box.hidden = !show; 1053 if (requireWhenShown && field) { 1054 field.required = show; 1055 field.setAttribute("aria-required", show ? "true" : "false"); 1056 var label = box.querySelector(".label"); 1057 if (show && label && !label.querySelector(".req")) { 1058 var star = document.createElement("span"); 1059 star.className = "req"; 1060 star.setAttribute("aria-hidden", "true"); 1061 star.textContent = " *"; 1062 label.appendChild(star); 1063 } 1064 } 1065 } 1066 ctrl.addEventListener("input", sync); 1067 ctrl.addEventListener("change", sync); 1068 sync(); 1069 } 1070 1071 /* =================================================================== 1072 ADDRESS â [data-address] : code postal â <select> ville rempli depuis une 1073 table LOCALE CPâvilles (assets/emploi/data/communes/{dd}.json, same-origin, 1074 AUCUNE dépendance externe â geo.api.gouv.fr pendait depuis la préprod). Ãtats (pilotés JS) : 1075 CP vide/incomplet â select DÃSACTIVà « Veuillez d'abord saisir un code 1076 postal » ; CP valide â « Sélectionner une ville » + communes + secours 1077 « Ma ville n'est pas dans la liste » (__autre__, révèle ville_autre). Un 1078 nouveau code postal RÃINITIALISE la ville. Sans JS, le select reste sur 1079 l'option de secours (saisie libre) â la validation serveur fait foi. 1080 =================================================================== */ 1081 function initAddress(box) { 1082 var cp = box.querySelector("#f-code_postal"); 1083 var ville = box.querySelector("#f-ville"); 1084 if (!cp || !ville || ville.tagName !== "SELECT") { 1085 return; 1086 } 1087 var AUTRE = "__autre__"; 1088 var autreLabel = "Ma ville n'est pas dans la liste"; 1089 var villeAutre = box.querySelector("#f-ville_autre"); 1090 // Champ « Pays » (combobox) : pilote le MODE du widget. Optionnel â sans 1091 // lui (ou pays = France/vide), comportement d'origine (référentiel CPâville). 1092 var pays = box.querySelector("#f-pays"); 1093 var villeField = ville.closest ? ville.closest(".field") : null; 1094 var villeAutreLabel = 1095 villeAutre && villeAutre.closest && villeAutre.closest(".field") 1096 ? villeAutre.closest(".field").querySelector(".label") 1097 : null; 1098 var villeAutreLabelOrig = 1099 villeAutreLabel && 1100 villeAutreLabel.firstChild && 1101 villeAutreLabel.firstChild.nodeType === 3 1102 ? villeAutreLabel.firstChild.nodeValue 1103 : null; 1104 var lastCp = null; 1105 var firstRun = true; 1106 1107 /** France (ou pays vide = donnée par défaut) â référentiel CPâville. */ 1108 function isFrance() { 1109 if (!pays) { 1110 return true; 1111 } 1112 var v = norm(pays.value).trim(); 1113 return v === "" || v === "france"; 1114 } 1115 1116 function option(value, label) { 1117 var o = document.createElement("option"); 1118 o.value = value; 1119 o.textContent = label; 1120 return o; 1121 } 1122 function hasAutre() { 1123 return Array.prototype.some.call(ville.options, function (o) { 1124 return o.value === AUTRE; 1125 }); 1126 } 1127 1128 // CP absent/incomplet : select désactivé + consigne. 1129 function disable() { 1130 ville.innerHTML = ""; 1131 ville.appendChild( 1132 option("", "Veuillez d'abord saisir un code postal"), 1133 ); 1134 ville.value = ""; 1135 ville.disabled = true; 1136 ville.dispatchEvent(new Event("change")); 1137 } 1138 1139 // CP valide : communes + option de secours. keep=true seulement au 1er 1140 // affichage (réaffichage serveur) pour conserver la ville déjà choisie. 1141 function populate(communes, keep) { 1142 var current = keep ? ville.value : ""; 1143 ville.disabled = false;
1144 ville.innerHTML = ""; 1145 ville.appendChild(option("", "Sélectionner une ville")); 1146 communes.forEach(function (name) { 1147 ville.appendChild(option(name, name)); 1148 }); 1149 var autre = option(AUTRE, autreLabel); 1150 ville.appendChild(autre); 1151 if (current === AUTRE || communes.indexOf(current) !== -1) { 1152 ville.value = current; 1153 } else if (current) { 1154 ville.insertBefore(option(current, current), autre); 1155 ville.value = current; 1156 } else if (communes.length === 1) { 1157 ville.value = communes[0]; 1158 } 1159 ville.dispatchEvent(new Event("change")); // synchronise le reveal « ville introuvable » 1160 } 1161 1162 function fill() { 1163 if (!isFrance()) { 1164 return; // hors-France : pas de référentiel CPâville (saisie libre) 1165 } 1166 var code = (cp.value || "").replace(/\D/g, ""); 1167 if (code.length !== 5) { 1168 if (lastCp !== "") { 1169 lastCp = ""; 1170 if (villeAutre) { 1171 villeAutre.value = ""; 1172 } 1173 disable(); 1174 } 1175 return; 1176 } 1177 if (code === lastCp) { 1178 return; 1179 } 1180 var keep = firstRun; // re-render serveur : conserver la ville déjà choisie 1181 firstRun = false; 1182 if (!keep && villeAutre) { 1183 villeAutre.value = ""; 1184 } // nouveau CP â reset saisie libre 1185 lastCp = code; 1186 var base = (window.LPE_EMPLOI && LPE_EMPLOI.communesUrl) || ""; 1187 fetch(base + code.slice(0, 2) + ".json") 1188 .then(function (r) { 1189 return r.ok ? r.json() : {}; 1190 }) 1191 .then(function (map) { 1192 populate((map && map[code]) || [], keep); 1193 }) 1194 .catch(function () { 1195 lastCp = ""; 1196 populate([], keep); 1197 }); // fallback : saisie libre via « __autre__ » 1198 } 1199 1200 // Ãtiquette du champ « ville libre » : « Ville » hors-France, libellé 1201 // d'origine en France. Conserve l'astérisque .req (posé par initReveal). 1202 function relabelVilleAutre() { 1203 if ( 1204 !villeAutreLabel || 1205 !villeAutreLabel.firstChild || 1206 villeAutreLabel.firstChild.nodeType !== 3 1207 ) { 1208 return; 1209 } 1210 villeAutreLabel.firstChild.nodeValue = isFrance() 1211 ? villeAutreLabelOrig || "Ville" 1212 : "Ville"; 1213 } 1214 1215 // France : référentiel CPâville (select visible, CP numérique). 1216 function toFrance() { 1217 // .field a `display:flex` â l'attribut [hidden] (display:none UA) serait 1218 // écrasé : on pilote donc style.display directement. 1219 if (villeField) { 1220 villeField.style.display = ""; 1221 } 1222 cp.setAttribute("inputmode", "numeric"); 1223 cp.setAttribute("placeholder", "5 chiffres"); 1224 lastCp = null; // force un refill propre depuis le CP courant 1225 firstRun = false; 1226 relabelVilleAutre(); 1227 fill(); 1228 } 1229 1230 // Hors-France : ville en SAISIE LIBRE (ville_autre, déjà résolu côté 1231 // serveur), select masqué (le CP reprend la largeur via .form-row), CP libre. 1232 function toEtranger() { 1233 if (villeField) { 1234 villeField.style.display = "none"; 1235 } 1236 ville.disabled = false; 1237 if (!hasAutre()) { 1238 ville.appendChild(option(AUTRE, autreLabel)); 1239 } 1240 ville.value = AUTRE; // â initReveal révèle ville_autre (requis) 1241 ville.dispatchEvent(new Event("change")); 1242 cp.removeAttribute("inputmode"); // CP étranger : alphanumérique possible 1243 cp.setAttribute("placeholder", "Code postal"); 1244 relabelVilleAutre(); 1245 } 1246 1247 function applyMode() { 1248 if (isFrance()) { 1249 toFrance(); 1250 } else { 1251 toEtranger(); 1252 } 1253 } 1254 1255 cp.addEventListener("input", fill); 1256 cp.addEventListener("blur", fill); 1257 if (pays) { 1258 pays.addEventListener("input", applyMode); 1259 pays.addEventListener("change", applyMode); 1260 } 1261 1262 // Ãtat initial (sans toucher au rendu serveur en France : f
1262irstRun). 1263 if (!isFrance()) { 1264 toEtranger(); 1265 } else { 1266 relabelVilleAutre(); 1267 if ((cp.value || "").replace(/\D/g, "").length === 5) { 1268 fill(); 1269 } else if (!ville.value) { 1270 disable(); 1271 } 1272 // Sinon : ville déjà renseignée SANS code postal (profil candidat hérité 1273 // de l'ancien champ texte libre) â on conserve le rendu serveur (select 1274 // utilisable, ville préservée) au lieu de le vider/désactiver. 1275 } 1276 } 1277 1278 /* =================================================================== 1279 MASK â [data-mask] : formatage léger à la saisie (vanilla, sans lib). 1280 'siret' = 14 chiffres groupés 3-3-3-5 ; 'tel' = 10 chiffres par paires 1281 (format FR « 06 12 34 56 78 »). Les espaces sont cosmétiques (le serveur 1282 les retire pour le SIRET, les accepte pour le téléphone). Saisie 1283 séquentielle : le curseur reste en fin de champ. 1284 =================================================================== */ 1285 function initMask(el) { 1286 var type = el.getAttribute("data-mask"); 1287 function format() { 1288 var d = (el.value || "").replace(/\D/g, ""); 1289 if (type === "siret") { 1290 d = d.slice(0, 14); 1291 var p = [d.slice(0, 3)]; 1292 if (d.length > 3) { 1293 p.push(d.slice(3, 6)); 1294 } 1295 if (d.length > 6) { 1296 p.push(d.slice(6, 9)); 1297 } 1298 if (d.length > 9) { 1299 p.push(d.slice(9, 14)); 1300 } 1301 el.value = p.filter(Boolean).join(" "); 1302 } else if (type === "tel") { 1303 // Normalise les formats internationaux FR souvent fournis par 1304 // l'autofill (+33 / 0033 6⦠â 06â¦) avant le groupage par paires. 1305 if (d.indexOf("0033") === 0) { 1306 d = "0" + d.slice(4); 1307 } else if (d.indexOf("33") === 0 && d.length >= 11) { 1308 d = "0" + d.slice(2); 1309 } 1310 d = d.slice(0, 10); 1311 el.value = (d.match(/.{1,2}/g) || []).join(" "); 1312 } 1313 } 1314 el.addEventListener("input", format); 1315 el.addEventListener("change", format); // capte le remplissage automatique du navigateur 1316 format(); 1317 } 1318 1319 /* =================================================================== 1320 STRIPE REDIRECT â [data-stripe-redirect] : redirection automatique vers le 1321 Payment Link après un court délai (le bouton « Payer » reste un repli si la 1322 redirection est bloquée). Sans JS, seul le bouton agit. 1323 =================================================================== */ 1324 function initStripeRedirect(el) { 1325 var url = el.getAttribute("data-stripe-redirect"); 1326 if (!url) { 1327 return; 1328 } 1329 var delay = parseInt(el.getAttribute("data-delay"), 10) || 4000; 1330 setTimeout(function () { 1331 window.location.href = url; 1332 }, delay); 1333 } 1334 1335 /* =================================================================== 1336 CONFIRM â form[data-confirm] : intercepte la soumission et demande 1337 confirmation dans la modale native <dialog data-confirm-dialog>. Le 1338 message vient de l'attribut data-confirm ; data-confirm-credit="1" révèle 1339 l'avertissement « crédit non remboursé ». Repli confirm() si pas de <dialog>. 1340 =================================================================== */ 1341 function initConfirm(dialog) { 1342 var msgEl = dialog.querySelector("[data-confirm-message]"); 1343 var creditEl = dialog.querySelector("[data-confirm-credit]"); 1344 var okBtn = dialog.querySelector("[data-confirm-ok]"); 1345 var pending = null; 1346 if (!okBtn) { 1347 return; 1348 } 1349 1350 function close() { 1351 if (dialog.open && typeof dialog.close === "function") { 1352 dialog.close(); 1353 } 1354 pending = null; 1355 } 1356 1357 Array.prototype.forEach.call( 1358 document.querySelectorAll("form[data-confirm]"), 1359 function (form) { 1360 form.addEventListener("submit", function (e) { 1361 e.preventDefault(); 1362 var message = 1363 form.getAttribute("data-confirm") || 1364 "Confirmer cette action ?"; 1365 pending = form; 1366 if (msgEl) { 1367 msgEl.textContent = message; 1368 } 1369 if (creditEl) { 1370 creditEl.hidden = 1371 form.getAttribute("data-confirm-credit") !== "1"; 1372 } 1373 if (typeof dialog.showModal === "function") { 1374 dialog.showModal(); 1375 } else if (window.confirm(message)) { 1376 form.submit(); 1377 } else { 1378 pending = null; 1379 } 1380 }); 1381 }, 1382 ); 1383 1384 okBtn.addEventListener("click", function () { 1385 var f = pending; 1386 close(); 1387 if (f) { 1388 f.submit(); // soumission programmatique : ne re-déclenche pas le handler 1389 } 1390 }); 1391 1392 Array.prototype.forEach.call( 1393 dialog.querySelectorAll("[data-confirm-cancel]"), 1394 function (b) { 1395 b.addEventListener("click", close); 1396 }, 1397 ); 1398 dialog.addEventListener("cancel", function () { 1399 pending = null; 1400 }); // touche Ãchap 1401 dialog.addEventListener("click", function (e) { 1402 if (e.target === dialog) { 1403 close(); 1404 } // clic sur le fond 1405 }); 1406 } 1407 1408 /* =================================================================== 1409 FILTER â [data-filter-group] : les boutons [data-filter] filtrent les 1410 lignes [data-status] de la cible [data-filter-target] (« all » = tout). 1411 100 % client (instantané, sans rechargement). 1412 =================================================================== */ 1413 function initFilter(group) { 1414 var target = document.querySelector( 1415 group.getAttribute("data-filter-target") || "", 1416 ); 1417 if (!target) { 1418 return; 1419 } 1420 var buttons = group.querySelectorAll("[data-filter]"); 1421 var rows = target.querySelectorAll("[data-status]"); 1422 Array.prototype.forEach.call(buttons, function (btn) { 1423 btn.addEventListener("click", function () { 1424 var val = btn.getAttribute("data-filter"); 1425 Array.prototype.forEach.call(rows, function (row) { 1426 row.hidden = !( 1427 val === "all" || row.getAttribute("data-status") === val 1428 ); 1429 }); 1430 Array.prototype.forEach.call(buttons, function (b) { 1431 b.classList.toggle("is-active", b === btn); 1432 }); 1433 }); 1434 }); 1435 } 1436 1437 /* =================================================================== 1438 AUTH MODAL â <dialog data-auth-modal> : garde de candidature. Les 1439 déclencheurs [data-auth-open] (bouton « Postuler » d'un visiteur non 1440 connecté) ouvrent la modale d'incitation à l'inscription ; fermeture par 1441 la croix [data-auth-close], la touche Ãchap ou un clic sur le fond. 1442 =================================================================== */ 1443 function initAuthModal(dialog) { 1444 function open() { 1445 if (typeof dialog.showModal === "function") { 1446 dialog.showModal(); 1447 } else { 1448 dialog.setAttribute("open", ""); 1449 } 1450 } 1451 function close() { 1452 if (dialog.open && typeof dialog.close === "function") { 1453 dialog.close(); 1454 } else { 1455 dialog.removeAttribute("open"); 1456 } 1457 } 1458 1459 Array.prototype.forEach.call( 1460 document.querySelectorAll("[data-auth-open]"), 1461 function (btn) { 1462 btn.addEventListener("click", open); 1463 }, 1464 ); 1465 Array.prototype.forEach.call( 1466 dialog.querySelectorAll("[data-auth-close]"), 1467 function (b) { 1468 b.addEventListener("click", close); 1469 }, 1470 ); 1471 dialog.addEventListener("click", function (e) { 1472 if (e.target === dialog) { 1473 close(); 1474 } // clic sur le fond (top-layer) 1475 }); 1476 } 1477 1478 /* =================================================================== 1479 MODAL â <dialog data-modal="id"> : modale d'info générique, ouverte par les 1480 déclencheurs [data-modal-open="id"] (plusieurs possibles â une seule modale), 1481 fermée par [data-modal-close], la touche Ãchap ou un clic sur le fond. 1482 Ex. « suppression d'annonce indisponible â nous contacter ». 1483 =================================================================== */ 1484 function initModal(dialog) { 1485 var id = dialog.getAttribute("data-modal"); 1486 function open() { 1487 if (typeof dialog.showModal === "function") { 1488 dialog.showModal(); 1489 } else { 1490 dialog.setAttribute("open", ""); 1491 } 1492 } 1493 function close() { 1494 if (dialog.open && typeof dialog.close === "function") { 1495 dialog.close(); 1496 } else { 1497 dialog.removeAttribute("open"); 1498 } 1499 } 1500 Array.prototype.forEach.call( 1501 document.querySelectorAll('[data-modal-open="' + id + '"]'), 1502 function (btn) { 1503 btn.addEventListener("click", open); 1504 }, 1505 ); 1506 Array.prototype.forEach.call( 1507 dialog.querySelectorAll("[data-modal-close]"), 1508 function (b) { 1509 b.addEventListener("click", close); 1510 }, 1511 ); 1512 dialog.addEventListener("click", function (e) { 1513 if (e.target === dialog) { 1514 close(); 1515 } 1516 }); 1517 } 1518 1519 /* =================================================================== 1520 DROPZONE â <label class="upload" data-dropzone> (dépôt de CV). L'input 1521 fichier, invisible, recouvre toute la zone : le clic ET le glisser- 1522 déposer sont NATIFS (aucun transfert de fichiers en JS). On assure ici 1523 le retour visuel (.is-drag au survol) ET la validation du fichier : 1524 l'attribut accept ne filtre que la boîte de dialogue, PAS le glisser- 1525 déposer â on revérifie donc type et taille ([data-max-mo]) au change, 1526 sinon .has-error + message ([data-error-type] / [data-error-size]) et 1527 input vidé. Fichier retenu â la carte sÅur [data-dropzone-file] 1528 remplace la zone : nom, poids, « Aperçu » (URL blob, nouvel onglet) et 1529 bouton « Retirer » ; sans carte (DS minimal), repli : nom du fichier 1530 dans la zone (.has-file). Doublé serveur (CvManager). 1531 =================================================================== */ 1532 function initDropzone(zone) { 1533 var input = zone.querySelector('input[type="file"]'); 1534 var text = zone.querySelector("[data-dropzone-text]"); 1535 var initial = text ? text.textContent : ""; 1536 var maxMo = parseFloat(zone.getAttribute("data-max-mo") || "0"); 1537 var card = zone.parentElement 1538 ? zone.parentElement.querySelector("[data-dropzone-file]") 1539 : null; 1540 var nameEl = card ? card.querySelector("[data-dropzone-name]") : null; 1541 var sizeEl = card ? card.querySelector("[data-dropzone-size]") : null; 1542 var previewEl = card 1543 ? card.querySelector("[data-dropzone-preview]") 1544 : null; 1545 var clearBtn = card 1546 ? card.querySelector("[data-dropzone-clear]") 1547 : null; 1548 var blobUrl = ""; 1549 1550 if (!input) { 1551 return; 1552 } 1553 1554 // Le fichier satisfait-il l'attribut accept ? (MIME exact, joker 1555 // « image/* » ou extension « .pdf » â liste séparée par des virgules) 1556 function matchesAccept(file) { 1557 var rules = (input.getAttribute("accept") || "") 1558 .toLowerCase() 1559 .split(","); 1560 if (rules.length === 1 && rules[0].trim() === "") { 1561 return true; 1562 } 1563 var type = (file.type || "").toLowerCase(); 1564 var name = file.name.toLowerCase(); 1565 return rules.some(function (raw) { 1566 var rule = raw.trim(); 1567 if (!rule) { 1568 return false;
1569 } 1570 if (rule.charAt(0) === ".") { 1571 return name.slice(-rule.length) === rule; 1572 } 1573 if (rule.slice(-2) === "/*") { 1574 return type.indexOf(rule.slice(0, -1)) === 0; 1575 } 1576 return type === rule; 1577 }); 1578 } 1579 1580 // Poids lisible : « 1,2 Mo » / « 340 Ko ». 1581 function formatSize(bytes) { 1582 if (bytes >= 1024 * 1024) { 1583 return ( 1584 (Math.round((bytes / (1024 * 1024)) * 10) / 10) 1585 .toString() 1586 .replace(".", ",") + " Mo" 1587 ); 1588 } 1589 return Math.max(1, Math.round(bytes / 1024)) + " Ko"; 1590 } 1591 1592 function revokeBlob() { 1593 if (blobUrl) { 1594 URL.revokeObjectURL(blobUrl); 1595 blobUrl = ""; 1596 } 1597 } 1598 1599 // Ãtat initial : dropzone visible, carte cachée, message d'origine. 1600 function reset() { 1601 revokeBlob(); 1602 if (card) { 1603 card.hidden = true; 1604 } 1605 zone.hidden = false; 1606 zone.classList.remove("has-file", "has-error"); 1607 if (text) { 1608 text.textContent = initial; 1609 } 1610 } 1611 1612 function setError(message) { 1613 input.value = ""; // fichier refusé : required redevient bloquant 1614 reset(); 1615 zone.classList.add("has-error"); 1616 if (text) { 1617 text.textContent = message; 1618 } 1619 } 1620 1621 // Fichier accepté : la carte récap remplace la zone (si présente). 1622 function showCard(file) { 1623 if (!card) { 1624 return false; 1625 } 1626 if (nameEl) { 1627 nameEl.textContent = file.name; 1628 } 1629 if (sizeEl) { 1630 sizeEl.textContent = formatSize(file.size); 1631 } 1632 revokeBlob(); 1633 if (previewEl) { 1634 blobUrl = URL.createObjectURL(file); 1635 previewEl.href = blobUrl; 1636 } 1637 card.hidden = false; 1638 zone.hidden = true; 1639 zone.classList.remove("has-file", "has-error"); 1640 return true; 1641 } 1642 1643 function refresh() { 1644 var file = 1645 input.files && input.files.length ? input.files[0] : null; 1646 if (!file) { 1647 reset(); 1648 return; 1649 } 1650 if (!matchesAccept(file)) { 1651 setError( 1652 zone.getAttribute("data-error-type") || 1653 "Format de fichier non accepté.", 1654 ); 1655 return; 1656 } 1657 if (maxMo > 0 && file.size > maxMo * 1024 * 1024) { 1658 setError( 1659 zone.getAttribute("data-error-size") || 1660 "Le fichier est trop volumineux.", 1661 ); 1662 return; 1663 } 1664 if (!showCard(file)) { 1665 // Repli sans carte : nom du fichier affiché dans la zone. 1666 zone.classList.remove("has-error"); 1667 zone.classList.add("has-file"); 1668 if (text) { 1669 text.textContent = file.name; 1670 } 1671 } 1672 } 1673 1674 if (clearBtn) { 1675 clearBtn.addEventListener("click", function () { 1676 input.value = ""; 1677 reset(); 1678 }); 1679 } 1680 1681 input.addEventListener("change", refresh); 1682 ["dragenter", "dragover"].forEach(function (ev) { 1683 input.addEventListener(ev, function () { 1684 zone.classList.add("is-drag"); 1685 }); 1686 }); 1687 ["dragleave", "drop"].forEach(function (ev) { 1688 input.addEventListener(ev, function () { 1689 zone.classList.remove("is-drag"); 1690 }); 1691 }); 1692 } 1693 1694 /* =================================================================== 1695 PASSWORD â politique vérifiée EN DIRECT (à la frappe, avant submit) sur 1696 les tunnels d'inscription + « Mon compte » : ⥠8 caractères, 1 chiffre, 1697 1 majuscule, et confirmation identique. Message inline (.field.has-error 1698 > .error-msg), au lieu d'attendre le POST. Doublé serveur (PasswordValidator). 1699 =================================================================== */ 1700 var PWD_POLICY_MSG = 1701 "Le mot de passe doit contenir au moins 8 caractères, dont 1 chiffre et 1 majuscule."; 1702 var PWD_MATCH_MSG = "Les mots de passe ne correspondent pas."; 1703 1704 function pwdFieldError(input, msg) { 1705 var field = input.closest(".field"); 1706 if (!field) { 1707 return; 1708 } 1709 var el = field.querySelector(".error-msg.js-pwd"); 1710 if (msg) { 1711 field.classList.add("has-error"); 1712 if (!el) { 1713 el = document.createElement("p"); 1714 el.className = "error-msg js-pwd"; 1715 field.appendChild(el); 1716 } 1717 el.textContent = msg; 1718 } else if (el) { 1719 el.remove(); 1720 if (!field.querySelector(".error-msg")) { 1721 field.classList.remove("has-error"); 1722 } 1723 } 1724 } 1725 1726 function pwdValid(v) { 1727 return v.length >= 8 && /[0-9]/.test(v) && /[A-Z]/.test(v); 1728 } 1729 1730 function initPasswordPolicy() { 1731 // Champs « nouveau mot de passe » (création de compte + changement) : on 1732 // exclut existing_password / current_password (saisie d'un mdp existant). 1733 var pwds = document.querySelectorAll( 1734 'input[type="password"][name="password"], input[type="password"][name="new_password"]', 1735 ); 1736 Array.prototype.forEach.call(pwds, function (pwd) { 1737 var form = pwd.form; 1738 var confirm = form 1739 ? form.querySelector( 1740 'input[type="password"][name="confirm"], input[type="password"][name="confirm_password"]', 1741 ) 1742 : null; 1743 1744 // Pas de champ de confirmation = formulaire de CONNEXION (mot de passe 1745 // existant) : on ne valide PAS la politique (on n'y crée pas de mdp). 1746 if (!confirm) { 1747 return; 1748 } 1749 1750 function checkConfirm() { 1751 if (!confirm) { 1752 return; 1753 } 1754 var c = confirm.value; 1755 pwdFieldError(confirm, c !== "" && c !== pwd.value ? PWD_MATCH_MSG : null); 1756 } 1757 function checkPwd() { 1758 var v = pwd.value; 1759 // Vide = pas de message (requis géré au submit ; mdp optionnel sur « Mon compte »). 1760 pwdFieldError(pwd, v === "" || pwdValid(v) ? null : PWD_POLICY_MSG); 1761 checkConfirm(); 1762 } 1763 pwd.addEventListener("input", checkPwd); 1764 if (confirm) { 1765 confirm.addEventListener("input", checkConfirm); 1766 } 1767 }); 1768 } 1769 1770 /* =================================================================== 1771 NAV MOBILE â [data-nav-toggle] : burger du header emploi. 1772 Ouvre/ferme le panneau (classe .nav-open sur .header), Ãchap et clic 1773 hors header referment. Sans JS le menu reste accessible en desktop. 1774 =================================================================== */ 1775 function initNavToggle(btn) { 1776 var header = btn.closest(".header"); 1777 var nav = document.getElementById(btn.getAttribute("aria-controls")); 1778 if (!header || !nav) { 1779 return; 1780 } 1781 1782 function close() { 1783 header.classList.remove("nav-open"); 1784 btn.setAttribute("aria-expanded", "false"); 1785 } 1786 1787 btn.addEventListener("click", function () { 1788 var open = header.classList.toggle("nav-open"); 1789 btn.setAttribute("aria-expanded", open ? "true" : "false"); 1790 closeAccountMenu(); 1791 }); 1792 1793 document.addEventListener("keydown", function (e) { 1794 if (e.key === "Escape" && header.classList.contains("nav-open")) { 1795 close(); 1796 btn.focus(); 1797 } 1798 }); 1799 1800 document.addEventListener("click", function (e) { 1801 if ( 1802 header.classList.contains("nav-open") && 1803 !header.contains(e.target) 1804 ) { 1805 close(); 1806 } 1807 }); 1808 } 1809 1810 /** Ferme le panneau « menu du compte » s'il est ouvert (exclusion 1811 mutuelle avec le burger : un seul panneau déroulé à la fois). */ 1812 function closeAccountMenu() { 1813 var menu = document.querySelector(".account-menu.is-open"); 1814 var btn = document.querySelector("[data-account-toggle]"); 1815 if (menu) { 1816 menu.classList.remove("is-open"); 1817 } 1818 if (btn) { 1819 btn.setAttribute("aria-expanded", "false"); 1820 } 1821 } 1822 1823 /* =================================================================== 1824 MENU DU COMPTE MOBILE â [data-account-toggle] : bouton du header 1825 (â¤1280px, toutes pages d'un connecté ayant un espace emploi). 1826 Déroule la copie du menu compte EMBARQUÃE dans le header (ciblée par 1827 id : les pages /mon-compte/* ont aussi leur sidebar) en panneau fixé 1828 sous la barre (--lpe-account-top = bas réel : suit la barre d'admin WP). 1829 =================================================================== */ 1830 function initAccountToggle(btn) { 1831 var header = btn.closest(".header"); 1832 var menu = document.getElementById(btn.getAttribute("aria-controls")); 1833 if (!header || !menu) { 1834 return; 1835 } 1836 header.classList.add("has-account-nav"); 1837 1838 function close() { 1839 menu.classList.remove("is-open"); 1840 btn.setAttribute("aria-expanded", "false"); 1841 } 1842 1843 btn.addEventListener("click", function () { 1844 menu.style.setProperty( 1845 "--lpe-account-top", 1846 Math.max(0, header.getBoundingClientRect().bottom) + "px", 1847 ); 1848 var open = menu.classList.toggle("is-open"); 1849 btn.setAttribute("aria-expanded", open ? "true" : "false"); 1850 // Exclusion mutuelle : referme le burger. 1851 header.classList.remove("nav-open"); 1852 var burger = header.querySelector("[data-nav-toggle]"); 1853 if (burger) { 1854 burger.setAttribute("aria-expanded", "false"); 1855 } 1856 }); 1857 1858 document.addEventListener("keydown", function (e) { 1859 if (e.key === "Escape" && menu.classList.contains("is-open")) { 1860 close(); 1861 btn.focus(); 1862 } 1863 }); 1864 1865 document.addEventListener("click", function (e) { 1866 if ( 1867 menu.classList.contains("is-open") && 1868 !menu.contains(e.target) && 1869 !btn.contains(e.target) 1870 ) { 1871 close(); 1872 } 1873 }); 1874 } 1875 1876 ready(function () { 1877 initTracking(); 1878 Array.prototype.forEach.call( 1879 document.querySelectorAll("[data-nav-toggle]"), 1880 initNavToggle, 1881 ); 1882 Array.prototype.forEach.call( 1883 document.querySelectorAll("[data-account-toggle]"), 1884 initAccountToggle, 1885 ); 1886 Array.prototype.forEach.call( 1887 document.querySelectorAll("[data-carousel]"), 1888 initCarousel, 1889 ); 1890 Array.prototype.forEach.call( 1891 document.querySelectorAll("[data-combobox]"), 1892 initCombobox, 1893 ); 1894 Array.prototype.forEach.call( 1895 document.querySelectorAll("[data-places]"), 1896 initPlaces, 1897 ); 1898 Array.prototype.forEach.call( 1899 document.querySelectorAll("[data-share]"), 1900 initShare, 1901 ); 1902 Array.prototype.forEach.call( 1903 document.querySelectorAll("[data-pwd-toggle]"), 1904 initPwdToggle, 1905 ); 1906 Array.prototype.forEach.call( 1907 document.querySelectorAll("[data-tagselect]"), 1908 initTagselect, 1909 ); 1910 Array.prototype.forEach.call( 1911 document.querySelectorAll("[data-reveal-when]"), 1912 initReveal, 1913 ); 1914 Array.prototype.forEach.call( 1915 document.querySelectorAll("[data-address]"), 1916 initAddress, 1917 ); 1918 Array.prototype.forEach.call( 1919 document.querySelectorAll("[data-mask]"), 1920 initMask, 1921 ); 1922 Array.prototype.forEach.call( 1923 document.querySelectorAll("[data-stripe-redirect]"), 1924 initStripeRedirect, 1925 ); 1926 Array.prototype.forEach.call( 1927 document.querySelectorAll("[data-confirm-dialog]"), 1928 initConfirm, 1929 ); 1930 Array.prototype.forEach.call( 1931 document.querySelectorAll("[data-filter-group]"), 1932 initFilter, 1933 ); 1934 Array.prototype.forEach.call( 1935 document.querySelectorAll("[data-auth-modal]"), 1936 initAuthModal, 1937 ); 1938 Array.prototype.forEach.call( 1939 document.querySelectorAll("[data-modal]"), 1940 initModal, 1941 ); 1942 Array.prototype.forEach.call( 1943 document.querySelectorAll("[data-dropzone]"), 1944 initDropzone, 1945 ); 1946 initPasswordPolicy(); 1947 1948 // Consentement cookies (Orejime) : le voile « cookies requis » posé par 1949 // inc/rgpd.php sur les iframes vidéo expose un bouton .open-cookie. Le JS 1950 // du thème (qui le câblait) est déqueué sur les pages emploi â on rebranche 1951 // ici, en vanilla, l'ouverture du gestionnaire de consentement. 1952 document.addEventListener("click", function (e) { 1953 var btn = 1954 e.target.closest && e.target.closest(".open-cookie"); 1955 if (!btn) { 1956 return; 1957 } 1958 e.preventDefault(); 1959 if (window.orejime && typeof window.orejime.show === "function") { 1960 window.orejime.show(); 1961 } 1962 }); 1963 }); 1964})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.