PageSourceSearch

https://www.lesprosdelapetiteenfance.fr/wp-content/themes/site-les…/assets/emploi/emploi.js?ver=1784626016

js lesprosdelapetiteenfance.fr collected 2026-10-02 03:41:31 UTC 76,614 bytes, 1,964 lines download raw bytes

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 = "&rarr;";
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 = "&times;";
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.