Files
xamxam/app/public/assets/js/app/pill-search.js
Pontoporeia 19bf9f101a Refactor apropos/charte/licence pages: shared layout, TOC anchors, and UI polish
Unify the three public pages (à propos, charte, licence) onto a single
grid layout (.page-content) with sticky TOC sidebar, replacing the old
separate  /  /  markup.

- Merge about.php, charte.php, licence.php templates into shared
  .page-content / .content-section structure
- Add CommonMark HeadingPermalinkExtension for stable heading anchors
- Use SlugNormalizer for TOC links so they match rendered heading IDs
- Standardize link styling across content blocks: bold black, accent on
  hover (consistent with global link style)
- Fix code block wrapping: use pre-wrap instead of pre, constrain grid
  columns with min-width:0, auto scrollbar
- Fix apropos page grid placement: force content-section into column 2
  so contacts and credits stay in the content area, not the sidebar

Also includes accumulated WIP changes:
- Header gradient: hardcoded purple-to-green (replaces CSS variables)
- Search placeholder font
- Duration field: replace minutes/sec/heures with h:m:s time inputs
- TFE file optional for formats 1,4,6 with client-side JS toggle
- Licence form: em-dash to hyphen, details/summary classes
- Pill search: block Enter key form submission when no results
- Draft autosave: remove CSRF rotation (broke concurrent FilePond uploads)
- Language pill: clear hints for excluded main languages
- Search results: gradient placeholder cards for items without covers
- TFE display: format durée values as XhYm instead of decimal
2026-06-19 19:40:05 +02:00

198 lines
6.6 KiB
JavaScript

/**
* pill-search.js — generalized pill-based search component for tags and languages.
*
* Initialisez avec un conteneur ayant l'attribut data-pill-search :
* <div data-pill-search data-pill-name="tag" data-pill-max="10" data-pill-min="3" data-pill-required="1">
*
* 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 =
'<input type="hidden" name="' +
inputName +
'[]" value="' +
escaped +
'">' +
'<span class="tag-pill-name">' +
escaped +
"</span>" +
'<button type="button" class="tag-pill-remove" title="Retirer \u00AB\u00A0' +
escaped +
'\u00A0\u00BB" aria-label="Retirer ' +
escaped +
'">' +
'<svg width="16" height="16" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM112,168V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Zm48,0V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Z"></path></svg>' +
"</button>";
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;
}
}
})();