diff --git a/games/static/js/search_select.js b/games/static/js/search_select.js index e19261a..813881f 100644 --- a/games/static/js/search_select.js +++ b/games/static/js/search_select.js @@ -6,12 +6,18 @@ * 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 components so Tailwind generates the classes and - * server-rendered and JS-created pills are indistinguishable. + * Pill / SearchSelect / FilterSelect components so Tailwind generates the classes + * and server-rendered and JS-created rows/pills are indistinguishable. */ (function () { "use strict"; @@ -24,6 +30,26 @@ 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() { @@ -42,6 +68,7 @@ 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; @@ -76,6 +103,7 @@ } function buildRow(option) { + if (isFilter) return buildFilterOptionRow(option); var row = document.createElement("div"); row.setAttribute("data-ss-option", ""); row.setAttribute("data-value", option.value); @@ -90,6 +118,44 @@ 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) { @@ -200,14 +266,96 @@ e.preventDefault(); }); - // ── Option click → select ── + // ── 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; - var option = optionFromRow(row); - selectOption(option); + 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 = {}; @@ -280,6 +428,16 @@ 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) + '"]'); @@ -336,11 +494,38 @@ return String(value).replace(/["\\]/g, "\\$&"); } - // Forward-looking hook (parallels readSelectableFilters): write each widget's - // current values to a data-values JSON attribute. + // 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"]'),