Files
timetracker/games/static/js/search_select.js
T
lukas afc16aabbb
Django CI/CD / test (push) Successful in 40s
Django CI/CD / build-and-push (push) Successful in 1m24s
Implement search select component
2026-06-06 22:52:26 +02:00

278 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SearchSelect widget — a search box paired with a dropdown of options.
* Single/multi select; chosen items render as removable pills, each backed by a
* hidden <input> so existing Django form validation keeps working.
*
* Mirrors selectable_filter.js: initAll() on DOMContentLoaded + htmx:afterSwap,
* each widget guarded with el._ssInit.
*
* The pill / option class strings below are kept byte-identical to the Python
* Pill / SearchSelect components so Tailwind generates the classes and
* server-rendered and JS-created pills are indistinguishable.
*/
(function () {
"use strict";
var PILL_CLASS =
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded " +
"bg-brand/15 text-heading";
var PILL_REMOVE_CLASS =
"ml-1 text-body hover:text-heading font-bold cursor-pointer";
var OPTION_ROW_CLASS =
"px-3 py-2 text-sm text-heading cursor-pointer hover:bg-brand/15";
var DEBOUNCE_MS = 500;
function initAll() {
document.querySelectorAll("[data-search-select]").forEach(function (el) {
if (el._ssInit) return;
el._ssInit = true;
initWidget(el);
});
}
function initWidget(container) {
var search = container.querySelector("[data-ss-search]");
var options = container.querySelector("[data-ss-options]");
var pills = container.querySelector("[data-ss-pills]");
if (!search || !options || !pills) return;
var name = container.getAttribute("data-name");
var searchUrl = container.getAttribute("data-search-url");
var multi = container.getAttribute("data-multi") === "true";
var alwaysVisible = container.getAttribute("data-always-visible") === "true";
var itemsScroll = parseInt(container.getAttribute("data-items-scroll"), 10) || 10;
var syncUrl = container.getAttribute("data-sync-url") === "true";
var noResults = options.querySelector("[data-ss-no-results]");
var debounceTimer = null;
function showPanel() {
options.classList.remove("hidden");
}
function hidePanel() {
if (!alwaysVisible) options.classList.add("hidden");
}
function setNoResults(visible) {
if (noResults) noResults.classList.toggle("hidden", !visible);
}
// ── Render server-fetched rows into the panel ──
function renderRows(items) {
options.querySelectorAll("[data-ss-option]").forEach(function (r) {
r.remove();
});
items.slice(0, itemsScroll).forEach(function (item) {
options.insertBefore(buildRow(item), noResults || null);
});
setNoResults(items.length === 0);
showPanel();
}
function buildRow(option) {
var row = document.createElement("div");
row.setAttribute("data-ss-option", "");
row.setAttribute("data-value", option.value);
row.setAttribute("data-label", option.label);
row.className = OPTION_ROW_CLASS;
var data = option.data || {};
Object.keys(data).forEach(function (key) {
row.setAttribute("data-" + key, data[key]);
});
row.textContent = option.label;
row._ssOption = option;
return row;
}
// ── Client-side filter of pre-rendered rows ──
function filterRows(q) {
var lower = q.toLowerCase();
var anyVisible = false;
options.querySelectorAll("[data-ss-option]").forEach(function (item) {
var label = (item.getAttribute("data-label") || "").toLowerCase();
var match = label.indexOf(lower) !== -1;
item.style.display = match ? "" : "none";
if (match) anyVisible = true;
});
setNoResults(!anyVisible);
showPanel();
}
function runSearch() {
var q = search.value.trim();
if (searchUrl && q) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function () {
fetch(searchUrl + "?q=" + encodeURIComponent(q), {
credentials: "same-origin",
})
.then(function (r) {
return r.json();
})
.then(renderRows)
.catch(function () {
setNoResults(true);
});
}, DEBOUNCE_MS);
} else {
filterRows(q);
}
}
search.addEventListener("focus", runSearch);
search.addEventListener("input", runSearch);
// ── Option click → select ──
options.addEventListener("click", function (e) {
var row = e.target.closest("[data-ss-option]");
if (!row) return;
var option = optionFromRow(row);
selectOption(option);
});
function optionFromRow(row) {
if (row._ssOption) return row._ssOption;
var data = {};
Object.keys(row.dataset).forEach(function (key) {
if (key !== "value" && key !== "label" && key !== "ssOption") {
data[key] = row.dataset[key];
}
});
return {
value: row.getAttribute("data-value"),
label: row.getAttribute("data-label"),
data: data,
};
}
function selectOption(option) {
if (multi) {
if (!pills.querySelector('input[value="' + cssEscape(option.value) + '"]')) {
addPill(option);
}
} else {
pills.innerHTML = "";
addPill(option);
search.value = option.label;
hidePanel();
}
emitChange(option);
}
function addPill(option) {
pills.appendChild(buildPill(option));
pills.appendChild(buildHidden(option.value));
}
function buildPill(option) {
var pill = document.createElement("span");
pill.className = PILL_CLASS;
pill.setAttribute("data-pill", "");
pill.setAttribute("data-value", option.value);
var data = option.data || {};
Object.keys(data).forEach(function (key) {
pill.setAttribute("data-" + key, data[key]);
});
pill.appendChild(document.createTextNode(option.label));
var remove = document.createElement("button");
remove.type = "button";
remove.setAttribute("data-pill-remove", "");
remove.className = PILL_REMOVE_CLASS;
remove.setAttribute("aria-label", "Remove");
remove.textContent = "×";
pill.appendChild(remove);
return pill;
}
function buildHidden(value) {
var input = document.createElement("input");
input.type = "hidden";
input.name = name;
input.value = value;
return input;
}
// ── Pill × → remove ──
pills.addEventListener("click", function (e) {
var removeBtn = e.target.closest("[data-pill-remove]");
if (!removeBtn) return;
var pill = removeBtn.closest("[data-pill]");
if (!pill) return;
var value = pill.getAttribute("data-value");
pill.remove();
var hidden = pills.querySelector('input[value="' + cssEscape(value) + '"]');
if (hidden) hidden.remove();
emitChange(null);
});
function currentValues() {
return Array.prototype.map.call(
pills.querySelectorAll('input[type="hidden"]'),
function (input) {
return input.value;
}
);
}
function emitChange(last) {
var values = currentValues();
if (syncUrl) syncToUrl(values);
container.dispatchEvent(
new CustomEvent("search-select:change", {
bubbles: true,
detail: { name: name, values: values, last: last },
})
);
}
function syncToUrl(values) {
var params = new URLSearchParams(window.location.search);
params.delete(name);
values.forEach(function (v) {
params.append(name, v);
});
var qs = params.toString();
history.replaceState(null, "", qs ? "?" + qs : window.location.pathname);
}
// On init, restore from URL params if the server supplied no selected pills.
if (syncUrl && !pills.querySelector("[data-pill]")) {
var initial = new URLSearchParams(window.location.search).getAll(name);
initial.forEach(function (v) {
addPill({ value: v, label: v, data: {} });
});
}
// ── Close panel on outside click ──
document.addEventListener("click", function (e) {
if (!container.contains(e.target)) hidePanel();
});
}
/** Minimal escape for use inside an attribute-value selector. */
function cssEscape(value) {
return String(value).replace(/["\\]/g, "\\$&");
}
// Forward-looking hook (parallels readSelectableFilters): write each widget's
// current values to a data-values JSON attribute.
window.readSearchSelect = function (form) {
form.querySelectorAll("[data-search-select]").forEach(function (container) {
var pills = container.querySelector("[data-ss-pills]");
var values = pills
? Array.prototype.map.call(
pills.querySelectorAll('input[type="hidden"]'),
function (input) {
return input.value;
}
)
: [];
container.setAttribute("data-values", JSON.stringify(values));
});
};
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
})();