/** * 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 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); })();