Convert search_select.js to TypeScript (issue #17)

- Add ts/search_select.ts: typed port of the SearchSelect/FilterSelect widget.
  Exports SearchSelectOption / SearchSelectChangeDetail as the single source of
  truth for the "search-select:change" event contract
- add_purchase.ts now imports those types via `import type` (no runtime
  coupling), instead of redefining them locally
- Declare window.readSearchSelect in ts/globals.d.ts
- Point the SearchSelect component Media and every view/e2e/test reference at
  the compiled dist/search_select.js
- Update doc comments in common/components/search_select.py to name the TS source

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 13:34:06 +02:00
parent daae9b8944
commit 541fb550ab
17 changed files with 165 additions and 139 deletions
+5 -4
View File
@@ -6,7 +6,8 @@ hidden ``<input>`` so an existing ``ModelMultipleChoiceField`` keeps validating.
This module imports only from ``common.components`` — it has no Django-forms or This module imports only from ``common.components`` — it has no Django-forms or
``games`` knowledge. Styling is inline Tailwind utilities; behavioural hooks are ``games`` knowledge. Styling is inline Tailwind utilities; behavioural hooks are
``data-*`` attributes wired up by ``games/static/js/search_select.js``. ``data-*`` attributes wired up by ``ts/search_select.ts`` (compiled to
``games/static/js/dist/search_select.js``).
Option sourcing follows two axes. *Population*: options are either rendered Option sourcing follows two axes. *Population*: options are either rendered
inline up front (``options=``, no ``search_url``) or fetched from ``search_url``. inline up front (``options=``, no ``search_url``) or fetched from ``search_url``.
@@ -25,8 +26,8 @@ from typing import TypedDict
from common.components.core import Attributes, Element, HTMLAttribute, Media, Node from common.components.core import Attributes, Element, HTMLAttribute, Media, Node
from common.components.primitives import Div, Input, Pill, Span, Template from common.components.primitives import Div, Input, Pill, Span, Template
# Both comboboxes are wired by search_select.js. # Both comboboxes are wired by ts/search_select.ts (compiled to dist/).
_SEARCH_SELECT_MEDIA = Media(js=("search_select.js",)) _SEARCH_SELECT_MEDIA = Media(js=("dist/search_select.js",))
class SearchSelectOption(TypedDict): class SearchSelectOption(TypedDict):
@@ -81,7 +82,7 @@ DEFAULT_PREFETCH = 20
# Inline class strings (ported verbatim from the retired SelectableFilter CSS) # Inline class strings (ported verbatim from the retired SelectableFilter CSS)
# so the filter combobox is fully self-styled — nothing in input.css. JS-added # so the filter combobox is fully self-styled — nothing in input.css. JS-added
# rows/pills are cloned from server-rendered <template>s, so these strings live # rows/pills are cloned from server-rendered <template>s, so these strings live
# only here — never duplicated in search_select.js. The keyboard-highlighted # only here — never duplicated in ts/search_select.ts. The keyboard-highlighted
# state is expressed via Tailwind `data-[search-select-highlighted]` and # state is expressed via Tailwind `data-[search-select-highlighted]` and
# `group-data-[search-select-highlighted]` variants on the row/label/button # `group-data-[search-select-highlighted]` variants on the row/label/button
# classes below; the JS only toggles the data attribute on the row. # classes below; the JS only toggles the data attribute on the row.
+1 -1
View File
@@ -23,7 +23,7 @@ def _bar_page(filter_json: str = "") -> str:
<title>Boolean filter E2E</title> <title>Boolean filter E2E</title>
<script src="/static/js/htmx.min.js"></script> <script src="/static/js/htmx.min.js"></script>
<script src="/static/js/range_slider.js" type="module"></script> <script src="/static/js/range_slider.js" type="module"></script>
<script src="/static/js/search_select.js" type="module"></script> <script src="/static/js/dist/search_select.js" type="module"></script>
<script src="/static/js/filter_bar.js" type="module"></script> <script src="/static/js/filter_bar.js" type="module"></script>
</head> </head>
<body> <body>
+1 -1
View File
@@ -31,7 +31,7 @@ def _bar_page(filter_json: str = "") -> str:
<title>Date filter E2E</title> <title>Date filter E2E</title>
<script src="/static/js/htmx.min.js"></script> <script src="/static/js/htmx.min.js"></script>
<script src="/static/js/range_slider.js" type="module"></script> <script src="/static/js/range_slider.js" type="module"></script>
<script src="/static/js/search_select.js" type="module"></script> <script src="/static/js/dist/search_select.js" type="module"></script>
<script src="/static/js/filter_bar.js" type="module"></script> <script src="/static/js/filter_bar.js" type="module"></script>
</head> </head>
<body> <body>
+1 -1
View File
@@ -30,7 +30,7 @@ def _bar_page(filter_json: str = "") -> str:
<title>Date range picker E2E</title> <title>Date range picker E2E</title>
<script src="/static/js/htmx.min.js"></script> <script src="/static/js/htmx.min.js"></script>
<script src="/static/js/range_slider.js" type="module"></script> <script src="/static/js/range_slider.js" type="module"></script>
<script src="/static/js/search_select.js" type="module"></script> <script src="/static/js/dist/search_select.js" type="module"></script>
<script src="/static/js/date_range_picker.js" defer></script> <script src="/static/js/date_range_picker.js" defer></script>
<script src="/static/js/filter_bar.js" type="module"></script> <script src="/static/js/filter_bar.js" type="module"></script>
</head> </head>
+1 -1
View File
@@ -24,7 +24,7 @@ def selection_fields_view(request):
<html> <html>
<head> <head>
<script src="/static/js/htmx.min.js"></script> <script src="/static/js/htmx.min.js"></script>
<script type="module" src="/static/js/search_select.js"></script> <script type="module" src="/static/js/dist/search_select.js"></script>
<script type="module" src="/static/js/dist/elements/selection-fields.js"></script> <script type="module" src="/static/js/dist/elements/selection-fields.js"></script>
</head> </head>
<body> <body>
+1 -1
View File
@@ -15,7 +15,7 @@ def _bar_page(filter_json: str = "") -> str:
<title>Range Slider E2E</title> <title>Range Slider E2E</title>
<script src="/static/js/htmx.min.js"></script> <script src="/static/js/htmx.min.js"></script>
<script src="/static/js/range_slider.js" type="module"></script> <script src="/static/js/range_slider.js" type="module"></script>
<script src="/static/js/search_select.js" type="module"></script> <script src="/static/js/dist/search_select.js" type="module"></script>
<script src="/static/js/filter_bar.js" type="module"></script> <script src="/static/js/filter_bar.js" type="module"></script>
</head> </head>
<body> <body>
+1 -1
View File
@@ -14,7 +14,7 @@ def e2e_test_view(request):
<!-- search_select.js is an ES module and initializes via onSwap(), <!-- search_select.js is an ES module and initializes via onSwap(),
which rides on htmx.onLoad — so htmx must be present. --> which rides on htmx.onLoad — so htmx must be present. -->
<script src="/static/js/htmx.min.js"></script> <script src="/static/js/htmx.min.js"></script>
<script type="module" src="/static/js/search_select.js"></script> <script type="module" src="/static/js/dist/search_select.js"></script>
</head> </head>
<body> <body>
<div style="padding: 50px;"> <div style="padding: 50px;">
+1 -1
View File
@@ -18,7 +18,7 @@ def _bar_page(filter_json: str = "") -> str:
<title>String filter E2E</title> <title>String filter E2E</title>
<script src="/static/js/htmx.min.js"></script> <script src="/static/js/htmx.min.js"></script>
<script src="/static/js/range_slider.js" type="module"></script> <script src="/static/js/range_slider.js" type="module"></script>
<script src="/static/js/search_select.js" type="module"></script> <script src="/static/js/dist/search_select.js" type="module"></script>
<script src="/static/js/filter_bar.js" type="module"></script> <script src="/static/js/filter_bar.js" type="module"></script>
</head> </head>
<body> <body>
+2 -2
View File
@@ -180,7 +180,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
), ),
), ),
title="Add New Game", title="Add New Game",
scripts=ModuleScript("search_select.js") + ModuleScript("dist/add_game.js"), scripts=ModuleScript("dist/search_select.js") + ModuleScript("dist/add_game.js"),
) )
@@ -325,7 +325,7 @@ def edit_game(request: HttpRequest, game_id: int) -> HttpResponse:
request, request,
AddForm(form, request=request), AddForm(form, request=request),
title="Edit Game", title="Edit Game",
scripts=ModuleScript("search_select.js"), scripts=ModuleScript("dist/search_select.js"),
) )
+2 -2
View File
@@ -216,7 +216,7 @@ def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse:
request, request,
AddForm(form, request=request), AddForm(form, request=request),
title="Add new playthrough", title="Add new playthrough",
scripts=ModuleScript("search_select.js"), scripts=ModuleScript("dist/search_select.js"),
) )
@@ -233,7 +233,7 @@ def edit_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
request, request,
AddForm(form, request=request), AddForm(form, request=request),
title="Edit Play Event", title="Edit Play Event",
scripts=ModuleScript("search_select.js"), scripts=ModuleScript("dist/search_select.js"),
) )
+2 -2
View File
@@ -301,7 +301,7 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
), ),
title="Add New Purchase", title="Add New Purchase",
scripts=mark_safe( scripts=mark_safe(
ModuleScript("search_select.js") + ModuleScript("dist/add_purchase.js") ModuleScript("dist/search_select.js") + ModuleScript("dist/add_purchase.js")
), ),
) )
@@ -319,7 +319,7 @@ def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
AddForm(form, request=request, additional_row=_purchase_additional_row()), AddForm(form, request=request, additional_row=_purchase_additional_row()),
title="Edit Purchase", title="Edit Purchase",
scripts=mark_safe( scripts=mark_safe(
ModuleScript("search_select.js") + ModuleScript("dist/add_purchase.js") ModuleScript("dist/search_select.js") + ModuleScript("dist/add_purchase.js")
), ),
) )
+2 -2
View File
@@ -254,7 +254,7 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
request, request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""), AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Add New Session", title="Add New Session",
scripts=mark_safe(ModuleScript("search_select.js")), scripts=mark_safe(ModuleScript("dist/search_select.js")),
) )
@@ -269,7 +269,7 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
request, request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""), AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Edit Session", title="Edit Session",
scripts=mark_safe(ModuleScript("search_select.js")), scripts=mark_safe(ModuleScript("dist/search_select.js")),
) )
+3 -3
View File
@@ -133,14 +133,14 @@ class RealComponentMediaTest(unittest.TestCase):
from common.components import SearchSelect from common.components import SearchSelect
self.assertEqual( self.assertEqual(
collect_media(SearchSelect(name="games")).js, ("search_select.js",) collect_media(SearchSelect(name="games")).js, ("dist/search_select.js",)
) )
def test_filter_select_declares_its_script(self): def test_filter_select_declares_its_script(self):
from common.components import FilterSelect from common.components import FilterSelect
self.assertIn( self.assertIn(
"search_select.js", collect_media(FilterSelect(field_name="type")).js "dist/search_select.js", collect_media(FilterSelect(field_name="type")).js
) )
def test_date_range_picker_declares_its_script(self): def test_date_range_picker_declares_its_script(self):
@@ -170,7 +170,7 @@ class RealComponentMediaTest(unittest.TestCase):
media = collect_media(FilterBar()) media = collect_media(FilterBar())
self.assertIn("filter_bar.js", media.js) self.assertIn("filter_bar.js", media.js)
self.assertIn("search_select.js", media.js) self.assertIn("dist/search_select.js", media.js)
self.assertIn("range_slider.js", media.js) self.assertIn("range_slider.js", media.js)
+1 -1
View File
@@ -64,7 +64,7 @@ class RenderedPagesTest(TestCase):
components declare their JS and Page() collects it.""" components declare their JS and Page() collects it."""
html = self.get("games:list_games").content.decode() html = self.get("games:list_games").content.decode()
self.assertIn("js/filter_bar.js", html) self.assertIn("js/filter_bar.js", html)
self.assertIn("js/search_select.js", html) self.assertIn("js/dist/search_select.js", html)
self.assertIn("js/range_slider.js", html) self.assertIn("js/range_slider.js", html)
def test_stats_page_auto_loads_datepicker(self): def test_stats_page_auto_loads_datepicker(self):
+1 -12
View File
@@ -1,16 +1,5 @@
import { disableElementsWhenTrue, onSwap } from "./utils.js"; import { disableElementsWhenTrue, onSwap } from "./utils.js";
import type { SearchSelectChangeDetail } from "./search_select.js";
interface SearchSelectOption {
value: string;
label: string;
data: Record<string, string>;
}
interface SearchSelectChangeDetail {
name: string;
values: string[];
last: SearchSelectOption | null;
}
// Switch between a single bundle price and one price per game. The per-game // Switch between a single bundle price and one price per game. The per-game
// inputs are the selection-fields element; this only sets the policy: the // inputs are the selection-fields element; this only sets the policy: the
+1
View File
@@ -3,5 +3,6 @@ export {};
declare global { declare global {
interface Window { interface Window {
fetchWithHtmxTriggers(input: RequestInfo | URL, init?: RequestInit): Promise<Response>; fetchWithHtmxTriggers(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
readSearchSelect(form: HTMLElement): void;
} }
} }
@@ -23,6 +23,35 @@
*/ */
import { onSwap } from "./utils.js"; import { onSwap } from "./utils.js";
// The contract for the "search-select:change" CustomEvent this widget emits.
// Consumers (e.g. add_purchase.ts) import these types — never redefine them.
export interface SearchSelectOption {
value: string;
label: string;
data: Record<string, string>;
}
export interface SearchSelectChangeDetail {
name: string;
values: string[];
last: SearchSelectOption | null;
}
// The widget stashes per-instance state directly on its DOM elements.
interface SearchSelectContainer extends HTMLElement {
_searchSelectLabel?: string;
_searchSelectDirty?: boolean;
}
interface OptionRow extends HTMLElement {
_searchSelectOption?: SearchSelectOption;
}
interface FilterPillEntry {
id: string;
label: string;
}
(() => { (() => {
"use strict"; "use strict";
@@ -34,28 +63,29 @@ import { onSwap } from "./utils.js";
// INCLUDES_ONLY) coexist with value pills. // INCLUDES_ONLY) coexist with value pills.
const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"]; const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
const initWidget = (container) => { const initWidget = (containerElement: Element) => {
const search = container.querySelector("[data-search-select-search]"); const container = containerElement as SearchSelectContainer;
const options = container.querySelector("[data-search-select-options]"); const search = container.querySelector<HTMLInputElement>("[data-search-select-search]");
const pills = container.querySelector("[data-search-select-pills]"); const options = container.querySelector<HTMLElement>("[data-search-select-options]");
const pills = container.querySelector<HTMLElement>("[data-search-select-pills]");
if (!search || !options || !pills) return; if (!search || !options || !pills) return;
const name = container.getAttribute("data-name"); const name = container.getAttribute("data-name") ?? "";
const searchUrl = container.getAttribute("data-search-url"); const searchUrl = container.getAttribute("data-search-url");
const isFilter = container.getAttribute("data-search-select-mode") === "filter"; const isFilter = container.getAttribute("data-search-select-mode") === "filter";
const freeText = container.getAttribute("data-search-select-free-text") === "true"; const freeText = container.getAttribute("data-search-select-free-text") === "true";
const multi = container.getAttribute("data-multi") === "true"; const multi = container.getAttribute("data-multi") === "true";
const alwaysVisible = container.getAttribute("data-always-visible") === "true"; const alwaysVisible = container.getAttribute("data-always-visible") === "true";
const prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0; const prefetch = parseInt(container.getAttribute("data-prefetch") ?? "", 10) || 0;
const syncUrl = container.getAttribute("data-sync-url") === "true"; const syncUrl = container.getAttribute("data-sync-url") === "true";
const noResults = options.querySelector("[data-search-select-no-results]"); const noResults = options.querySelector<HTMLElement>("[data-search-select-no-results]");
let debounceTimer = null; let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let pendingRequest = null; // in-flight AbortController, so newer queries win let pendingRequest: AbortController | null = null; // in-flight, so newer queries win
let hasPrefetched = false; let hasPrefetched = false;
const hasVisibleContent = () => { const hasVisibleContent = () => {
const optionRows = options.querySelectorAll("[data-search-select-option]"); const optionRows = options.querySelectorAll<HTMLElement>("[data-search-select-option]");
for (let i = 0; i < optionRows.length; i++) { for (let i = 0; i < optionRows.length; i++) {
if (optionRows[i].style.display !== "none") return true; if (optionRows[i].style.display !== "none") return true;
} }
@@ -73,16 +103,16 @@ import { onSwap } from "./utils.js";
if (!alwaysVisible) options.classList.add("hidden"); if (!alwaysVisible) options.classList.add("hidden");
}; };
const setNoResults = (visible) => { const setNoResults = (visible: boolean) => {
if (!noResults) return; if (!noResults) return;
noResults.classList.toggle("hidden", !visible); noResults.classList.toggle("hidden", !visible);
if (visible) showPanel(); if (visible) showPanel();
}; };
// ── Highlight tracking (filter mode) ── // ── Highlight tracking (filter mode) ──
let highlightedRow = null; let highlightedRow: HTMLElement | null = null;
const highlightOption = (row) => { const highlightOption = (row: HTMLElement | null) => {
clearHighlight(); clearHighlight();
if (!row) return; if (!row) return;
row.setAttribute("data-search-select-highlighted", ""); row.setAttribute("data-search-select-highlighted", "");
@@ -97,12 +127,12 @@ import { onSwap } from "./utils.js";
} }
}; };
const getVisibleOptions = () => { const getVisibleOptions = (): HTMLElement[] => {
const all = options.querySelectorAll("[data-search-select-option]"); const all = options.querySelectorAll<HTMLElement>("[data-search-select-option]");
return Array.from(all).filter(row => row.style.display !== "none"); return Array.from(all).filter(row => row.style.display !== "none");
}; };
const autoHighlight = (query) => { const autoHighlight = (query: string) => {
const visible = getVisibleOptions(); const visible = getVisibleOptions();
if (visible.length === 0) { if (visible.length === 0) {
clearHighlight(); clearHighlight();
@@ -130,38 +160,38 @@ import { onSwap } from "./utils.js";
}; };
// Get active values in both form and filter modes // Get active values in both form and filter modes
const getSelectedValues = () => { const getSelectedValues = (): Set<string> => {
const vals = new Set(); const values = new Set<string>();
pills.querySelectorAll('input[type="hidden"]').forEach(input => { pills.querySelectorAll<HTMLInputElement>('input[type="hidden"]').forEach(input => {
vals.add(input.value); values.add(input.value);
}); });
pills.querySelectorAll("[data-pill]").forEach(pill => { pills.querySelectorAll<HTMLElement>("[data-pill]").forEach(pill => {
const val = pill.getAttribute("data-value"); const value = pill.getAttribute("data-value");
if (val) vals.add(val); if (value) values.add(value);
}); });
return vals; return values;
}; };
// ── Render server-fetched rows into the panel ── // ── Render server-fetched rows into the panel ──
const renderRows = (items) => { const renderRows = (items: SearchSelectOption[]) => {
const selectedVals = getSelectedValues(); const selectedValues = getSelectedValues();
const preservedOptions = []; const preservedOptions: SearchSelectOption[] = [];
// Extract existing option data for currently selected values before removing // Extract existing option data for currently selected values before removing
options.querySelectorAll("[data-search-select-option]").forEach(row => { options.querySelectorAll<HTMLElement>("[data-search-select-option]").forEach(row => {
const val = row.getAttribute("data-value"); const value = row.getAttribute("data-value");
if (selectedVals.has(val)) { if (value && selectedValues.has(value)) {
preservedOptions.push(optionFromRow(row)); preservedOptions.push(optionFromRow(row));
} }
row.remove(); row.remove();
}); });
const renderedValues = new Set(); const renderedValues = new Set<string>();
// Render preserved options first (to keep them at the top) // Render preserved options first (to keep them at the top)
preservedOptions.forEach(opt => { preservedOptions.forEach(option => {
options.insertBefore(buildRow(opt), noResults || null); options.insertBefore(buildRow(option), noResults || null);
renderedValues.add(String(opt.value)); renderedValues.add(String(option.value));
}); });
// Render newly fetched items (excluding already rendered preserved ones) // Render newly fetched items (excluding already rendered preserved ones)
@@ -178,19 +208,20 @@ import { onSwap } from "./utils.js";
// ── Clone a server-rendered <template> prototype by name. The server emits // ── Clone a server-rendered <template> prototype by name. The server emits
// the mode-appropriate prototypes, so the JS never names a class. ── // the mode-appropriate prototypes, so the JS never names a class. ──
const cloneTemplate = (name) => { const cloneTemplate = (templateName: string): HTMLElement | null => {
const template = container.querySelector(`template[data-search-select-template="${name}"]`); const template = container.querySelector<HTMLTemplateElement>(
return template `template[data-search-select-template="${templateName}"]`
? template.content.firstElementChild.cloneNode(true) );
: null; const clone = template?.content.firstElementChild?.cloneNode(true);
return (clone as HTMLElement) ?? null;
}; };
const setLabel = (node, label) => { const setLabel = (node: Element, label: string) => {
const slot = node.querySelector("[data-search-select-label]"); const slot = node.querySelector("[data-search-select-label]");
if (slot) slot.textContent = label; if (slot) slot.textContent = label;
}; };
const applyData = (node, data = {}) => { const applyData = (node: Element, data: Record<string, string> = {}) => {
Object.keys(data).forEach(key => { Object.keys(data).forEach(key => {
node.setAttribute(`data-${key}`, data[key]); node.setAttribute(`data-${key}`, data[key]);
}); });
@@ -198,8 +229,8 @@ import { onSwap } from "./utils.js";
// Build an option row by cloning the "row" template (the same prototype the // Build an option row by cloning the "row" template (the same prototype the
// server renders, so fetched and pre-rendered rows are identical). // server renders, so fetched and pre-rendered rows are identical).
const buildRow = (option) => { const buildRow = (option: SearchSelectOption): HTMLElement | Comment => {
const row = cloneTemplate("row"); const row = cloneTemplate("row") as OptionRow | null;
if (!row) return document.createComment("ss-row"); if (!row) return document.createComment("ss-row");
row.setAttribute("data-value", option.value); row.setAttribute("data-value", option.value);
row.setAttribute("data-label", option.label); row.setAttribute("data-label", option.label);
@@ -211,10 +242,10 @@ import { onSwap } from "./utils.js";
// ── Client-side filter of the currently loaded rows. Returns the number of // ── 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. ── // visible rows so the caller decides whether to show the no-results node. ──
const filterRows = (query) => { const filterRows = (query: string): number => {
const lower = query.toLowerCase(); const lower = query.toLowerCase();
let visibleCount = 0; let visibleCount = 0;
options.querySelectorAll("[data-search-select-option]").forEach(item => { options.querySelectorAll<HTMLElement>("[data-search-select-option]").forEach(item => {
const label = (item.getAttribute("data-label") || "").toLowerCase(); const label = (item.getAttribute("data-label") || "").toLowerCase();
const match = label.includes(lower); const match = label.includes(lower);
item.style.display = match ? "" : "none"; item.style.display = match ? "" : "none";
@@ -225,14 +256,14 @@ import { onSwap } from "./utils.js";
// ── Fetch matching rows from the server. The previous in-flight request is // ── Fetch matching rows from the server. The previous in-flight request is
// aborted so a slower earlier response can never overwrite a newer one. ── // aborted so a slower earlier response can never overwrite a newer one. ──
const fetchFromServer = (query) => { const fetchFromServer = (query: string) => {
if (pendingRequest) pendingRequest.abort(); if (pendingRequest) pendingRequest.abort();
pendingRequest = new AbortController(); pendingRequest = new AbortController();
let url = `${searchUrl}?q=${encodeURIComponent(query)}`; let url = `${searchUrl}?q=${encodeURIComponent(query)}`;
if (prefetch && !query) url += `&limit=${prefetch}`; if (prefetch && !query) url += `&limit=${prefetch}`;
fetch(url, { credentials: "same-origin", signal: pendingRequest.signal }) fetch(url, { credentials: "same-origin", signal: pendingRequest.signal })
.then(response => response.json()) .then(response => response.json())
.then(items => { .then((items: SearchSelectOption[]) => {
pendingRequest = null; pendingRequest = null;
renderRows(items); renderRows(items);
// Re-apply the live query: the box may hold more text than was sent. // Re-apply the live query: the box may hold more text than was sent.
@@ -249,7 +280,7 @@ import { onSwap } from "./utils.js";
// In free-text mode the typed text is the value itself: there is no // In free-text mode the typed text is the value itself: there is no
// backing list, so we rebuild a single ephemeral option row reflecting the // backing list, so we rebuild a single ephemeral option row reflecting the
// current query so the +/ buttons (or Enter) can commit it as a pill. // current query so the +/ buttons (or Enter) can commit it as a pill.
const rebuildFreeTextRow = (query) => { const rebuildFreeTextRow = (query: string) => {
options.querySelectorAll("[data-search-select-option]").forEach(row => row.remove()); options.querySelectorAll("[data-search-select-option]").forEach(row => row.remove());
if (!query) { if (!query) {
setNoResults(false); setNoResults(false);
@@ -259,7 +290,7 @@ import { onSwap } from "./utils.js";
const row = buildRow({ value: query, label: query, data: {} }); const row = buildRow({ value: query, label: query, data: {} });
options.insertBefore(row, noResults || null); options.insertBefore(row, noResults || null);
setNoResults(false); setNoResults(false);
highlightOption(row); highlightOption(row as HTMLElement);
}; };
// Called on every keystroke. With a search_url, filter the loaded window // Called on every keystroke. With a search_url, filter the loaded window
@@ -277,7 +308,7 @@ import { onSwap } from "./utils.js";
if (searchUrl) { if (searchUrl) {
filterRows(query); filterRows(query);
setNoResults(false); setNoResults(false);
clearTimeout(debounceTimer); if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { debounceTimer = setTimeout(() => {
fetchFromServer(query); fetchFromServer(query);
}, DEBOUNCE_MS); }, DEBOUNCE_MS);
@@ -371,13 +402,13 @@ import { onSwap } from "./utils.js";
if (key === "ArrowDown") { if (key === "ArrowDown") {
event.preventDefault(); event.preventDefault();
showPanel(); showPanel();
const downIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1; const downIndex = highlightedRow ? visible.indexOf(highlightedRow) : -1;
highlightOption(visible[(downIdx + 1) % visible.length]); highlightOption(visible[(downIndex + 1) % visible.length]);
} else if (key === "ArrowUp") { } else if (key === "ArrowUp") {
event.preventDefault(); event.preventDefault();
showPanel(); showPanel();
const upIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1; const upIndex = highlightedRow ? visible.indexOf(highlightedRow) : -1;
highlightOption(visible[(upIdx - 1 + visible.length) % visible.length]); highlightOption(visible[(upIndex - 1 + visible.length) % visible.length]);
} else if (key === "Enter") { } else if (key === "Enter") {
if (highlightedRow) { if (highlightedRow) {
event.preventDefault(); event.preventDefault();
@@ -408,31 +439,32 @@ import { onSwap } from "./utils.js";
handleFilterOptionClick(event); handleFilterOptionClick(event);
return; return;
} }
const row = event.target.closest("[data-search-select-option]"); const row = (event.target as Element).closest<HTMLElement>("[data-search-select-option]");
if (!row) return; if (!row) return;
selectOption(optionFromRow(row)); selectOption(optionFromRow(row));
}); });
const handleFilterOptionClick = (event) => { const handleFilterOptionClick = (event: MouseEvent) => {
const target = event.target as Element;
// Pinned modifier pseudo-option → set the (mutually exclusive) modifier. // Pinned modifier pseudo-option → set the (mutually exclusive) modifier.
const modifierRow = event.target.closest("[data-search-select-modifier-option]"); const modifierRow = target.closest<HTMLElement>("[data-search-select-modifier-option]");
if (modifierRow) { if (modifierRow) {
setModifier( setModifier(
modifierRow.getAttribute("data-search-select-modifier-option"), modifierRow.getAttribute("data-search-select-modifier-option") ?? "",
modifierRow.getAttribute("data-label") modifierRow.getAttribute("data-label") ?? ""
); );
return; return;
} }
// Include / exclude button on a value row. // Include / exclude button on a value row.
const button = event.target.closest("[data-search-select-action]"); const button = target.closest<HTMLElement>("[data-search-select-action]");
if (button) { if (button) {
const row = button.closest("[data-search-select-option]"); const row = button.closest<HTMLElement>("[data-search-select-option]");
if (!row) return; if (!row) return;
addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action")); addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action") ?? "include");
return; return;
} }
// Click on the option row itself → include. // Click on the option row itself → include.
const optionRow = event.target.closest("[data-search-select-option]"); const optionRow = target.closest<HTMLElement>("[data-search-select-option]");
if (optionRow) { if (optionRow) {
addFilterPill(optionFromRow(optionRow), "include"); addFilterPill(optionFromRow(optionRow), "include");
} }
@@ -442,11 +474,11 @@ import { onSwap } from "./utils.js";
// clears a presence modifier — NOT_NULL / IS_NULL are mutually exclusive // clears a presence modifier — NOT_NULL / IS_NULL are mutually exclusive
// with value pills. Non-presence modifiers (INCLUDES_ALL / INCLUDES_ONLY) // with value pills. Non-presence modifiers (INCLUDES_ALL / INCLUDES_ONLY)
// persist alongside value pills. // persist alongside value pills.
const addFilterPill = (option, kind) => { const addFilterPill = (option: SearchSelectOption, kind: string) => {
const modPill = pills.querySelector("[data-search-select-modifier]"); const modifierPill = pills.querySelector("[data-search-select-modifier]");
if (modPill) { if (modifierPill) {
const modVal = modPill.getAttribute("data-search-select-modifier"); const modifierValue = modifierPill.getAttribute("data-search-select-modifier") ?? "";
if (PRESENCE_MODIFIERS.includes(modVal)) { if (PRESENCE_MODIFIERS.includes(modifierValue)) {
clearModifier(); clearModifier();
} }
} }
@@ -459,8 +491,8 @@ import { onSwap } from "./utils.js";
emitChange(null); emitChange(null);
}; };
const buildFilterValuePill = (option, kind) => { const buildFilterValuePill = (option: SearchSelectOption, kind: string): HTMLElement => {
const pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude"); const pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude")!;
pill.setAttribute("data-value", option.value); pill.setAttribute("data-value", option.value);
pill.setAttribute("data-label", option.label); pill.setAttribute("data-label", option.label);
applyData(pill, option.data); applyData(pill, option.data);
@@ -471,13 +503,13 @@ import { onSwap } from "./utils.js";
// Set the modifier pill. Presence modifiers (NOT_NULL / IS_NULL) clear all // Set the modifier pill. Presence modifiers (NOT_NULL / IS_NULL) clear all
// value pills — they are mutually exclusive. Non-presence modifiers // value pills — they are mutually exclusive. Non-presence modifiers
// (INCLUDES_ALL / INCLUDES_ONLY) are prepended before existing value pills. // (INCLUDES_ALL / INCLUDES_ONLY) are prepended before existing value pills.
const setModifier = (modifierValue, label) => { const setModifier = (modifierValue: string, label: string) => {
// Remove any existing modifier pill to avoid duplicates. // Remove any existing modifier pill to avoid duplicates.
clearModifierPill(); clearModifierPill();
if (PRESENCE_MODIFIERS.includes(modifierValue)) { if (PRESENCE_MODIFIERS.includes(modifierValue)) {
pills.innerHTML = ""; pills.innerHTML = "";
} }
const pill = cloneTemplate("pill-modifier"); const pill = cloneTemplate("pill-modifier")!;
pill.setAttribute("data-search-select-modifier", modifierValue); pill.setAttribute("data-search-select-modifier", modifierValue);
setLabel(pill, label); setLabel(pill, label);
pills.insertBefore(pill, pills.firstChild); pills.insertBefore(pill, pills.firstChild);
@@ -498,22 +530,23 @@ import { onSwap } from "./utils.js";
clearModifierPill(); clearModifierPill();
}; };
const optionFromRow = (row) => { const optionFromRow = (row: HTMLElement): SearchSelectOption => {
if (row._searchSelectOption) return row._searchSelectOption; const optionRow = row as OptionRow;
const data = {}; if (optionRow._searchSelectOption) return optionRow._searchSelectOption;
const data: Record<string, string> = {};
Object.keys(row.dataset).forEach(key => { Object.keys(row.dataset).forEach(key => {
if (key !== "value" && key !== "label" && key !== "ssOption") { if (key !== "value" && key !== "label" && key !== "ssOption") {
data[key] = row.dataset[key]; data[key] = row.dataset[key] ?? "";
} }
}); });
return { return {
value: row.getAttribute("data-value"), value: row.getAttribute("data-value") ?? "",
label: row.getAttribute("data-label"), label: row.getAttribute("data-label") ?? "",
data, data,
}; };
}; };
const selectOption = (option) => { const selectOption = (option: SearchSelectOption) => {
if (multi) { if (multi) {
if (!pills.querySelector(`input[value="${cssEscape(option.value)}"]`)) { if (!pills.querySelector(`input[value="${cssEscape(option.value)}"]`)) {
addPill(option); addPill(option);
@@ -532,13 +565,13 @@ import { onSwap } from "./utils.js";
emitChange(option); emitChange(option);
}; };
const addPill = (option) => { const addPill = (option: SearchSelectOption) => {
const pill = buildPill(option); const pill = buildPill(option);
if (pill) pills.appendChild(pill); if (pill) pills.appendChild(pill);
pills.appendChild(buildHidden(option.value)); pills.appendChild(buildHidden(option.value));
}; };
const buildPill = (option) => { const buildPill = (option: SearchSelectOption): HTMLElement | null => {
const pill = cloneTemplate("pill"); const pill = cloneTemplate("pill");
if (!pill) return null; if (!pill) return null;
pill.setAttribute("data-value", option.value); pill.setAttribute("data-value", option.value);
@@ -547,7 +580,7 @@ import { onSwap } from "./utils.js";
return pill; return pill;
}; };
const buildHidden = (value) => { const buildHidden = (value: string): HTMLInputElement => {
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "hidden"; input.type = "hidden";
input.name = name; input.name = name;
@@ -557,7 +590,7 @@ import { onSwap } from "./utils.js";
// ── Pill × → remove ── // ── Pill × → remove ──
pills.addEventListener("click", (event) => { pills.addEventListener("click", (event) => {
const removeButton = event.target.closest("[data-pill-remove]"); const removeButton = (event.target as Element).closest("[data-pill-remove]");
if (!removeButton) return; if (!removeButton) return;
const pill = removeButton.closest("[data-pill]"); const pill = removeButton.closest("[data-pill]");
if (!pill) return; if (!pill) return;
@@ -578,67 +611,69 @@ import { onSwap } from "./utils.js";
emitChange(null); emitChange(null);
}); });
const currentValues = () => { const currentValues = (): string[] => {
return Array.from(pills.querySelectorAll('input[type="hidden"]')).map(input => input.value); return Array.from(
pills.querySelectorAll<HTMLInputElement>('input[type="hidden"]')
).map(input => input.value);
}; };
const emitChange = (last) => { const emitChange = (last: SearchSelectOption | null) => {
const values = currentValues(); const values = currentValues();
if (syncUrl) syncToUrl(values); if (syncUrl) syncToUrl(values);
container.dispatchEvent( container.dispatchEvent(
new CustomEvent("search-select:change", { new CustomEvent<SearchSelectChangeDetail>("search-select:change", {
bubbles: true, bubbles: true,
detail: { name, values, last }, detail: { name, values, last },
}) })
); );
}; };
const syncToUrl = (values) => { const syncToUrl = (values: string[]) => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
params.delete(name); params.delete(name);
values.forEach(v => { values.forEach(value => {
params.append(name, v); params.append(name, value);
}); });
const qs = params.toString(); const queryString = params.toString();
history.replaceState(null, "", qs ? `?${qs}` : window.location.pathname); history.replaceState(null, "", queryString ? `?${queryString}` : window.location.pathname);
}; };
// On init, restore from URL params if the server supplied no selected pills. // On init, restore from URL params if the server supplied no selected pills.
if (syncUrl && !pills.querySelector("[data-pill]")) { if (syncUrl && !pills.querySelector("[data-pill]")) {
const initial = new URLSearchParams(window.location.search).getAll(name); const initial = new URLSearchParams(window.location.search).getAll(name);
initial.forEach(v => { initial.forEach(value => {
addPill({ value: v, label: v, data: {} }); addPill({ value, label: value, data: {} });
}); });
} }
// ── Close panel on outside click ── // ── Close panel on outside click ──
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
if (!container.contains(event.target)) hidePanel(); if (!container.contains(event.target as Node)) hidePanel();
}); });
}; };
/** Minimal escape for use inside an attribute-value selector. */ /** Minimal escape for use inside an attribute-value selector. */
const cssEscape = (value) => String(value).replace(/["\\]/g, "\\$&"); const cssEscape = (value: string | null): string => String(value).replace(/["\\]/g, "\\$&");
// Serialise each widget's current state onto data-* attributes for the caller. // Serialise each widget's current state onto data-* attributes for the caller.
// Form widgets expose data-values (the submitted hidden-input values); filter // Form widgets expose data-values (the submitted hidden-input values); filter
// widgets expose data-included / data-excluded / data-modifier for the filter // widgets expose data-included / data-excluded / data-modifier for the filter
// bar to read. // bar to read.
window.readSearchSelect = (form) => { window.readSearchSelect = (form: HTMLElement) => {
form.querySelectorAll("[data-search-select]").forEach(container => { form.querySelectorAll<HTMLElement>("[data-search-select]").forEach(container => {
const pills = container.querySelector("[data-search-select-pills]"); const pills = container.querySelector<HTMLElement>("[data-search-select-pills]");
if (container.getAttribute("data-search-select-mode") === "filter") { if (container.getAttribute("data-search-select-mode") === "filter") {
const included = []; const included: FilterPillEntry[] = [];
const excluded = []; const excluded: FilterPillEntry[] = [];
let modifier = ""; let modifier = "";
if (pills) { if (pills) {
pills.querySelectorAll("[data-pill]").forEach(pill => { pills.querySelectorAll<HTMLElement>("[data-pill]").forEach(pill => {
const pillModifier = pill.getAttribute("data-search-select-modifier"); const pillModifier = pill.getAttribute("data-search-select-modifier");
if (pillModifier) { if (pillModifier) {
modifier = pillModifier; // last modifier pill wins modifier = pillModifier; // last modifier pill wins
return; // skip value extraction for this pill return; // skip value extraction for this pill
} }
const value = pill.getAttribute("data-value"); const value = pill.getAttribute("data-value") ?? "";
const label = pill.getAttribute("data-label") || ""; const label = pill.getAttribute("data-label") || "";
if (pill.getAttribute("data-search-select-type") === "exclude") { if (pill.getAttribute("data-search-select-type") === "exclude") {
excluded.push({ id: value, label }); excluded.push({ id: value, label });
@@ -654,7 +689,7 @@ import { onSwap } from "./utils.js";
return; return;
} }
const values = pills const values = pills
? Array.from(pills.querySelectorAll('input[type="hidden"]')).map(input => input.value) ? Array.from(pills.querySelectorAll<HTMLInputElement>('input[type="hidden"]')).map(input => input.value)
: []; : [];
container.setAttribute("data-values", JSON.stringify(values)); container.setAttribute("data-values", JSON.stringify(values));
}); });