aboutsummaryrefslogtreecommitdiff
path: root/cockpit/js/selection.js
diff options
context:
space:
mode:
authorFlorian Fischer <florian.fischer@muhq.space>2026-06-10 15:25:51 +0200
committerFlorian Fischer <florian.fischer@muhq.space>2026-06-10 15:25:51 +0200
commit159b0fdc75dccecdf6b33b98a3eca6072ae33a83 (patch)
tree48d58a75e41bc7f97ca7965d672ddc3d631734d5 /cockpit/js/selection.js
parent66824db4120c5b61d167aeecf37df13785dc9481 (diff)
downloadbycs-sol-159b0fdc75dccecdf6b33b98a3eca6072ae33a83.tar.gz
bycs-sol-159b0fdc75dccecdf6b33b98a3eca6072ae33a83.zip
generalize our selection component
Diffstat (limited to 'cockpit/js/selection.js')
-rw-r--r--cockpit/js/selection.js167
1 files changed, 167 insertions, 0 deletions
diff --git a/cockpit/js/selection.js b/cockpit/js/selection.js
new file mode 100644
index 0000000..a1da244
--- /dev/null
+++ b/cockpit/js/selection.js
@@ -0,0 +1,167 @@
+/**
+ * Initialises a selection based on existing HTML elements.
+ * For HTML elements are required:
+ * - A text input with id $idPrefix
+ * - A list with id ${idPrefix}-list
+ * - An element shown if no results are available with id ${idPrefix}-noresults
+ * - A button to clear the input with id ${idPrefix}-clear
+ *
+ * @param {string} idPrefix - The id prefix of the four HTML elements.
+ * @param {async function} fetchCandidates - The async function to retrieve the selectable candidates.
+ * @param {function} fmtCandidate - The function to format the displayed HTML for each candidate.
+ * @param {function} onSelection - The function to call when a candidate is selected.
+ */
+async function initSelection(idPrefix, fetchCandidates, fmtCandidate, onSelection) {
+ const sel = document.getElementById(idPrefix);
+ const list = document.getElementById(`${idPrefix}-list`);
+ const noResults = document.getElementById(`${idPrefix}-noresults`);
+ const clearBtn = document.getElementById(`${idPrefix}-clear`);
+
+ const candidates = await fetchCandidates();
+
+ let items = [];
+
+ let currentFocus = -1;
+ let visibleItems = [];
+ function setActive() {
+ visibleItems.forEach(item => item.classList.remove("active"));
+ if (currentFocus >= 0 && visibleItems[currentFocus]) {
+ visibleItems[currentFocus].classList.add("active");
+ }
+ }
+
+ for (const candidate of candidates) {
+ let item = document.createElement("li");
+ item.candidate = candidate;
+ item.innerHTML = fmtCandidate(candidate);
+ item.style.display = 'none';
+
+ item.addEventListener('click', function() {
+ sel.value = "";
+ sel.dispatchEvent(new Event('keyup'));
+ onSelection(candidate);
+ });
+
+ list.appendChild(item);
+ items.push(item);
+ }
+
+ sel.addEventListener('keyup', function(e) {
+ if ((["ArrowDown", "ArrowUp", "Enter"]).includes(e.key))
+ return;
+
+ const filter = sel.value.toLowerCase();
+ visibleItems = [];
+ for (const item of items) {
+ const itemText = item.textContent.trim().toLowerCase();
+
+ if (itemText.indexOf(filter) > -1 && filter !== "") {
+ item.style.display = "";
+ visibleItems.push(item);
+
+ // Highlight matched text
+ const regex = new RegExp(`(${filter})`, 'gi');
+ item.innerHTML = item.textContent.replace(
+ regex,
+ "<u class='student-selection-list-highlight'>$1</u>"
+ );
+ } else {
+ item.style.display = "none";
+ }
+ }
+
+ // Show the clear button if there is an input
+ clearBtn.style.display = sel.value ? "block" : "none";
+
+ // Select the first or none if there are no items visible
+ currentFocus = visibleItems.length ? 0 : -1;
+ setActive(visibleItems);
+ noResults.style.display = visibleItems.length || filter === "" ? "none" : "block";
+ });
+
+ clearBtn.addEventListener('click', function() {
+ sel.value = "";
+ sel.dispatchEvent(new Event('keyup')); // reset list
+ sel.focus();
+ });
+
+ sel.addEventListener('keydown', function(e) {
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
+ if(e.key === "ArrowDown")
+ currentFocus = (currentFocus + 1) % visibleItems.length;
+ else
+ currentFocus = (currentFocus - 1 + visibleItems.length) % visibleItems.length;
+ setActive();
+ e.preventDefault();
+ } else if (e.key === "Enter") {
+ if (currentFocus > -1 && visibleItems[currentFocus]) {
+
+ const visibleItem = visibleItems[currentFocus];
+ sel.value = "";
+ sel.dispatchEvent(new Event('keyup'));
+ onSelection(visibleItem.candidate);
+ }
+ e.preventDefault();
+ }
+ });
+}
+
+function pointClickHandler(color) {
+ const textareaDiv = document.getElementById("student-info-point-reason-container");
+ const textarea = document.getElementById("student-info-point-reason");
+ const submitBtn = document.getElementById("student-info-point-reason-submit");
+
+ // TODO(muhq): Prefill the textarea with common reasons
+ switch (color) {
+ case "green":
+ textarea.style.background = "rgb(from green r g b / 20%)"
+ textarea.value = "";
+ break;
+ case "orange":
+ textarea.style.background = "rgb(from orange r g b / 20%)"
+ textarea.value = "";
+ break;
+ case "red":
+ textarea.style.background = "rgb(from red r g b / 20%)"
+ textarea.value = "";
+ break;
+ }
+ textareaDiv.style.display = "block";
+ textarea.focus();
+
+ // Can not use string interpolation because of Moodle's firstname filter.
+ const coach = firstname + " " + lastname;
+
+ let submit = function() {
+ let change;
+ switch (color) {
+ case "green":
+ change = GraduationChange.newGreenPointChange(student, textarea.value, coach);
+ break;
+ case "orange":
+ change = GraduationChange.newOrangePointChange(student, textarea.value, coach);
+ break;
+ case "red":
+ change = GraduationChange.newRedPointChange(student, textarea.value, coach);
+ break;
+ default:
+ alert("Invalid color");
+ break;
+ }
+ submitGraduationChange(student, change);
+ textareaDiv.style.display = "none";
+ updateStudentInfo();
+ }
+ // Use onEVENT to override any previous handler
+ textarea.onkeydown = function(e) {
+ if (e.shiftKey && e.key === "Enter") {
+ submit();
+ }
+ if (e.key === "Escape") {
+ textareaDiv.style.display = "none";
+ }
+ };
+ submitBtn.onclick = submit;
+}
+
+export { initSelection };