diff --git a/games/static/js/search_select.js b/games/static/js/search_select.js index 0a96652..6303751 100644 --- a/games/static/js/search_select.js +++ b/games/static/js/search_select.js @@ -21,158 +21,190 @@ * and data-* attributes — so all markup and Tailwind class strings live in one * place (the Python components), never duplicated here. */ -(function () { +(() => { "use strict"; - var DEBOUNCE_MS = 100; + const DEBOUNCE_MS = 100; // Must match Python common/components/filters.py:_PRESENCE_MODIFIERS. // These modifiers are mutually exclusive with value pills — selecting // one clears all value pills. Non-presence modifiers (INCLUDES_ALL, // INCLUDES_ONLY) coexist with value pills. - var PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"]; + const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"]; - function initAll() { - document.querySelectorAll("[data-search-select]").forEach(function (element) { + const initAll = () => { + document.querySelectorAll("[data-search-select]").forEach(element => { if (element._searchSelectInit) return; element._searchSelectInit = true; initWidget(element); }); - } + }; - function initWidget(container) { - var search = container.querySelector("[data-search-select-search]"); - var options = container.querySelector("[data-search-select-options]"); - var pills = container.querySelector("[data-search-select-pills]"); + const initWidget = (container) => { + const search = container.querySelector("[data-search-select-search]"); + const options = container.querySelector("[data-search-select-options]"); + const pills = container.querySelector("[data-search-select-pills]"); if (!search || !options || !pills) return; - var name = container.getAttribute("data-name"); - var searchUrl = container.getAttribute("data-search-url"); - var isFilter = container.getAttribute("data-search-select-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"; + const name = container.getAttribute("data-name"); + const searchUrl = container.getAttribute("data-search-url"); + const isFilter = container.getAttribute("data-search-select-mode") === "filter"; + const multi = container.getAttribute("data-multi") === "true"; + const alwaysVisible = container.getAttribute("data-always-visible") === "true"; + const prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0; + const syncUrl = container.getAttribute("data-sync-url") === "true"; - var noResults = options.querySelector("[data-search-select-no-results]"); - var debounceTimer = null; - var pendingRequest = null; // in-flight AbortController, so newer queries win - var hasPrefetched = false; + const noResults = options.querySelector("[data-search-select-no-results]"); + let debounceTimer = null; + let pendingRequest = null; // in-flight AbortController, so newer queries win + let hasPrefetched = false; - function hasVisibleContent() { - var optionRows = options.querySelectorAll("[data-search-select-option]"); - for (var i = 0; i < optionRows.length; i++) { + const hasVisibleContent = () => { + const optionRows = options.querySelectorAll("[data-search-select-option]"); + for (let i = 0; i < optionRows.length; i++) { if (optionRows[i].style.display !== "none") return true; } if (noResults && !noResults.classList.contains("hidden")) return true; if (options.querySelector("[data-search-select-modifier-option]")) return true; return false; - } + }; - function showPanel() { + const showPanel = () => { if (alwaysVisible || hasVisibleContent()) { options.classList.remove("hidden"); } - } - function hidePanel() { + }; + const hidePanel = () => { if (!alwaysVisible) options.classList.add("hidden"); - } + }; - function setNoResults(visible) { + const setNoResults = (visible) => { if (!noResults) return; noResults.classList.toggle("hidden", !visible); if (visible) showPanel(); - } + }; // ── Highlight tracking (filter mode) ── - var highlightedRow = null; + let highlightedRow = null; - function highlightOption(row) { + const highlightOption = (row) => { clearHighlight(); if (!row) return; row.setAttribute("data-search-select-highlighted", ""); highlightedRow = row; row.scrollIntoView({ block: "nearest" }); - } + }; - function clearHighlight() { + const clearHighlight = () => { if (highlightedRow) { highlightedRow.removeAttribute("data-search-select-highlighted"); highlightedRow = null; } - } + }; - function getVisibleOptions() { - var all = options.querySelectorAll("[data-search-select-option]"); - return Array.prototype.filter.call(all, function (row) { - return row.style.display !== "none"; - }); - } + const getVisibleOptions = () => { + const all = options.querySelectorAll("[data-search-select-option]"); + return Array.from(all).filter(row => row.style.display !== "none"); + }; - function autoHighlight(query) { - var visible = getVisibleOptions(); + const autoHighlight = (query) => { + const visible = getVisibleOptions(); if (visible.length === 0) { clearHighlight(); return; } - var lower = query.toLowerCase(); + const lower = query.toLowerCase(); // 1. Starts-with match - for (var i = 0; i < visible.length; i++) { - var label = (visible[i].getAttribute("data-label") || "").toLowerCase(); + for (let i = 0; i < visible.length; i++) { + const label = (visible[i].getAttribute("data-label") || "").toLowerCase(); if (lower && label.startsWith(lower)) { highlightOption(visible[i]); return; } } // 2. Substring match (fuzzy-lite) - for (var j = 0; j < visible.length; j++) { - var subLabel = (visible[j].getAttribute("data-label") || "").toLowerCase(); - if (lower && subLabel.indexOf(lower) !== -1) { + for (let j = 0; j < visible.length; j++) { + const subLabel = (visible[j].getAttribute("data-label") || "").toLowerCase(); + if (lower && subLabel.includes(lower)) { highlightOption(visible[j]); return; } } // 3. Fallback: first visible option highlightOption(visible[0]); - } + }; + + // Get active values in both form and filter modes + const getSelectedValues = () => { + const vals = new Set(); + pills.querySelectorAll('input[type="hidden"]').forEach(input => { + vals.add(input.value); + }); + pills.querySelectorAll("[data-pill]").forEach(pill => { + const val = pill.getAttribute("data-value"); + if (val) vals.add(val); + }); + return vals; + }; // ── Render server-fetched rows into the panel ── - function renderRows(items) { - options.querySelectorAll("[data-search-select-option]").forEach(function (row) { + const renderRows = (items) => { + const selectedVals = getSelectedValues(); + const preservedOptions = []; + + // Extract existing option data for currently selected values before removing + options.querySelectorAll("[data-search-select-option]").forEach(row => { + const val = row.getAttribute("data-value"); + if (selectedVals.has(val)) { + preservedOptions.push(optionFromRow(row)); + } row.remove(); }); - items.slice(0, itemsScroll).forEach(function (item) { - options.insertBefore(buildRow(item), noResults || null); + + const renderedValues = new Set(); + + // Render preserved options first (to keep them at the top) + preservedOptions.forEach(opt => { + options.insertBefore(buildRow(opt), noResults || null); + renderedValues.add(String(opt.value)); }); + + // Render newly fetched items (excluding already rendered preserved ones) + // Fix DOM-limit vs fetch mismatch: Do not slice the items, render all returned items. + items.forEach(item => { + if (!renderedValues.has(String(item.value))) { + options.insertBefore(buildRow(item), noResults || null); + renderedValues.add(String(item.value)); + } + }); + showPanel(); - } + }; // ── Clone a server-rendered