mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 08:09:18 +02:00
- 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)
153 lines
4.5 KiB
JavaScript
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;
|
|
}
|
|
});
|
|
}
|
|
})();
|