Files
xamxam/app/public/assets/js/app/jury-autocomplete.js
Pontoporeia 99125cc8e3 Add autosave draft system for partage form with HTMX-based session persistence
- New fragment endpoint POST/GET /partage/fragments/draft.php:
  saves all form fields to PHP session, excludes file/csrf/slug fields
  GET returns JSON for JS hydration on page load
  rotates both global CSRF and share CSRF tokens in sync

- form.php accepts optional $formExtraAttrs and $showAutosaveStatus:
  allows injecting HTMX attributes and 'Brouillon enregistré' indicator

- renderShareLinkForm adds hx-post with change/input debounce trigger,
  loads autosave-handler.js, hydrate fields from draft on page load

- Draft cleared on successful form submission in handleShareLinkSubmission

- autosave-handler.js now also updates share_link_token hidden input
  when rotating CSRF token (partage form uses both csrf_token and share_link_token)

- Added .autosave-status CSS to form.css (was admin.css-only)

- Updated fragment routing to accept GET requests (needed for draft hydration)
2026-06-11 11:04:49 +02:00

153 lines
4.5 KiB
JavaScript

/**
* jury-autocomplete.js — inlines autocomplete for jury member text inputs.
*
* Each jury sub-fieldset (<fieldset class="admin-jury-lecteurs">) gains a shared
* suggestion dropdown positioned below the last input. Typing in any input within
* that fieldset triggers an HTMX POST to the pill-search fragment (type=supervisor).
* Clicking a suggestion fills the input that triggered the search.
*
* Data attributes on the fieldset:
* data-jury-autocomplete — marks the fieldset for initialisation
* data-jury-hx-post — HTMX endpoint URL (required)
* data-jury-hx-target — CSS selector for the shared dropdown (optional)
*/
(() => {
function initAll() {
document
.querySelectorAll(
"[data-jury-autocomplete]:not([data-jury-autocomplete-initialized])",
)
.forEach((fieldset) => {
fieldset.setAttribute("data-jury-autocomplete-initialized", "1");
initFieldset(fieldset);
});
}
document.addEventListener("DOMContentLoaded", initAll);
document.body.addEventListener("htmx:afterSwap", initAll);
function initFieldset(fieldset) {
var list;
var activeInput;
var selectedIdx;
var debounceTimer;
var hxPost =
fieldset.getAttribute("data-jury-hx-post") ||
"/admin/fragments/pill-search.php";
var role = fieldset.getAttribute("data-jury-role") || "";
var dropdown = fieldset.querySelector(".jury-suggestions");
if (!dropdown) {
dropdown = document.createElement("div");
dropdown.className = "jury-suggestions tag-search-suggestions";
dropdown.setAttribute("role", "listbox");
// Insert after the list container
list = fieldset.querySelector(".admin-jury-list");
if (list) {
list.insertAdjacentElement("afterend", dropdown);
} else {
fieldset.appendChild(dropdown);
}
}
// Click on suggestion → fill the active input
dropdown.addEventListener("click", (e) => {
var btn = e.target.closest(".tag-search-item");
if (!btn) return;
var name = (btn.getAttribute("data-tag-name") || "").trim();
if (!name || !activeInput) return;
activeInput.value = btn.classList.contains("tag-search-item--create")
? activeInput.value.trim()
: name;
dropdown.innerHTML = "";
selectedIdx = -1;
activeInput.focus();
});
// Highlighting helper
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);
}
}
fieldset.addEventListener("input", (e) => {
var inp = e.target.closest('input[type="text"]');
if (!inp) return;
activeInput = inp;
var q = inp.value.trim();
// Build the hx-include query — include hidden type=supervisor
var _typeInput = fieldset.querySelector(
'input[name="type"][value="supervisor"]',
);
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (q === "") {
dropdown.innerHTML = "";
selectedIdx = -1;
return;
}
// Manual HTMX POST
var xhr = new XMLHttpRequest();
xhr.open("POST", hxPost);
xhr.setRequestHeader(
"Content-Type",
"application/x-www-form-urlencoded",
);
xhr.setRequestHeader("HX-Request", "true");
xhr.onload = () => {
if (xhr.status === 200) {
dropdown.innerHTML = xhr.responseText;
selectedIdx = -1;
}
};
var params =
"type=supervisor&q=" +
encodeURIComponent(q) +
(role ? `&role=${encodeURIComponent(role)}` : "");
xhr.send(params);
}, 200);
});
// Keyboard navigation
fieldset.addEventListener("keydown", (e) => {
var items = dropdown.querySelectorAll(".tag-search-item");
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
if (items.length === 0) return;
e.preventDefault();
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") {
if (items.length > 0 && dropdown.innerHTML !== "") {
e.preventDefault();
if (selectedIdx >= 0 && selectedIdx < items.length) {
items[selectedIdx].click();
} else {
items[0].click();
}
}
} else if (e.key === "Escape") {
dropdown.innerHTML = "";
selectedIdx = -1;
}
});
// Close dropdown on outside click
document.addEventListener("click", (e) => {
if (!fieldset.contains(e.target)) {
dropdown.innerHTML = "";
selectedIdx = -1;
}
});
}
})();