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:
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;">
|
||||||
|
|||||||
@@ -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
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Vendored
+1
@@ -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));
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user