From ed086c97028595a4f773b583525844c09733e561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Tue, 9 Jun 2026 11:37:41 +0200 Subject: [PATCH] Fix prefetch --- common/components/__init__.py | 2 + common/components/filters.py | 6 +-- common/components/search_select.py | 5 ++ common/input.css | 4 +- games/forms.py | 4 ++ games/static/base.css | 2 +- games/static/js/search_select.js | 82 +++++++++++++++++------------- 7 files changed, 64 insertions(+), 41 deletions(-) diff --git a/common/components/__init__.py b/common/components/__init__.py index d48b045..1e73f63 100644 --- a/common/components/__init__.py +++ b/common/components/__init__.py @@ -41,6 +41,7 @@ from common.components.primitives import ( paginated_table_content, ) from common.components.search_select import ( + DEFAULT_PREFETCH, FilterSelect, LabeledOption, SearchSelect, @@ -87,6 +88,7 @@ __all__ = [ "Popover", "PopoverTruncated", "SearchField", + "DEFAULT_PREFETCH", "FilterSelect", "LabeledOption", "SearchSelect", diff --git a/common/components/filters.py b/common/components/filters.py index 9068f70..83a126b 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -7,7 +7,7 @@ from django.utils.safestring import SafeText, mark_safe from common.components.core import Component from common.components.primitives import Label, Span -from common.components.search_select import FilterSelect, LabeledOption +from common.components.search_select import DEFAULT_PREFETCH, FilterSelect, LabeledOption class FilterChoice(NamedTuple): @@ -91,8 +91,6 @@ def _parse_bool(existing: dict, key: str) -> bool: # option set; model-backed fields fetch from a search endpoint on demand, with # labels embedded in the filter JSON so pills render without a DB round-trip. -_FILTER_PREFETCH = 20 - # Presence modifiers drive the pinned (Any)/(None) pseudo-options. They are # mutually exclusive with value pills (selecting one clears the value set). # Must match JS PRESENCE_MODIFIERS in search_select.js. @@ -189,7 +187,7 @@ def _model_filter( modifier=modifier, modifier_options=_modifier_options(nullable, m2m_modifiers), search_url=search_url, - prefetch=_FILTER_PREFETCH, + prefetch=DEFAULT_PREFETCH, ) diff --git a/common/components/search_select.py b/common/components/search_select.py index 843c63e..672b862 100644 --- a/common/components/search_select.py +++ b/common/components/search_select.py @@ -67,6 +67,11 @@ _NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden" # used to derive the panel's max-height from items_visible. _ROW_HEIGHT_REM = 2.25 +# Default number of rows to fetch on first focus when a search_url is set. +# Shared by filter and form widgets so the dropdown is populated for keyboard +# navigation as soon as the user opens it. +DEFAULT_PREFETCH = 20 + # ── FilterSelect styling ─────────────────────────────────────────────────── # Inline class strings (ported verbatim from the retired SelectableFilter CSS) # so the filter combobox is fully self-styled — nothing in input.css. JS-added diff --git a/common/input.css b/common/input.css index d299746..74fcab6 100644 --- a/common/input.css +++ b/common/input.css @@ -206,8 +206,8 @@ textarea:disabled { label { @apply mb-2.5 text-sm font-medium text-heading; } - input:not([type="checkbox"]) { - @apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body; + input:not([type="checkbox"]):not([data-search-select-search]) { + @apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body; } input[type="checkbox"] { @apply w-4 h-4 border border-default-medium rounded-xs bg-neutral-secondary-medium focus:ring-2 focus:ring-brand-soft; diff --git a/games/forms.py b/games/forms.py index 0101d09..1e43b90 100644 --- a/games/forms.py +++ b/games/forms.py @@ -3,6 +3,7 @@ from django.db import transaction from django.db.models import OuterRef, Subquery from common.components import ( + DEFAULT_PREFETCH, SearchSelect, SearchSelectOption, searchselect_selected, @@ -75,6 +76,7 @@ class SearchSelectWidget(forms.Widget): multi_select=False, items_visible=5, items_scroll=10, + prefetch=DEFAULT_PREFETCH, always_visible=False, placeholder="Search…", attrs=None, @@ -85,6 +87,7 @@ class SearchSelectWidget(forms.Widget): self.multi_select = multi_select self.items_visible = items_visible self.items_scroll = items_scroll + self.prefetch = prefetch self.always_visible = always_visible self.placeholder = placeholder @@ -107,6 +110,7 @@ class SearchSelectWidget(forms.Widget): multi_select=self.multi_select, items_visible=self.items_visible, items_scroll=self.items_scroll, + prefetch=self.prefetch, always_visible=self.always_visible, placeholder=self.placeholder, id=(attrs or {}).get("id", ""), diff --git a/games/static/base.css b/games/static/base.css index 67e498c..14ba31c 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -4370,7 +4370,7 @@ form input:disabled, select:disabled, textarea:disabled { font-weight: var(--font-weight-medium); color: var(--color-heading); } - input:not([type="checkbox"]) { + input:not([type="checkbox"]):not([data-search-select-search]) { margin-bottom: calc(var(--spacing) * 3); display: block; width: 100%; diff --git a/games/static/js/search_select.js b/games/static/js/search_select.js index b8ba7e8..0a96652 100644 --- a/games/static/js/search_select.js +++ b/games/static/js/search_select.js @@ -60,15 +60,29 @@ var pendingRequest = null; // in-flight AbortController, so newer queries win var hasPrefetched = false; + function hasVisibleContent() { + var optionRows = options.querySelectorAll("[data-search-select-option]"); + for (var 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() { - options.classList.remove("hidden"); + if (alwaysVisible || hasVisibleContent()) { + options.classList.remove("hidden"); + } } function hidePanel() { if (!alwaysVisible) options.classList.add("hidden"); } function setNoResults(visible) { - if (noResults) noResults.classList.toggle("hidden", !visible); + if (!noResults) return; + noResults.classList.toggle("hidden", !visible); + if (visible) showPanel(); } // ── Highlight tracking (filter mode) ── @@ -198,7 +212,7 @@ renderRows(items); // Re-apply the live query: the box may hold more text than was sent. setNoResults(filterRows(search.value.trim()) === 0); - if (isFilter) autoHighlight(search.value.trim()); + autoHighlight(search.value.trim()); }) .catch(function (error) { if (error && error.name === "AbortError") return; // superseded @@ -214,7 +228,6 @@ // so the client-side filter is authoritative. function runSearch() { var query = search.value.trim(); - showPanel(); if (searchUrl) { filterRows(query); setNoResults(false); @@ -225,7 +238,8 @@ } else { setNoResults(filterRows(query) === 0); } - if (isFilter) autoHighlight(query); + autoHighlight(query); + showPanel(); } // ── Single-select combobox: the search box shows the committed label; @@ -238,7 +252,6 @@ search.value = ""; container._searchSelectDirty = false; } - showPanel(); if (searchUrl) { if (prefetch && !hasPrefetched) { // Seed the window immediately on first open (not debounced). @@ -248,12 +261,13 @@ // Show whatever is already loaded; the server decides no-results. filterRows(search.value.trim()); setNoResults(false); - if (isFilter) autoHighlight(search.value.trim()); + autoHighlight(search.value.trim()); } } else { setNoResults(filterRows(search.value.trim()) === 0); - if (isFilter) autoHighlight(search.value.trim()); + autoHighlight(search.value.trim()); } + showPanel(); }); search.addEventListener("input", function () { clearHighlight(); @@ -278,42 +292,42 @@ }); } - // ── Keyboard navigation (filter mode) ── + // ── Keyboard navigation (both form and filter modes) ── search.addEventListener("keydown", function (event) { - if (!isFilter) return; var key = event.key; - if (key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === "Escape") { - var visible = getVisibleOptions(); - if (visible.length === 0) { - if (key === "Escape") hidePanel(); - return; - } + if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") return; + var visible = getVisibleOptions(); + if (visible.length === 0) { + if (key === "Escape") hidePanel(); + return; + } - if (key === "ArrowDown") { + if (key === "ArrowDown") { + event.preventDefault(); + showPanel(); + var downIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1; + highlightOption(visible[(downIdx + 1) % visible.length]); + } else if (key === "ArrowUp") { + event.preventDefault(); + showPanel(); + var upIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1; + highlightOption(visible[(upIdx - 1 + visible.length) % visible.length]); + } else if (key === "Enter") { + if (highlightedRow) { event.preventDefault(); - showPanel(); - var idx = highlightedRow ? visible.indexOf(highlightedRow) : -1; - var next = visible[(idx + 1) % visible.length]; - highlightOption(next); - } else if (key === "ArrowUp") { - event.preventDefault(); - showPanel(); - var idx = highlightedRow ? visible.indexOf(highlightedRow) : -1; - var prev = visible[(idx - 1 + visible.length) % visible.length]; - highlightOption(prev); - } else if (key === "Enter") { - if (highlightedRow) { - event.preventDefault(); - var option = optionFromRow(highlightedRow); + var option = optionFromRow(highlightedRow); + if (isFilter) { addFilterPill(option, "include"); search.value = ""; - clearHighlight(); - hidePanel(); + } else { + selectOption(option); } - } else if (key === "Escape") { clearHighlight(); hidePanel(); } + } else if (key === "Escape") { + clearHighlight(); + hidePanel(); } });