/** * SearchSelect widget — a search box paired with a dropdown of options. * Multi-select renders chosen items as removable pills (inline with the search * box), each backed by a hidden . Single-select renders no pill: the * committed label lives inside the search box (which doubles as a combobox — * focus clears it to search, picking an option fills it), with a lone hidden * carrying the value. Both keep hidden inputs so Django validation works. * * Filter mode (data-ss-mode="filter", rendered by FilterSelect): value rows * carry +/− buttons that add include (✓) / exclude (✗) pills, plus pinned * modifier pseudo-options ((Any)/(None)) that are mutually exclusive with value * pills. Filter widgets have no hidden inputs; readSearchSelect serialises their * state into data-included / data-excluded / data-modifier for the filter bar. * * 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 / FilterSelect components so Tailwind generates the classes * and server-rendered and JS-created rows/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"; // Filter-mode class strings — byte-identical to the FilterSelect constants in // common/components/search_select.py. var FILTER_INCLUDE_PILL_CLASS = "inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded " + "bg-brand/15 text-heading"; var FILTER_EXCLUDE_PILL_CLASS = "inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded " + "bg-red-500/15 text-red-600 line-through decoration-red-400"; var FILTER_MODIFIER_PILL_CLASS = "inline-flex items-center px-2 py-0.5 text-sm rounded " + "bg-amber-500/15 text-amber-600 cursor-pointer"; var FILTER_OPTION_ROW_CLASS = "flex items-center justify-between px-2 py-1 rounded text-sm " + "hover:bg-neutral-secondary-strong cursor-pointer"; var FILTER_OPTION_LABEL_CLASS = "truncate"; var FILTER_OPTION_BUTTONS_CLASS = "flex gap-1 ml-2 shrink-0"; var FILTER_ACTION_BUTTON_CLASS = "w-5 h-5 flex items-center justify-center text-xs font-bold rounded " + "border border-default-medium hover:bg-brand hover:text-white hover:border-brand"; 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 isFilter = container.getAttribute("data-ss-mode") === "filter"; 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 prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0; var syncUrl = container.getAttribute("data-sync-url") === "true"; var noResults = options.querySelector("[data-ss-no-results]"); var debounceTimer = null; var pendingRequest = null; // in-flight AbortController, so newer queries win var hasPrefetched = false; 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 (row) { row.remove(); }); items.slice(0, itemsScroll).forEach(function (item) { options.insertBefore(buildRow(item), noResults || null); }); showPanel(); } function buildRow(option) { if (isFilter) return buildFilterOptionRow(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; } // ── Filter-mode value row: label + include/exclude buttons (mirrors the // Python _filter_option_row so fetched and server-rendered rows match). ── function buildFilterOptionRow(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 = FILTER_OPTION_ROW_CLASS; var data = option.data || {}; Object.keys(data).forEach(function (key) { row.setAttribute("data-" + key, data[key]); }); var labelSpan = document.createElement("span"); labelSpan.className = FILTER_OPTION_LABEL_CLASS; labelSpan.textContent = option.label; var buttons = document.createElement("span"); buttons.className = FILTER_OPTION_BUTTONS_CLASS; buttons.appendChild(buildActionButton("include", "+", "Include")); buttons.appendChild(buildActionButton("exclude", "−", "Exclude")); row.appendChild(labelSpan); row.appendChild(buttons); row._ssOption = option; return row; } function buildActionButton(action, symbol, title) { var button = document.createElement("button"); button.type = "button"; button.setAttribute("data-ss-action", action); button.className = FILTER_ACTION_BUTTON_CLASS; button.title = title; button.textContent = symbol; return button; } // ── Client-side filter of the currently loaded rows. Returns the number of // visible rows so the caller decides whether to show the no-results node. ── function filterRows(query) { var lower = query.toLowerCase(); var visibleCount = 0; 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) visibleCount += 1; }); return visibleCount; } // ── Fetch matching rows from the server. The previous in-flight request is // aborted so a slower earlier response can never overwrite a newer one. ── function fetchFromServer(query) { if (pendingRequest) pendingRequest.abort(); pendingRequest = new AbortController(); var url = searchUrl + "?q=" + encodeURIComponent(query); if (prefetch && !query) url += "&limit=" + prefetch; fetch(url, { credentials: "same-origin", signal: pendingRequest.signal }) .then(function (response) { return response.json(); }) .then(function (items) { pendingRequest = null; renderRows(items); // Re-apply the live query: the box may hold more text than was sent. setNoResults(filterRows(search.value.trim()) === 0); }) .catch(function (error) { if (error && error.name === "AbortError") return; // superseded pendingRequest = null; setNoResults(true); }); } // Called on every keystroke. With a search_url, filter the loaded window // instantly (zero latency) and debounce a server request for the rest; // no-results stays hidden until the response decides it, to avoid a flash // over an incomplete window. Without a search_url the loaded set is complete, // so the client-side filter is authoritative. function runSearch() { var query = search.value.trim(); showPanel(); if (searchUrl) { filterRows(query); setNoResults(false); clearTimeout(debounceTimer); debounceTimer = setTimeout(function () { fetchFromServer(query); }, DEBOUNCE_MS); } else { setNoResults(filterRows(query) === 0); } } // ── Single-select combobox: the search box shows the committed label; // focusing clears it to search, blurring restores it (or deselects). ── if (!multi) container._ssLabel = search.value; search.addEventListener("focus", function () { if (!multi) { // Hide the committed label so the box becomes a fresh search field. search.value = ""; container._ssDirty = false; } showPanel(); if (searchUrl) { if (prefetch && !hasPrefetched) { // Seed the window immediately on first open (not debounced). hasPrefetched = true; fetchFromServer(""); } else { // Show whatever is already loaded; the server decides no-results. filterRows(search.value.trim()); setNoResults(false); } } else { setNoResults(filterRows(search.value.trim()) === 0); } }); search.addEventListener("input", function () { if (!multi) container._ssDirty = true; runSearch(); }); if (!multi) { search.addEventListener("blur", function () { // Defer so an option click (which fires before blur settles) wins. setTimeout(function () { if (container._ssDirty && search.value.trim() === "") { // User intentionally cleared the box → deselect. pills.innerHTML = ""; container._ssLabel = ""; emitChange(null); } else { // Focused-and-left, or typed a partial query without picking → // restore the committed label (no-op right after a selection). search.value = container._ssLabel || ""; } }, 120); }); } // Clicking an option must not blur the input before the click selects. options.addEventListener("mousedown", function (e) { e.preventDefault(); }); // ── Option click → select (form mode) or include/exclude (filter mode) ── options.addEventListener("click", function (e) { if (isFilter) { handleFilterOptionClick(e); return; } var row = e.target.closest("[data-ss-option]"); if (!row) return; selectOption(optionFromRow(row)); }); function handleFilterOptionClick(e) { // Pinned modifier pseudo-option → set the (mutually exclusive) modifier. var modifierRow = e.target.closest("[data-ss-modifier-option]"); if (modifierRow) { setModifier( modifierRow.getAttribute("data-ss-modifier-option"), modifierRow.getAttribute("data-label") ); return; } // Include / exclude button on a value row. var button = e.target.closest("[data-ss-action]"); if (!button) return; var row = button.closest("[data-ss-option]"); if (!row) return; addFilterPill(optionFromRow(row), button.getAttribute("data-ss-action")); } // Add (or re-type) an include/exclude pill for a value. Selecting any value // clears an active modifier — the two are mutually exclusive. function addFilterPill(option, kind) { clearModifier(); var existing = pills.querySelector( '[data-pill][data-value="' + cssEscape(option.value) + '"]' ); if (existing) existing.remove(); pills.appendChild(buildFilterValuePill(option, kind)); emitChange(null); } function buildFilterValuePill(option, kind) { var pill = document.createElement("span"); pill.className = kind === "include" ? FILTER_INCLUDE_PILL_CLASS : FILTER_EXCLUDE_PILL_CLASS; pill.setAttribute("data-pill", ""); pill.setAttribute("data-value", option.value); pill.setAttribute("data-label", option.label); pill.setAttribute("data-ss-type", kind); var data = option.data || {}; Object.keys(data).forEach(function (key) { pill.setAttribute("data-" + key, data[key]); }); var symbol = kind === "include" ? "✓" : "✗"; pill.appendChild(document.createTextNode(symbol + " " + option.label)); pill.appendChild(buildRemoveButton()); return pill; } // Set the lone modifier pill, clearing all value pills (mutual exclusivity). function setModifier(modifierValue, label) { pills.innerHTML = ""; var pill = document.createElement("span"); pill.className = FILTER_MODIFIER_PILL_CLASS; pill.setAttribute("data-pill", ""); pill.setAttribute("data-ss-modifier", modifierValue); pill.appendChild(document.createTextNode(label)); pill.appendChild(buildRemoveButton()); pills.appendChild(pill); container.setAttribute("data-modifier", modifierValue); hidePanel(); emitChange(null); } function clearModifier() { var modifierPill = pills.querySelector("[data-ss-modifier]"); if (modifierPill) modifierPill.remove(); container.removeAttribute("data-modifier"); } function buildRemoveButton() { 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 = "×"; return remove; } 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 { // Single-select: no pill — show the label in the search box and keep a // lone hidden input under [data-ss-pills] for submission. pills.innerHTML = ""; pills.appendChild(buildHidden(option.value)); search.value = option.label; container._ssLabel = option.label; container._ssDirty = false; 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; if (isFilter) { // Filter pills have no hidden input; a modifier pill also clears the // container flag. if (pill.hasAttribute("data-ss-modifier")) { container.removeAttribute("data-modifier"); } pill.remove(); emitChange(null); 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, "\\$&"); } // Serialise each widget's current state onto data-* attributes for the caller. // Form widgets expose data-values (the submitted hidden-input values); filter // widgets (parallel to readSelectableFilters) expose data-included / // data-excluded / data-modifier for the filter bar to read. window.readSearchSelect = function (form) { form.querySelectorAll("[data-search-select]").forEach(function (container) { var pills = container.querySelector("[data-ss-pills]"); if (container.getAttribute("data-ss-mode") === "filter") { var included = []; var excluded = []; var modifier = ""; if (pills) { pills.querySelectorAll("[data-pill]").forEach(function (pill) { var pillModifier = pill.getAttribute("data-ss-modifier"); if (pillModifier) { modifier = pillModifier; return; } var value = pill.getAttribute("data-value"); if (pill.getAttribute("data-ss-type") === "exclude") { excluded.push(value); } else { included.push(value); } }); } container.setAttribute("data-included", JSON.stringify(included)); container.setAttribute("data-excluded", JSON.stringify(excluded)); if (modifier) container.setAttribute("data-modifier", modifier); else container.removeAttribute("data-modifier"); return; } 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); })();