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
# `group-data-[search-select-highlighted]` variants on the row/label/button
# classes below; the JS only toggles the data attribute on the row.
diff --git a/e2e/test_boolean_filter_e2e.py b/e2e/test_boolean_filter_e2e.py
index 8264129..ea894c8 100644
--- a/e2e/test_boolean_filter_e2e.py
+++ b/e2e/test_boolean_filter_e2e.py
@@ -23,7 +23,7 @@ def _bar_page(filter_json: str = "") -> str:
Boolean filter E2E
-
+
diff --git a/e2e/test_date_filter_e2e.py b/e2e/test_date_filter_e2e.py
index 33f9879..dad2799 100644
--- a/e2e/test_date_filter_e2e.py
+++ b/e2e/test_date_filter_e2e.py
@@ -31,7 +31,7 @@ def _bar_page(filter_json: str = "") -> str:
Date filter E2E
-
+
diff --git a/e2e/test_date_range_picker_e2e.py b/e2e/test_date_range_picker_e2e.py
index bb057ea..3fcdc46 100644
--- a/e2e/test_date_range_picker_e2e.py
+++ b/e2e/test_date_range_picker_e2e.py
@@ -30,7 +30,7 @@ def _bar_page(filter_json: str = "") -> str:
Date range picker E2E
-
+
diff --git a/e2e/test_purchase_e2e.py b/e2e/test_purchase_e2e.py
index 85fad4e..c4308ca 100644
--- a/e2e/test_purchase_e2e.py
+++ b/e2e/test_purchase_e2e.py
@@ -24,7 +24,7 @@ def selection_fields_view(request):
-
+
diff --git a/e2e/test_range_slider_e2e.py b/e2e/test_range_slider_e2e.py
index 0ee7043..204ce64 100644
--- a/e2e/test_range_slider_e2e.py
+++ b/e2e/test_range_slider_e2e.py
@@ -15,7 +15,7 @@ def _bar_page(filter_json: str = "") -> str:
Range Slider E2E
-
+
diff --git a/e2e/test_search_select_e2e.py b/e2e/test_search_select_e2e.py
index af7840a..9939fa5 100644
--- a/e2e/test_search_select_e2e.py
+++ b/e2e/test_search_select_e2e.py
@@ -14,7 +14,7 @@ def e2e_test_view(request):
-
+
diff --git a/e2e/test_string_filter_e2e.py b/e2e/test_string_filter_e2e.py
index 855b650..2f9b3a5 100644
--- a/e2e/test_string_filter_e2e.py
+++ b/e2e/test_string_filter_e2e.py
@@ -18,7 +18,7 @@ def _bar_page(filter_json: str = "") -> str:
String filter E2E
-
+
diff --git a/games/views/game.py b/games/views/game.py
index bd4699b..4433e57 100644
--- a/games/views/game.py
+++ b/games/views/game.py
@@ -180,7 +180,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
),
),
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,
AddForm(form, request=request),
title="Edit Game",
- scripts=ModuleScript("search_select.js"),
+ scripts=ModuleScript("dist/search_select.js"),
)
diff --git a/games/views/playevent.py b/games/views/playevent.py
index 2dd792c..7e17d28 100644
--- a/games/views/playevent.py
+++ b/games/views/playevent.py
@@ -216,7 +216,7 @@ def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse:
request,
AddForm(form, request=request),
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,
AddForm(form, request=request),
title="Edit Play Event",
- scripts=ModuleScript("search_select.js"),
+ scripts=ModuleScript("dist/search_select.js"),
)
diff --git a/games/views/purchase.py b/games/views/purchase.py
index b7c599a..c191cdf 100644
--- a/games/views/purchase.py
+++ b/games/views/purchase.py
@@ -301,7 +301,7 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
),
title="Add New Purchase",
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()),
title="Edit Purchase",
scripts=mark_safe(
- ModuleScript("search_select.js") + ModuleScript("dist/add_purchase.js")
+ ModuleScript("dist/search_select.js") + ModuleScript("dist/add_purchase.js")
),
)
diff --git a/games/views/session.py b/games/views/session.py
index 2b5321b..4cb22c3 100644
--- a/games/views/session.py
+++ b/games/views/session.py
@@ -254,7 +254,7 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
request,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
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,
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
title="Edit Session",
- scripts=mark_safe(ModuleScript("search_select.js")),
+ scripts=mark_safe(ModuleScript("dist/search_select.js")),
)
diff --git a/tests/test_node_tree.py b/tests/test_node_tree.py
index fe137d5..8a25492 100644
--- a/tests/test_node_tree.py
+++ b/tests/test_node_tree.py
@@ -133,14 +133,14 @@ class RealComponentMediaTest(unittest.TestCase):
from common.components import SearchSelect
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):
from common.components import FilterSelect
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):
@@ -170,7 +170,7 @@ class RealComponentMediaTest(unittest.TestCase):
media = collect_media(FilterBar())
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)
diff --git a/tests/test_rendered_pages.py b/tests/test_rendered_pages.py
index 48a86f1..fdc19d7 100644
--- a/tests/test_rendered_pages.py
+++ b/tests/test_rendered_pages.py
@@ -64,7 +64,7 @@ class RenderedPagesTest(TestCase):
components declare their JS and Page() collects it."""
html = self.get("games:list_games").content.decode()
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)
def test_stats_page_auto_loads_datepicker(self):
diff --git a/ts/add_purchase.ts b/ts/add_purchase.ts
index b80b650..ff8cbc1 100644
--- a/ts/add_purchase.ts
+++ b/ts/add_purchase.ts
@@ -1,16 +1,5 @@
import { disableElementsWhenTrue, onSwap } from "./utils.js";
-
-interface SearchSelectOption {
- value: string;
- label: string;
- data: Record;
-}
-
-interface SearchSelectChangeDetail {
- name: string;
- values: string[];
- last: SearchSelectOption | null;
-}
+import type { SearchSelectChangeDetail } from "./search_select.js";
// 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
diff --git a/ts/globals.d.ts b/ts/globals.d.ts
index b0c8127..f600b0b 100644
--- a/ts/globals.d.ts
+++ b/ts/globals.d.ts
@@ -3,5 +3,6 @@ export {};
declare global {
interface Window {
fetchWithHtmxTriggers(input: RequestInfo | URL, init?: RequestInit): Promise;
+ readSearchSelect(form: HTMLElement): void;
}
}
diff --git a/games/static/js/search_select.js b/ts/search_select.ts
similarity index 72%
rename from games/static/js/search_select.js
rename to ts/search_select.ts
index 62f59a0..6042dc4 100644
--- a/games/static/js/search_select.js
+++ b/ts/search_select.ts
@@ -23,6 +23,35 @@
*/
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;
+}
+
+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";
@@ -34,28 +63,29 @@ import { onSwap } from "./utils.js";
// INCLUDES_ONLY) coexist with value pills.
const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
- 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]");
+ const initWidget = (containerElement: Element) => {
+ const container = containerElement as SearchSelectContainer;
+ 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;
- const name = container.getAttribute("data-name");
+ const name = container.getAttribute("data-name") ?? "";
const searchUrl = container.getAttribute("data-search-url");
const isFilter = container.getAttribute("data-search-select-mode") === "filter";
const freeText = container.getAttribute("data-search-select-free-text") === "true";
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 prefetch = parseInt(container.getAttribute("data-prefetch") ?? "", 10) || 0;
const syncUrl = container.getAttribute("data-sync-url") === "true";
- const noResults = options.querySelector("[data-search-select-no-results]");
- let debounceTimer = null;
- let pendingRequest = null; // in-flight AbortController, so newer queries win
+ const noResults = options.querySelector("[data-search-select-no-results]");
+ let debounceTimer: ReturnType | null = null;
+ let pendingRequest: AbortController | null = null; // in-flight, so newer queries win
let hasPrefetched = false;
const hasVisibleContent = () => {
- const optionRows = options.querySelectorAll("[data-search-select-option]");
+ const optionRows = options.querySelectorAll("[data-search-select-option]");
for (let i = 0; i < optionRows.length; i++) {
if (optionRows[i].style.display !== "none") return true;
}
@@ -73,16 +103,16 @@ import { onSwap } from "./utils.js";
if (!alwaysVisible) options.classList.add("hidden");
};
- const setNoResults = (visible) => {
+ const setNoResults = (visible: boolean) => {
if (!noResults) return;
noResults.classList.toggle("hidden", !visible);
if (visible) showPanel();
};
// ── Highlight tracking (filter mode) ──
- let highlightedRow = null;
+ let highlightedRow: HTMLElement | null = null;
- const highlightOption = (row) => {
+ const highlightOption = (row: HTMLElement | null) => {
clearHighlight();
if (!row) return;
row.setAttribute("data-search-select-highlighted", "");
@@ -97,12 +127,12 @@ import { onSwap } from "./utils.js";
}
};
- const getVisibleOptions = () => {
- const all = options.querySelectorAll("[data-search-select-option]");
+ const getVisibleOptions = (): HTMLElement[] => {
+ const all = options.querySelectorAll("[data-search-select-option]");
return Array.from(all).filter(row => row.style.display !== "none");
};
- const autoHighlight = (query) => {
+ const autoHighlight = (query: string) => {
const visible = getVisibleOptions();
if (visible.length === 0) {
clearHighlight();
@@ -130,38 +160,38 @@ import { onSwap } from "./utils.js";
};
// 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);
+ const getSelectedValues = (): Set => {
+ const values = new Set();
+ pills.querySelectorAll('input[type="hidden"]').forEach(input => {
+ values.add(input.value);
});
- pills.querySelectorAll("[data-pill]").forEach(pill => {
- const val = pill.getAttribute("data-value");
- if (val) vals.add(val);
+ pills.querySelectorAll("[data-pill]").forEach(pill => {
+ const value = pill.getAttribute("data-value");
+ if (value) values.add(value);
});
- return vals;
+ return values;
};
// ── Render server-fetched rows into the panel ──
- const renderRows = (items) => {
- const selectedVals = getSelectedValues();
- const preservedOptions = [];
+ const renderRows = (items: SearchSelectOption[]) => {
+ const selectedValues = getSelectedValues();
+ const preservedOptions: SearchSelectOption[] = [];
// 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)) {
+ options.querySelectorAll("[data-search-select-option]").forEach(row => {
+ const value = row.getAttribute("data-value");
+ if (value && selectedValues.has(value)) {
preservedOptions.push(optionFromRow(row));
}
row.remove();
});
- const renderedValues = new Set();
+ 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));
+ preservedOptions.forEach(option => {
+ options.insertBefore(buildRow(option), noResults || null);
+ renderedValues.add(String(option.value));
});
// Render newly fetched items (excluding already rendered preserved ones)
@@ -178,19 +208,20 @@ import { onSwap } from "./utils.js";
// ── Clone a server-rendered prototype by name. The server emits
// the mode-appropriate prototypes, so the JS never names a class. ──
- const cloneTemplate = (name) => {
- const template = container.querySelector(`template[data-search-select-template="${name}"]`);
- return template
- ? template.content.firstElementChild.cloneNode(true)
- : null;
+ const cloneTemplate = (templateName: string): HTMLElement | null => {
+ const template = container.querySelector(
+ `template[data-search-select-template="${templateName}"]`
+ );
+ 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]");
if (slot) slot.textContent = label;
};
- const applyData = (node, data = {}) => {
+ const applyData = (node: Element, data: Record = {}) => {
Object.keys(data).forEach(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
// server renders, so fetched and pre-rendered rows are identical).
- const buildRow = (option) => {
- const row = cloneTemplate("row");
+ const buildRow = (option: SearchSelectOption): HTMLElement | Comment => {
+ const row = cloneTemplate("row") as OptionRow | null;
if (!row) return document.createComment("ss-row");
row.setAttribute("data-value", option.value);
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
// 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();
let visibleCount = 0;
- options.querySelectorAll("[data-search-select-option]").forEach(item => {
+ options.querySelectorAll("[data-search-select-option]").forEach(item => {
const label = (item.getAttribute("data-label") || "").toLowerCase();
const match = label.includes(lower);
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
// aborted so a slower earlier response can never overwrite a newer one. ──
- const fetchFromServer = (query) => {
+ const fetchFromServer = (query: string) => {
if (pendingRequest) pendingRequest.abort();
pendingRequest = new AbortController();
let url = `${searchUrl}?q=${encodeURIComponent(query)}`;
if (prefetch && !query) url += `&limit=${prefetch}`;
fetch(url, { credentials: "same-origin", signal: pendingRequest.signal })
.then(response => response.json())
- .then(items => {
+ .then((items: SearchSelectOption[]) => {
pendingRequest = null;
renderRows(items);
// 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
// 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.
- const rebuildFreeTextRow = (query) => {
+ const rebuildFreeTextRow = (query: string) => {
options.querySelectorAll("[data-search-select-option]").forEach(row => row.remove());
if (!query) {
setNoResults(false);
@@ -259,7 +290,7 @@ import { onSwap } from "./utils.js";
const row = buildRow({ value: query, label: query, data: {} });
options.insertBefore(row, noResults || null);
setNoResults(false);
- highlightOption(row);
+ highlightOption(row as HTMLElement);
};
// Called on every keystroke. With a search_url, filter the loaded window
@@ -277,7 +308,7 @@ import { onSwap } from "./utils.js";
if (searchUrl) {
filterRows(query);
setNoResults(false);
- clearTimeout(debounceTimer);
+ if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
fetchFromServer(query);
}, DEBOUNCE_MS);
@@ -371,13 +402,13 @@ import { onSwap } from "./utils.js";
if (key === "ArrowDown") {
event.preventDefault();
showPanel();
- const downIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
- highlightOption(visible[(downIdx + 1) % visible.length]);
+ const downIndex = highlightedRow ? visible.indexOf(highlightedRow) : -1;
+ highlightOption(visible[(downIndex + 1) % visible.length]);
} else if (key === "ArrowUp") {
event.preventDefault();
showPanel();
- const upIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
- highlightOption(visible[(upIdx - 1 + visible.length) % visible.length]);
+ const upIndex = highlightedRow ? visible.indexOf(highlightedRow) : -1;
+ highlightOption(visible[(upIndex - 1 + visible.length) % visible.length]);
} else if (key === "Enter") {
if (highlightedRow) {
event.preventDefault();
@@ -408,31 +439,32 @@ import { onSwap } from "./utils.js";
handleFilterOptionClick(event);
return;
}
- const row = event.target.closest("[data-search-select-option]");
+ const row = (event.target as Element).closest("[data-search-select-option]");
if (!row) return;
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.
- const modifierRow = event.target.closest("[data-search-select-modifier-option]");
+ const modifierRow = target.closest("[data-search-select-modifier-option]");
if (modifierRow) {
setModifier(
- modifierRow.getAttribute("data-search-select-modifier-option"),
- modifierRow.getAttribute("data-label")
+ modifierRow.getAttribute("data-search-select-modifier-option") ?? "",
+ modifierRow.getAttribute("data-label") ?? ""
);
return;
}
// Include / exclude button on a value row.
- const button = event.target.closest("[data-search-select-action]");
+ const button = target.closest("[data-search-select-action]");
if (button) {
- const row = button.closest("[data-search-select-option]");
+ const row = button.closest("[data-search-select-option]");
if (!row) return;
- addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action"));
+ addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action") ?? "include");
return;
}
// Click on the option row itself → include.
- const optionRow = event.target.closest("[data-search-select-option]");
+ const optionRow = target.closest("[data-search-select-option]");
if (optionRow) {
addFilterPill(optionFromRow(optionRow), "include");
}
@@ -442,11 +474,11 @@ import { onSwap } from "./utils.js";
// clears a presence modifier — NOT_NULL / IS_NULL are mutually exclusive
// with value pills. Non-presence modifiers (INCLUDES_ALL / INCLUDES_ONLY)
// persist alongside value pills.
- const addFilterPill = (option, kind) => {
- const modPill = pills.querySelector("[data-search-select-modifier]");
- if (modPill) {
- const modVal = modPill.getAttribute("data-search-select-modifier");
- if (PRESENCE_MODIFIERS.includes(modVal)) {
+ const addFilterPill = (option: SearchSelectOption, kind: string) => {
+ const modifierPill = pills.querySelector("[data-search-select-modifier]");
+ if (modifierPill) {
+ const modifierValue = modifierPill.getAttribute("data-search-select-modifier") ?? "";
+ if (PRESENCE_MODIFIERS.includes(modifierValue)) {
clearModifier();
}
}
@@ -459,8 +491,8 @@ import { onSwap } from "./utils.js";
emitChange(null);
};
- const buildFilterValuePill = (option, kind) => {
- const pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude");
+ const buildFilterValuePill = (option: SearchSelectOption, kind: string): HTMLElement => {
+ const pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude")!;
pill.setAttribute("data-value", option.value);
pill.setAttribute("data-label", option.label);
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
// value pills — they are mutually exclusive. Non-presence modifiers
// (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.
clearModifierPill();
if (PRESENCE_MODIFIERS.includes(modifierValue)) {
pills.innerHTML = "";
}
- const pill = cloneTemplate("pill-modifier");
+ const pill = cloneTemplate("pill-modifier")!;
pill.setAttribute("data-search-select-modifier", modifierValue);
setLabel(pill, label);
pills.insertBefore(pill, pills.firstChild);
@@ -498,22 +530,23 @@ import { onSwap } from "./utils.js";
clearModifierPill();
};
- const optionFromRow = (row) => {
- if (row._searchSelectOption) return row._searchSelectOption;
- const data = {};
+ const optionFromRow = (row: HTMLElement): SearchSelectOption => {
+ const optionRow = row as OptionRow;
+ if (optionRow._searchSelectOption) return optionRow._searchSelectOption;
+ const data: Record = {};
Object.keys(row.dataset).forEach(key => {
if (key !== "value" && key !== "label" && key !== "ssOption") {
- data[key] = row.dataset[key];
+ data[key] = row.dataset[key] ?? "";
}
});
return {
- value: row.getAttribute("data-value"),
- label: row.getAttribute("data-label"),
+ value: row.getAttribute("data-value") ?? "",
+ label: row.getAttribute("data-label") ?? "",
data,
};
};
- const selectOption = (option) => {
+ const selectOption = (option: SearchSelectOption) => {
if (multi) {
if (!pills.querySelector(`input[value="${cssEscape(option.value)}"]`)) {
addPill(option);
@@ -532,13 +565,13 @@ import { onSwap } from "./utils.js";
emitChange(option);
};
- const addPill = (option) => {
+ const addPill = (option: SearchSelectOption) => {
const pill = buildPill(option);
if (pill) pills.appendChild(pill);
pills.appendChild(buildHidden(option.value));
};
- const buildPill = (option) => {
+ const buildPill = (option: SearchSelectOption): HTMLElement | null => {
const pill = cloneTemplate("pill");
if (!pill) return null;
pill.setAttribute("data-value", option.value);
@@ -547,7 +580,7 @@ import { onSwap } from "./utils.js";
return pill;
};
- const buildHidden = (value) => {
+ const buildHidden = (value: string): HTMLInputElement => {
const input = document.createElement("input");
input.type = "hidden";
input.name = name;
@@ -557,7 +590,7 @@ import { onSwap } from "./utils.js";
// ── Pill × → remove ──
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;
const pill = removeButton.closest("[data-pill]");
if (!pill) return;
@@ -578,67 +611,69 @@ import { onSwap } from "./utils.js";
emitChange(null);
});
- const currentValues = () => {
- return Array.from(pills.querySelectorAll('input[type="hidden"]')).map(input => input.value);
+ const currentValues = (): string[] => {
+ return Array.from(
+ pills.querySelectorAll('input[type="hidden"]')
+ ).map(input => input.value);
};
- const emitChange = (last) => {
+ const emitChange = (last: SearchSelectOption | null) => {
const values = currentValues();
if (syncUrl) syncToUrl(values);
container.dispatchEvent(
- new CustomEvent("search-select:change", {
+ new CustomEvent("search-select:change", {
bubbles: true,
detail: { name, values, last },
})
);
};
- const syncToUrl = (values) => {
+ const syncToUrl = (values: string[]) => {
const params = new URLSearchParams(window.location.search);
params.delete(name);
- values.forEach(v => {
- params.append(name, v);
+ values.forEach(value => {
+ params.append(name, value);
});
- const qs = params.toString();
- history.replaceState(null, "", qs ? `?${qs}` : window.location.pathname);
+ const queryString = params.toString();
+ history.replaceState(null, "", queryString ? `?${queryString}` : window.location.pathname);
};
// On init, restore from URL params if the server supplied no selected pills.
if (syncUrl && !pills.querySelector("[data-pill]")) {
const initial = new URLSearchParams(window.location.search).getAll(name);
- initial.forEach(v => {
- addPill({ value: v, label: v, data: {} });
+ initial.forEach(value => {
+ addPill({ value, label: value, data: {} });
});
}
// ── Close panel on outside click ──
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. */
- 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.
// Form widgets expose data-values (the submitted hidden-input values); filter
// widgets expose data-included / data-excluded / data-modifier for the filter
// bar to read.
- window.readSearchSelect = (form) => {
- form.querySelectorAll("[data-search-select]").forEach(container => {
- const pills = container.querySelector("[data-search-select-pills]");
+ window.readSearchSelect = (form: HTMLElement) => {
+ form.querySelectorAll("[data-search-select]").forEach(container => {
+ const pills = container.querySelector("[data-search-select-pills]");
if (container.getAttribute("data-search-select-mode") === "filter") {
- const included = [];
- const excluded = [];
+ const included: FilterPillEntry[] = [];
+ const excluded: FilterPillEntry[] = [];
let modifier = "";
if (pills) {
- pills.querySelectorAll("[data-pill]").forEach(pill => {
+ pills.querySelectorAll("[data-pill]").forEach(pill => {
const pillModifier = pill.getAttribute("data-search-select-modifier");
if (pillModifier) {
modifier = pillModifier; // last modifier pill wins
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") || "";
if (pill.getAttribute("data-search-select-type") === "exclude") {
excluded.push({ id: value, label });
@@ -654,7 +689,7 @@ import { onSwap } from "./utils.js";
return;
}
const values = pills
- ? Array.from(pills.querySelectorAll('input[type="hidden"]')).map(input => input.value)
+ ? Array.from(pills.querySelectorAll('input[type="hidden"]')).map(input => input.value)
: [];
container.setAttribute("data-values", JSON.stringify(values));
});