/** * pill-search.js — generalized pill-based search component for tags and languages. * * Initialisez avec un conteneur ayant l'attribut data-pill-search : *
* * DOM attendu à l'intérieur du conteneur : * - .tag-search-pills → conteneur des pills * - .tag-search-input → champ de recherche (avec hx-post, hx-trigger, etc.) * - .tag-search-suggestions → dropdown * - .tag-search-count → compteur * - .tag-search-counter → wrapper du compteur * - .tag-search-input-wrap → wrapper du champ de recherche * - .tag-search-max-msg → message "maximum atteint" * * Options (par attribut data) : * data-pill-name → nom pour les inputs cachés (ex: "tag", "language_autre") * data-pill-max → max pills (default 10) * data-pill-min → min pills requis (default 0) * data-pill-required → si "1", active l'affichage du minimum * data-pill-role → "tag" (lowercase) ou "lang" (ucfirst) */ (() => { function initAll() { document .querySelectorAll( "[data-pill-search]:not([data-pill-search-initialized])", ) .forEach((container) => { container.setAttribute("data-pill-search-initialized", "1"); initPillSearch(container); }); } document.addEventListener("DOMContentLoaded", initAll); document.body.addEventListener("htmx:afterSwap", initAll); function initPillSearch(container) { var pills = container.querySelector(".tag-search-pills"); var search = container.querySelector(".tag-search-input"); var dropdown = container.querySelector(".tag-search-suggestions"); var countEl = container.querySelector(".tag-search-count"); var counter = container.querySelector(".tag-search-counter"); var maxTags = parseInt(container.getAttribute("data-pill-max"), 10) || 10; var minTags = parseInt(container.getAttribute("data-pill-min"), 10) || 0; var required = container.getAttribute("data-pill-required") === "1"; var inputName = container.getAttribute("data-pill-name") || "tag"; var _role = container.getAttribute("data-pill-role") || "tag"; var selectedIdx = -1; if (!pills || !search || !dropdown) return; function normalize(name) { return name.trim().replace(/\s+/g, " ").toLowerCase(); } function pillAlreadyExists(name) { var norm = normalize(name); var existing = pills.querySelectorAll(".tag-pill-name"); for (let i = 0; i < existing.length; i++) { if (normalize(existing[i].textContent) === norm) return true; } return false; } function updateCount() { var n = pills.querySelectorAll(".tag-pill").length; var suffix = required ? ` (min ${minTags})` : ""; if (countEl) countEl.textContent = `${n}/${maxTags}${suffix}`; if (counter) counter.style.display = n > 0 || required ? "" : "none"; if (countEl && required) { countEl.style.color = n < minTags ? "var(--text-danger)" : "var(--accent)"; } var wrap = container.querySelector(".tag-search-input-wrap"); var maxMsg = container.querySelector(".tag-search-max-msg"); if (n >= maxTags) { if (wrap) wrap.style.display = "none"; if (maxMsg) maxMsg.style.display = ""; } else { if (wrap) { wrap.style.display = ""; if (search) search.style.display = ""; } if (maxMsg) maxMsg.style.display = "none"; } } pills.addEventListener("click", (e) => { var btn = e.target.closest(".tag-pill-remove"); if (!btn) return; var pill = btn.closest(".tag-pill"); pill.remove(); updateCount(); var wrap = container.querySelector(".tag-search-input-wrap"); var inp = container.querySelector(".tag-search-input"); if (wrap && inp) { wrap.style.display = ""; inp.style.display = ""; } }); function highlight(idx) { var items = dropdown.querySelectorAll(".tag-search-item"); for (let i = 0; i < items.length; i++) { items[i].classList.toggle("tag-search-item--highlight", i === idx); } } function selectPill(btn) { var name = normalize(btn.getAttribute("data-tag-name") || ""); if (!name) return; if (pillAlreadyExists(name)) return; if (pills.querySelectorAll(".tag-pill").length >= maxTags) return; var escaped = htmlEscape(name); var pill = document.createElement("span"); pill.className = "tag-pill"; pill.innerHTML = '' + '' + escaped + "" + '"; pills.appendChild(pill); updateCount(); search.value = ""; dropdown.innerHTML = ""; selectedIdx = -1; search.focus(); } dropdown.addEventListener("click", (e) => { var btn = e.target.closest(".tag-search-item"); if (!btn) return; selectPill(btn); }); search.addEventListener("keydown", (e) => { var items = dropdown.querySelectorAll(".tag-search-item"); if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); if (items.length === 0) return; if (e.key === "ArrowDown") { selectedIdx = (selectedIdx + 1) % items.length; } else { selectedIdx = selectedIdx <= 0 ? items.length - 1 : selectedIdx - 1; } highlight(selectedIdx); } else if (e.key === "Enter") { // Always prevent Enter from submitting the form. // If there are no suggestions (e.g., "anglais" in language // search — excluded main language), the Enter key would // otherwise propagate to the form and trigger its hx-post to // draft.php, causing the JSON response to replace the form // content. e.preventDefault(); if (items.length > 0) { if (selectedIdx >= 0 && selectedIdx < items.length) { selectPill(items[selectedIdx]); } else { selectPill(items[0]); } } } else if (e.key === "Escape") { dropdown.innerHTML = ""; selectedIdx = -1; } }); search.addEventListener("blur", () => { setTimeout(() => { if (!dropdown.contains(document.activeElement)) { dropdown.innerHTML = ""; selectedIdx = -1; } }, 150); }); function htmlEscape(str) { var el = document.createElement("span"); el.textContent = str; return el.innerHTML; } } })();