Expand the ss namespace prefix to search-select everywhere
Spell out the abbreviated data-ss-* hook attributes (data-search-select-option, -label, -mode, -template, -action, -type, -modifier, -modifier-option, -pills, -search, -options, -no-results) and the JS expando properties (_searchSelectInit, _searchSelectLabel, _searchSelectDirty, _searchSelectOption) across components, JS, and tests — no abbreviations left in the widget's hooks. https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
This commit is contained in:
@@ -395,7 +395,7 @@ def Pill(
|
|||||||
are JS hooks only (no CSS attached). ``value`` (when set) becomes
|
are JS hooks only (no CSS attached). ``value`` (when set) becomes
|
||||||
``data-value``; extra ``attributes`` are appended to the outer span.
|
``data-value``; extra ``attributes`` are appended to the outer span.
|
||||||
|
|
||||||
``label_slot=True`` wraps the label in a ``<span data-ss-label>`` so JS can
|
``label_slot=True`` wraps the label in a ``<span data-search-select-label>`` so JS can
|
||||||
fill it when cloning the pill from a server-rendered ``<template>`` (keeps the
|
fill it when cloning the pill from a server-rendered ``<template>`` (keeps the
|
||||||
markup single-sourced — see ``search_select.py``).
|
markup single-sourced — see ``search_select.py``).
|
||||||
"""
|
"""
|
||||||
@@ -407,7 +407,11 @@ def Pill(
|
|||||||
pill_attrs.extend(attributes)
|
pill_attrs.extend(attributes)
|
||||||
|
|
||||||
label_child: HTMLTag = (
|
label_child: HTMLTag = (
|
||||||
Component(tag_name="span", attributes=[("data-ss-label", "")], children=[label])
|
Component(
|
||||||
|
tag_name="span",
|
||||||
|
attributes=[("data-search-select-label", "")],
|
||||||
|
children=[label],
|
||||||
|
)
|
||||||
if label_slot
|
if label_slot
|
||||||
else label
|
else label
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -121,21 +121,23 @@ def _hidden_input(name: str, value) -> SafeText:
|
|||||||
|
|
||||||
|
|
||||||
def _label_slot(text: str, *, extra_class: str = "") -> SafeText:
|
def _label_slot(text: str, *, extra_class: str = "") -> SafeText:
|
||||||
"""A ``<span data-ss-label>`` holding a row/pill's visible label. JS fills this
|
"""A ``<span data-search-select-label>`` holding a row/pill's visible label. JS fills this
|
||||||
one node when cloning the shape from a ``<template>``, so labels are the only
|
one node when cloning the shape from a ``<template>``, so labels are the only
|
||||||
thing the JS sets — all classes and structure stay server-side."""
|
thing the JS sets — all classes and structure stay server-side."""
|
||||||
attributes: list[HTMLAttribute] = [("data-ss-label", "")]
|
attributes: list[HTMLAttribute] = [("data-search-select-label", "")]
|
||||||
if extra_class:
|
if extra_class:
|
||||||
attributes.append(("class", extra_class))
|
attributes.append(("class", extra_class))
|
||||||
return Component(tag_name="span", attributes=attributes, children=[text])
|
return Component(tag_name="span", attributes=attributes, children=[text])
|
||||||
|
|
||||||
|
|
||||||
def _template(name: str, node: SafeText) -> SafeText:
|
def _template(name: str, node: SafeText) -> SafeText:
|
||||||
"""Wrap a prototype row/pill in an inert ``<template data-ss-template=name>`` that
|
"""Wrap a prototype row/pill in an inert ``<template data-search-select-template=name>`` that
|
||||||
the JS clones. Rendering the prototype with the real component keeps the JS
|
the JS clones. Rendering the prototype with the real component keeps the JS
|
||||||
free of any markup or class strings."""
|
free of any markup or class strings."""
|
||||||
return Component(
|
return Component(
|
||||||
tag_name="template", attributes=[("data-ss-template", name)], children=[node]
|
tag_name="template",
|
||||||
|
attributes=[("data-search-select-template", name)],
|
||||||
|
children=[node],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -147,7 +149,7 @@ def _option_row(option: SearchSelectOption) -> SafeText:
|
|||||||
return Component(
|
return Component(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=[
|
attributes=[
|
||||||
("data-ss-option", ""),
|
("data-search-select-option", ""),
|
||||||
("data-value", str(option["value"])),
|
("data-value", str(option["value"])),
|
||||||
("data-label", option["label"]),
|
("data-label", option["label"]),
|
||||||
("class", _OPTION_ROW_CLASS),
|
("class", _OPTION_ROW_CLASS),
|
||||||
@@ -183,14 +185,17 @@ def _combobox_shell(
|
|||||||
|
|
||||||
no_results = Component(
|
no_results = Component(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=[("data-ss-no-results", ""), ("class", _NO_RESULTS_CLASS)],
|
attributes=[
|
||||||
|
("data-search-select-no-results", ""),
|
||||||
|
("class", _NO_RESULTS_CLASS),
|
||||||
|
],
|
||||||
children=["No results"],
|
children=["No results"],
|
||||||
)
|
)
|
||||||
options_class = _OPTIONS_CLASS if always_visible else _OPTIONS_CLASS + " hidden"
|
options_class = _OPTIONS_CLASS if always_visible else _OPTIONS_CLASS + " hidden"
|
||||||
options_panel = Component(
|
options_panel = Component(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=[
|
attributes=[
|
||||||
("data-ss-options", ""),
|
("data-search-select-options", ""),
|
||||||
("style", f"max-height: {items_visible * _ROW_HEIGHT_REM:.2f}rem"),
|
("style", f"max-height: {items_visible * _ROW_HEIGHT_REM:.2f}rem"),
|
||||||
("class", options_class),
|
("class", options_class),
|
||||||
],
|
],
|
||||||
@@ -228,7 +233,7 @@ def SearchSelect(
|
|||||||
# Multi-select renders a removable Pill per value; single-select renders no
|
# Multi-select renders a removable Pill per value; single-select renders no
|
||||||
# pill — the committed label shows inside the search box instead, with a
|
# pill — the committed label shows inside the search box instead, with a
|
||||||
# lone hidden input carrying the value. Both keep the hidden input(s) inside
|
# lone hidden input carrying the value. Both keep the hidden input(s) inside
|
||||||
# `[data-ss-pills]` so the JS reads/writes values uniformly.
|
# `[data-search-select-pills]` so the JS reads/writes values uniformly.
|
||||||
pills_children: list[SafeText] = []
|
pills_children: list[SafeText] = []
|
||||||
search_value = ""
|
search_value = ""
|
||||||
if multi_select:
|
if multi_select:
|
||||||
@@ -250,13 +255,13 @@ def SearchSelect(
|
|||||||
|
|
||||||
pills = Component(
|
pills = Component(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=[("data-ss-pills", ""), ("class", _PILLS_CLASS)],
|
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
|
||||||
children=pills_children,
|
children=pills_children,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Search box (NO name — the query is never submitted) ──
|
# ── Search box (NO name — the query is never submitted) ──
|
||||||
search_attrs: list[HTMLAttribute] = [
|
search_attrs: list[HTMLAttribute] = [
|
||||||
("data-ss-search", ""),
|
("data-search-select-search", ""),
|
||||||
("type", "text"),
|
("type", "text"),
|
||||||
("placeholder", placeholder),
|
("placeholder", placeholder),
|
||||||
("autocomplete", "off"),
|
("autocomplete", "off"),
|
||||||
@@ -332,7 +337,7 @@ def _filter_value_pill(option: SearchSelectOption, kind: str) -> SafeText:
|
|||||||
("data-pill", ""),
|
("data-pill", ""),
|
||||||
("data-value", str(option["value"])),
|
("data-value", str(option["value"])),
|
||||||
("data-label", option["label"]),
|
("data-label", option["label"]),
|
||||||
("data-ss-type", kind),
|
("data-search-select-type", kind),
|
||||||
*_data_attributes(option["data"]),
|
*_data_attributes(option["data"]),
|
||||||
],
|
],
|
||||||
children=[f"{symbol} ", _label_slot(option["label"]), _filter_remove_button()],
|
children=[f"{symbol} ", _label_slot(option["label"]), _filter_remove_button()],
|
||||||
@@ -346,7 +351,7 @@ def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText:
|
|||||||
attributes=[
|
attributes=[
|
||||||
("class", _FILTER_MODIFIER_PILL_CLASS),
|
("class", _FILTER_MODIFIER_PILL_CLASS),
|
||||||
("data-pill", ""),
|
("data-pill", ""),
|
||||||
("data-ss-modifier", modifier_value),
|
("data-search-select-modifier", modifier_value),
|
||||||
],
|
],
|
||||||
children=[_label_slot(label), _filter_remove_button()],
|
children=[_label_slot(label), _filter_remove_button()],
|
||||||
)
|
)
|
||||||
@@ -357,7 +362,7 @@ def _filter_action_button(action: str, symbol: str, title: str) -> SafeText:
|
|||||||
tag_name="button",
|
tag_name="button",
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "button"),
|
("type", "button"),
|
||||||
("data-ss-action", action),
|
("data-search-select-action", action),
|
||||||
("class", _FILTER_ACTION_BUTTON_CLASS),
|
("class", _FILTER_ACTION_BUTTON_CLASS),
|
||||||
("title", title),
|
("title", title),
|
||||||
],
|
],
|
||||||
@@ -370,7 +375,7 @@ def _filter_option_row(value: str | int, label: str) -> SafeText:
|
|||||||
return Component(
|
return Component(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=[
|
attributes=[
|
||||||
("data-ss-option", ""),
|
("data-search-select-option", ""),
|
||||||
("data-value", str(value)),
|
("data-value", str(value)),
|
||||||
("data-label", label),
|
("data-label", label),
|
||||||
("class", _FILTER_OPTION_ROW_CLASS),
|
("class", _FILTER_OPTION_ROW_CLASS),
|
||||||
@@ -390,12 +395,12 @@ def _filter_option_row(value: str | int, label: str) -> SafeText:
|
|||||||
|
|
||||||
|
|
||||||
def _filter_modifier_row(modifier_value: str, label: str) -> SafeText:
|
def _filter_modifier_row(modifier_value: str, label: str) -> SafeText:
|
||||||
"""A pinned pseudo-option row. It carries no ``data-ss-option`` so the text
|
"""A pinned pseudo-option row. It carries no ``data-search-select-option`` so the text
|
||||||
filter never hides it — modifiers stay visible at the top of the panel."""
|
filter never hides it — modifiers stay visible at the top of the panel."""
|
||||||
return Component(
|
return Component(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=[
|
attributes=[
|
||||||
("data-ss-modifier-option", modifier_value),
|
("data-search-select-modifier-option", modifier_value),
|
||||||
("data-label", label),
|
("data-label", label),
|
||||||
("class", _FILTER_MODIFIER_ROW_CLASS),
|
("class", _FILTER_MODIFIER_ROW_CLASS),
|
||||||
],
|
],
|
||||||
@@ -454,13 +459,13 @@ def FilterSelect(
|
|||||||
|
|
||||||
pills = Component(
|
pills = Component(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=[("data-ss-pills", ""), ("class", _PILLS_CLASS)],
|
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
|
||||||
children=pills_children,
|
children=pills_children,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Search box (NO name — the query is never submitted) ──
|
# ── Search box (NO name — the query is never submitted) ──
|
||||||
search_attributes: list[HTMLAttribute] = [
|
search_attributes: list[HTMLAttribute] = [
|
||||||
("data-ss-search", ""),
|
("data-search-select-search", ""),
|
||||||
("type", "text"),
|
("type", "text"),
|
||||||
("placeholder", placeholder),
|
("placeholder", placeholder),
|
||||||
("autocomplete", "off"),
|
("autocomplete", "off"),
|
||||||
@@ -491,7 +496,7 @@ def FilterSelect(
|
|||||||
|
|
||||||
container_attributes: list[HTMLAttribute] = [
|
container_attributes: list[HTMLAttribute] = [
|
||||||
("data-search-select", ""),
|
("data-search-select", ""),
|
||||||
("data-ss-mode", "filter"),
|
("data-search-select-mode", "filter"),
|
||||||
("data-name", field_name),
|
("data-name", field_name),
|
||||||
("data-search-url", search_url),
|
("data-search-url", search_url),
|
||||||
("data-multi", "true"),
|
("data-multi", "true"),
|
||||||
|
|||||||
@@ -59,10 +59,10 @@
|
|||||||
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
|
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FilterSelect widgets (data-ss-mode="filter") ──
|
// ── FilterSelect widgets (data-search-select-mode="filter") ──
|
||||||
// readSearchSelect serialises each into data-included/data-excluded/data-modifier.
|
// readSearchSelect serialises each into data-included/data-excluded/data-modifier.
|
||||||
readSearchSelect(form);
|
readSearchSelect(form);
|
||||||
var widgets = form.querySelectorAll('[data-search-select][data-ss-mode="filter"]');
|
var widgets = form.querySelectorAll('[data-search-select][data-search-select-mode="filter"]');
|
||||||
widgets.forEach(function (widget) {
|
widgets.forEach(function (widget) {
|
||||||
var field = widget.getAttribute("data-name");
|
var field = widget.getAttribute("data-name");
|
||||||
var included = parseJSONAttr(widget, "data-included");
|
var included = parseJSONAttr(widget, "data-included");
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
|
|
||||||
// ── Session-specific fields ──
|
// ── Session-specific fields ──
|
||||||
var pageIsSessions =
|
var pageIsSessions =
|
||||||
!!form.querySelector('[data-search-select][data-ss-mode="filter"][data-name="game"]');
|
!!form.querySelector('[data-search-select][data-search-select-mode="filter"][data-name="game"]');
|
||||||
|
|
||||||
// Emulated checkbox (sessions page)
|
// Emulated checkbox (sessions page)
|
||||||
var emulated = form.querySelector('[name="filter-emulated"]');
|
var emulated = form.querySelector('[name="filter-emulated"]');
|
||||||
|
|||||||
@@ -6,18 +6,18 @@
|
|||||||
* focus clears it to search, picking an option fills it), with a lone hidden
|
* focus clears it to search, picking an option fills it), with a lone hidden
|
||||||
* <input> carrying the value. Both keep hidden inputs so Django validation works.
|
* <input> carrying the value. Both keep hidden inputs so Django validation works.
|
||||||
*
|
*
|
||||||
* Filter mode (data-ss-mode="filter", rendered by FilterSelect): value rows
|
* Filter mode (data-search-select-mode="filter", rendered by FilterSelect): value rows
|
||||||
* carry +/− buttons that add include (✓) / exclude (✗) pills, plus pinned
|
* carry +/− buttons that add include (✓) / exclude (✗) pills, plus pinned
|
||||||
* modifier pseudo-options ((Any)/(None)) that are mutually exclusive with value
|
* modifier pseudo-options ((Any)/(None)) that are mutually exclusive with value
|
||||||
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
|
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
|
||||||
* state into data-included / data-excluded / data-modifier for the filter bar.
|
* state into data-included / data-excluded / data-modifier for the filter bar.
|
||||||
*
|
*
|
||||||
* initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with
|
* initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with
|
||||||
* element._ssInit.
|
* element._searchSelectInit.
|
||||||
*
|
*
|
||||||
* Dynamically-added rows and pills are cloned from hidden <template> elements
|
* Dynamically-added rows and pills are cloned from hidden <template> elements
|
||||||
* the server renders with the same Python components (Pill / SearchSelect /
|
* the server renders with the same Python components (Pill / SearchSelect /
|
||||||
* FilterSelect). The JS only fills in the label slot ([data-ss-label]), value,
|
* FilterSelect). The JS only fills in the label slot ([data-search-select-label]), value,
|
||||||
* and data-* attributes — so all markup and Tailwind class strings live in one
|
* and data-* attributes — so all markup and Tailwind class strings live in one
|
||||||
* place (the Python components), never duplicated here.
|
* place (the Python components), never duplicated here.
|
||||||
*/
|
*/
|
||||||
@@ -28,28 +28,28 @@
|
|||||||
|
|
||||||
function initAll() {
|
function initAll() {
|
||||||
document.querySelectorAll("[data-search-select]").forEach(function (element) {
|
document.querySelectorAll("[data-search-select]").forEach(function (element) {
|
||||||
if (element._ssInit) return;
|
if (element._searchSelectInit) return;
|
||||||
element._ssInit = true;
|
element._searchSelectInit = true;
|
||||||
initWidget(element);
|
initWidget(element);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initWidget(container) {
|
function initWidget(container) {
|
||||||
var search = container.querySelector("[data-ss-search]");
|
var search = container.querySelector("[data-search-select-search]");
|
||||||
var options = container.querySelector("[data-ss-options]");
|
var options = container.querySelector("[data-search-select-options]");
|
||||||
var pills = container.querySelector("[data-ss-pills]");
|
var pills = container.querySelector("[data-search-select-pills]");
|
||||||
if (!search || !options || !pills) return;
|
if (!search || !options || !pills) return;
|
||||||
|
|
||||||
var name = container.getAttribute("data-name");
|
var name = container.getAttribute("data-name");
|
||||||
var searchUrl = container.getAttribute("data-search-url");
|
var searchUrl = container.getAttribute("data-search-url");
|
||||||
var isFilter = container.getAttribute("data-ss-mode") === "filter";
|
var isFilter = container.getAttribute("data-search-select-mode") === "filter";
|
||||||
var multi = container.getAttribute("data-multi") === "true";
|
var multi = container.getAttribute("data-multi") === "true";
|
||||||
var alwaysVisible = container.getAttribute("data-always-visible") === "true";
|
var alwaysVisible = container.getAttribute("data-always-visible") === "true";
|
||||||
var itemsScroll = parseInt(container.getAttribute("data-items-scroll"), 10) || 10;
|
var itemsScroll = parseInt(container.getAttribute("data-items-scroll"), 10) || 10;
|
||||||
var prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0;
|
var prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0;
|
||||||
var syncUrl = container.getAttribute("data-sync-url") === "true";
|
var syncUrl = container.getAttribute("data-sync-url") === "true";
|
||||||
|
|
||||||
var noResults = options.querySelector("[data-ss-no-results]");
|
var noResults = options.querySelector("[data-search-select-no-results]");
|
||||||
var debounceTimer = null;
|
var debounceTimer = null;
|
||||||
var pendingRequest = null; // in-flight AbortController, so newer queries win
|
var pendingRequest = null; // in-flight AbortController, so newer queries win
|
||||||
var hasPrefetched = false;
|
var hasPrefetched = false;
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
|
|
||||||
// ── Render server-fetched rows into the panel ──
|
// ── Render server-fetched rows into the panel ──
|
||||||
function renderRows(items) {
|
function renderRows(items) {
|
||||||
options.querySelectorAll("[data-ss-option]").forEach(function (row) {
|
options.querySelectorAll("[data-search-select-option]").forEach(function (row) {
|
||||||
row.remove();
|
row.remove();
|
||||||
});
|
});
|
||||||
items.slice(0, itemsScroll).forEach(function (item) {
|
items.slice(0, itemsScroll).forEach(function (item) {
|
||||||
@@ -79,14 +79,14 @@
|
|||||||
// ── 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. ──
|
||||||
function cloneTemplate(name) {
|
function cloneTemplate(name) {
|
||||||
var template = container.querySelector('template[data-ss-template="' + name + '"]');
|
var template = container.querySelector('template[data-search-select-template="' + name + '"]');
|
||||||
return template
|
return template
|
||||||
? template.content.firstElementChild.cloneNode(true)
|
? template.content.firstElementChild.cloneNode(true)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLabel(node, label) {
|
function setLabel(node, label) {
|
||||||
var slot = node.querySelector("[data-ss-label]");
|
var slot = node.querySelector("[data-search-select-label]");
|
||||||
if (slot) slot.textContent = label;
|
if (slot) slot.textContent = label;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
row.setAttribute("data-label", option.label);
|
row.setAttribute("data-label", option.label);
|
||||||
applyData(row, option.data);
|
applyData(row, option.data);
|
||||||
setLabel(row, option.label);
|
setLabel(row, option.label);
|
||||||
row._ssOption = option;
|
row._searchSelectOption = option;
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
function filterRows(query) {
|
function filterRows(query) {
|
||||||
var lower = query.toLowerCase();
|
var lower = query.toLowerCase();
|
||||||
var visibleCount = 0;
|
var visibleCount = 0;
|
||||||
options.querySelectorAll("[data-ss-option]").forEach(function (item) {
|
options.querySelectorAll("[data-search-select-option]").forEach(function (item) {
|
||||||
var label = (item.getAttribute("data-label") || "").toLowerCase();
|
var label = (item.getAttribute("data-label") || "").toLowerCase();
|
||||||
var match = label.indexOf(lower) !== -1;
|
var match = label.indexOf(lower) !== -1;
|
||||||
item.style.display = match ? "" : "none";
|
item.style.display = match ? "" : "none";
|
||||||
@@ -170,13 +170,13 @@
|
|||||||
|
|
||||||
// ── Single-select combobox: the search box shows the committed label;
|
// ── Single-select combobox: the search box shows the committed label;
|
||||||
// focusing clears it to search, blurring restores it (or deselects). ──
|
// focusing clears it to search, blurring restores it (or deselects). ──
|
||||||
if (!multi) container._ssLabel = search.value;
|
if (!multi) container._searchSelectLabel = search.value;
|
||||||
|
|
||||||
search.addEventListener("focus", function () {
|
search.addEventListener("focus", function () {
|
||||||
if (!multi) {
|
if (!multi) {
|
||||||
// Hide the committed label so the box becomes a fresh search field.
|
// Hide the committed label so the box becomes a fresh search field.
|
||||||
search.value = "";
|
search.value = "";
|
||||||
container._ssDirty = false;
|
container._searchSelectDirty = false;
|
||||||
}
|
}
|
||||||
showPanel();
|
showPanel();
|
||||||
if (searchUrl) {
|
if (searchUrl) {
|
||||||
@@ -194,22 +194,22 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
search.addEventListener("input", function () {
|
search.addEventListener("input", function () {
|
||||||
if (!multi) container._ssDirty = true;
|
if (!multi) container._searchSelectDirty = true;
|
||||||
runSearch();
|
runSearch();
|
||||||
});
|
});
|
||||||
if (!multi) {
|
if (!multi) {
|
||||||
search.addEventListener("blur", function () {
|
search.addEventListener("blur", function () {
|
||||||
// Defer so an option click (which fires before blur settles) wins.
|
// Defer so an option click (which fires before blur settles) wins.
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
if (container._ssDirty && search.value.trim() === "") {
|
if (container._searchSelectDirty && search.value.trim() === "") {
|
||||||
// User intentionally cleared the box → deselect.
|
// User intentionally cleared the box → deselect.
|
||||||
pills.innerHTML = "";
|
pills.innerHTML = "";
|
||||||
container._ssLabel = "";
|
container._searchSelectLabel = "";
|
||||||
emitChange(null);
|
emitChange(null);
|
||||||
} else {
|
} else {
|
||||||
// Focused-and-left, or typed a partial query without picking →
|
// Focused-and-left, or typed a partial query without picking →
|
||||||
// restore the committed label (no-op right after a selection).
|
// restore the committed label (no-op right after a selection).
|
||||||
search.value = container._ssLabel || "";
|
search.value = container._searchSelectLabel || "";
|
||||||
}
|
}
|
||||||
}, 120);
|
}, 120);
|
||||||
});
|
});
|
||||||
@@ -226,27 +226,27 @@
|
|||||||
handleFilterOptionClick(e);
|
handleFilterOptionClick(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var row = event.target.closest("[data-ss-option]");
|
var row = event.target.closest("[data-search-select-option]");
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
selectOption(optionFromRow(row));
|
selectOption(optionFromRow(row));
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleFilterOptionClick(e) {
|
function handleFilterOptionClick(e) {
|
||||||
// Pinned modifier pseudo-option → set the (mutually exclusive) modifier.
|
// Pinned modifier pseudo-option → set the (mutually exclusive) modifier.
|
||||||
var modifierRow = event.target.closest("[data-ss-modifier-option]");
|
var modifierRow = event.target.closest("[data-search-select-modifier-option]");
|
||||||
if (modifierRow) {
|
if (modifierRow) {
|
||||||
setModifier(
|
setModifier(
|
||||||
modifierRow.getAttribute("data-ss-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.
|
||||||
var button = event.target.closest("[data-ss-action]");
|
var button = event.target.closest("[data-search-select-action]");
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
var row = button.closest("[data-ss-option]");
|
var row = button.closest("[data-search-select-option]");
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
addFilterPill(optionFromRow(row), button.getAttribute("data-ss-action"));
|
addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add (or re-type) an include/exclude pill for a value. Selecting any value
|
// Add (or re-type) an include/exclude pill for a value. Selecting any value
|
||||||
@@ -274,7 +274,7 @@
|
|||||||
function setModifier(modifierValue, label) {
|
function setModifier(modifierValue, label) {
|
||||||
pills.innerHTML = "";
|
pills.innerHTML = "";
|
||||||
var pill = cloneTemplate("pill-modifier");
|
var pill = cloneTemplate("pill-modifier");
|
||||||
pill.setAttribute("data-ss-modifier", modifierValue);
|
pill.setAttribute("data-search-select-modifier", modifierValue);
|
||||||
setLabel(pill, label);
|
setLabel(pill, label);
|
||||||
pills.appendChild(pill);
|
pills.appendChild(pill);
|
||||||
container.setAttribute("data-modifier", modifierValue);
|
container.setAttribute("data-modifier", modifierValue);
|
||||||
@@ -283,13 +283,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearModifier() {
|
function clearModifier() {
|
||||||
var modifierPill = pills.querySelector("[data-ss-modifier]");
|
var modifierPill = pills.querySelector("[data-search-select-modifier]");
|
||||||
if (modifierPill) modifierPill.remove();
|
if (modifierPill) modifierPill.remove();
|
||||||
container.removeAttribute("data-modifier");
|
container.removeAttribute("data-modifier");
|
||||||
}
|
}
|
||||||
|
|
||||||
function optionFromRow(row) {
|
function optionFromRow(row) {
|
||||||
if (row._ssOption) return row._ssOption;
|
if (row._searchSelectOption) return row._searchSelectOption;
|
||||||
var data = {};
|
var data = {};
|
||||||
Object.keys(row.dataset).forEach(function (key) {
|
Object.keys(row.dataset).forEach(function (key) {
|
||||||
if (key !== "value" && key !== "label" && key !== "ssOption") {
|
if (key !== "value" && key !== "label" && key !== "ssOption") {
|
||||||
@@ -310,12 +310,12 @@
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Single-select: no pill — show the label in the search box and keep a
|
// Single-select: no pill — show the label in the search box and keep a
|
||||||
// lone hidden input under [data-ss-pills] for submission.
|
// lone hidden input under [data-search-select-pills] for submission.
|
||||||
pills.innerHTML = "";
|
pills.innerHTML = "";
|
||||||
pills.appendChild(buildHidden(option.value));
|
pills.appendChild(buildHidden(option.value));
|
||||||
search.value = option.label;
|
search.value = option.label;
|
||||||
container._ssLabel = option.label;
|
container._searchSelectLabel = option.label;
|
||||||
container._ssDirty = false;
|
container._searchSelectDirty = false;
|
||||||
hidePanel();
|
hidePanel();
|
||||||
}
|
}
|
||||||
emitChange(option);
|
emitChange(option);
|
||||||
@@ -353,7 +353,7 @@
|
|||||||
if (isFilter) {
|
if (isFilter) {
|
||||||
// Filter pills have no hidden input; a modifier pill also clears the
|
// Filter pills have no hidden input; a modifier pill also clears the
|
||||||
// container flag.
|
// container flag.
|
||||||
if (pill.hasAttribute("data-ss-modifier")) {
|
if (pill.hasAttribute("data-search-select-modifier")) {
|
||||||
container.removeAttribute("data-modifier");
|
container.removeAttribute("data-modifier");
|
||||||
}
|
}
|
||||||
pill.remove();
|
pill.remove();
|
||||||
@@ -422,20 +422,20 @@
|
|||||||
// bar to read.
|
// bar to read.
|
||||||
window.readSearchSelect = function (form) {
|
window.readSearchSelect = function (form) {
|
||||||
form.querySelectorAll("[data-search-select]").forEach(function (container) {
|
form.querySelectorAll("[data-search-select]").forEach(function (container) {
|
||||||
var pills = container.querySelector("[data-ss-pills]");
|
var pills = container.querySelector("[data-search-select-pills]");
|
||||||
if (container.getAttribute("data-ss-mode") === "filter") {
|
if (container.getAttribute("data-search-select-mode") === "filter") {
|
||||||
var included = [];
|
var included = [];
|
||||||
var excluded = [];
|
var excluded = [];
|
||||||
var modifier = "";
|
var modifier = "";
|
||||||
if (pills) {
|
if (pills) {
|
||||||
pills.querySelectorAll("[data-pill]").forEach(function (pill) {
|
pills.querySelectorAll("[data-pill]").forEach(function (pill) {
|
||||||
var pillModifier = pill.getAttribute("data-ss-modifier");
|
var pillModifier = pill.getAttribute("data-search-select-modifier");
|
||||||
if (pillModifier) {
|
if (pillModifier) {
|
||||||
modifier = pillModifier;
|
modifier = pillModifier;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var value = pill.getAttribute("data-value");
|
var value = pill.getAttribute("data-value");
|
||||||
if (pill.getAttribute("data-ss-type") === "exclude") {
|
if (pill.getAttribute("data-search-select-type") === "exclude") {
|
||||||
excluded.push(value);
|
excluded.push(value);
|
||||||
} else {
|
} else {
|
||||||
included.push(value);
|
included.push(value);
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
|
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertIn('data-ss-mode="filter"', html)
|
self.assertIn('data-search-select-mode="filter"', html)
|
||||||
self.assertIn('data-ss-type="include"', html) # rendered as an include pill
|
self.assertIn('data-search-select-type="include"', html) # rendered as an include pill
|
||||||
self.assertIn('data-value="f"', html) # selected status reflected in widget
|
self.assertIn('data-value="f"', html) # selected status reflected in widget
|
||||||
self.assertIn("Finished", html) # ...with its label
|
self.assertIn("Finished", html) # ...with its label
|
||||||
self.assertNoEscapedTags(html)
|
self.assertNoEscapedTags(html)
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ class TestFilterBarRendering:
|
|||||||
|
|
||||||
def test_status_uses_filter_select(self):
|
def test_status_uses_filter_select(self):
|
||||||
html = str(FilterBar())
|
html = str(FilterBar())
|
||||||
assert 'data-ss-mode="filter"' in html
|
assert 'data-search-select-mode="filter"' in html
|
||||||
assert 'data-name="status"' in html
|
assert 'data-name="status"' in html
|
||||||
|
|
||||||
def test_mastered_not_checked_by_default(self):
|
def test_mastered_not_checked_by_default(self):
|
||||||
|
|||||||
+32
-32
@@ -52,7 +52,7 @@ class SearchSelectComponentTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_empty_options_renders_no_results_scaffold(self):
|
def test_empty_options_renders_no_results_scaffold(self):
|
||||||
html = SearchSelect(name="games")
|
html = SearchSelect(name="games")
|
||||||
self.assertIn("data-ss-no-results", html)
|
self.assertIn("data-search-select-no-results", html)
|
||||||
self.assertIn("No results", html)
|
self.assertIn("No results", html)
|
||||||
|
|
||||||
def test_outer_container_carries_config(self):
|
def test_outer_container_carries_config(self):
|
||||||
@@ -91,13 +91,13 @@ class SearchSelectComponentTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_search_box_has_no_name(self):
|
def test_search_box_has_no_name(self):
|
||||||
html = SearchSelect(name="games")
|
html = SearchSelect(name="games")
|
||||||
self.assertIn("data-ss-search", html)
|
self.assertIn("data-search-select-search", html)
|
||||||
# container exposes data-name, never a submittable name on the search box
|
# container exposes data-name, never a submittable name on the search box
|
||||||
self.assertEqual(html.count(' name="games"'), 0)
|
self.assertEqual(html.count(' name="games"'), 0)
|
||||||
|
|
||||||
def test_tuple_options_are_normalized(self):
|
def test_tuple_options_are_normalized(self):
|
||||||
html = SearchSelect(name="t", options=[("1", "One")])
|
html = SearchSelect(name="t", options=[("1", "One")])
|
||||||
self.assertIn('data-ss-option=""', html)
|
self.assertIn('data-search-select-option=""', html)
|
||||||
self.assertIn('data-value="1"', html)
|
self.assertIn('data-value="1"', html)
|
||||||
self.assertIn("One", html)
|
self.assertIn("One", html)
|
||||||
|
|
||||||
@@ -107,27 +107,27 @@ class SearchSelectComponentTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
# No pre-rendered rows in the live panel; the row prototype lives only in
|
# No pre-rendered rows in the live panel; the row prototype lives only in
|
||||||
# the cloneable <template>.
|
# the cloneable <template>.
|
||||||
panel = html.split("data-ss-template")[0]
|
panel = html.split("data-search-select-template")[0]
|
||||||
self.assertNotIn('data-ss-option=""', panel)
|
self.assertNotIn('data-search-select-option=""', panel)
|
||||||
self.assertIn('data-ss-template="row"', html)
|
self.assertIn('data-search-select-template="row"', html)
|
||||||
|
|
||||||
def test_templates_carry_label_slot_for_js_cloning(self):
|
def test_templates_carry_label_slot_for_js_cloning(self):
|
||||||
# The dynamic shapes the JS clones expose a [data-ss-label] slot so the JS
|
# The dynamic shapes the JS clones expose a [data-search-select-label] slot so the JS
|
||||||
# only fills text — classes/structure stay server-side.
|
# only fills text — classes/structure stay server-side.
|
||||||
html = SearchSelect(name="t", search_url="/api/games/search", multi_select=True)
|
html = SearchSelect(name="t", search_url="/api/games/search", multi_select=True)
|
||||||
self.assertIn('data-ss-template="row"', html)
|
self.assertIn('data-search-select-template="row"', html)
|
||||||
self.assertIn('data-ss-template="pill"', html)
|
self.assertIn('data-search-select-template="pill"', html)
|
||||||
self.assertIn("data-ss-label", html)
|
self.assertIn("data-search-select-label", html)
|
||||||
|
|
||||||
def test_shell_region_order_pills_search_options(self):
|
def test_shell_region_order_pills_search_options(self):
|
||||||
# The shared shell assembles the three regions in a fixed order; option
|
# The shared shell assembles the three regions in a fixed order; option
|
||||||
# rows precede the trailing no-results node inside the options panel.
|
# rows precede the trailing no-results node inside the options panel.
|
||||||
html = SearchSelect(name="t", options=[("1", "One")])
|
html = SearchSelect(name="t", options=[("1", "One")])
|
||||||
pills = html.index("data-ss-pills")
|
pills = html.index("data-search-select-pills")
|
||||||
search = html.index("data-ss-search")
|
search = html.index("data-search-select-search")
|
||||||
options = html.index("data-ss-options")
|
options = html.index("data-search-select-options")
|
||||||
option_row = html.index('data-ss-option=""')
|
option_row = html.index('data-search-select-option=""')
|
||||||
no_results = html.index("data-ss-no-results")
|
no_results = html.index("data-search-select-no-results")
|
||||||
self.assertLess(pills, search)
|
self.assertLess(pills, search)
|
||||||
self.assertLess(search, options)
|
self.assertLess(search, options)
|
||||||
self.assertLess(options, option_row)
|
self.assertLess(options, option_row)
|
||||||
@@ -144,15 +144,15 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
html = FilterSelect(field_name="type")
|
html = FilterSelect(field_name="type")
|
||||||
# Reuses the SearchSelect shell (data-search-select) but flags filter mode.
|
# Reuses the SearchSelect shell (data-search-select) but flags filter mode.
|
||||||
self.assertIn("data-search-select", html)
|
self.assertIn("data-search-select", html)
|
||||||
self.assertIn('data-ss-mode="filter"', html)
|
self.assertIn('data-search-select-mode="filter"', html)
|
||||||
self.assertIn('data-name="type"', html)
|
self.assertIn('data-name="type"', html)
|
||||||
# No name is submitted — state is read from the DOM into the filter JSON.
|
# No name is submitted — state is read from the DOM into the filter JSON.
|
||||||
self.assertEqual(html.count(' name="type"'), 0)
|
self.assertEqual(html.count(' name="type"'), 0)
|
||||||
|
|
||||||
def test_value_rows_have_include_exclude_buttons(self):
|
def test_value_rows_have_include_exclude_buttons(self):
|
||||||
html = FilterSelect(field_name="type", options=[("g", "Game")])
|
html = FilterSelect(field_name="type", options=[("g", "Game")])
|
||||||
self.assertIn('data-ss-action="include"', html)
|
self.assertIn('data-search-select-action="include"', html)
|
||||||
self.assertIn('data-ss-action="exclude"', html)
|
self.assertIn('data-search-select-action="exclude"', html)
|
||||||
self.assertIn('data-value="g"', html)
|
self.assertIn('data-value="g"', html)
|
||||||
|
|
||||||
def test_included_renders_check_pill_excluded_renders_cross_pill(self):
|
def test_included_renders_check_pill_excluded_renders_cross_pill(self):
|
||||||
@@ -162,22 +162,22 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
included=[("1", "Steam")],
|
included=[("1", "Steam")],
|
||||||
excluded=[("2", "GOG")],
|
excluded=[("2", "GOG")],
|
||||||
)
|
)
|
||||||
# Labels live in a [data-ss-label] slot (so JS can fill clones); the ✓/✗
|
# Labels live in a [data-search-select-label] slot (so JS can fill clones); the ✓/✗
|
||||||
# symbol is a sibling text node.
|
# symbol is a sibling text node.
|
||||||
self.assertIn('data-ss-type="include"', html)
|
self.assertIn('data-search-select-type="include"', html)
|
||||||
self.assertIn("✓", html)
|
self.assertIn("✓", html)
|
||||||
self.assertIn(">Steam</span>", html)
|
self.assertIn(">Steam</span>", html)
|
||||||
self.assertIn('data-ss-type="exclude"', html)
|
self.assertIn('data-search-select-type="exclude"', html)
|
||||||
self.assertIn("✗", html)
|
self.assertIn("✗", html)
|
||||||
self.assertIn(">GOG</span>", html)
|
self.assertIn(">GOG</span>", html)
|
||||||
self.assertIn("line-through", html) # excluded pill styling
|
self.assertIn("line-through", html) # excluded pill styling
|
||||||
|
|
||||||
def test_modifier_options_render_pinned_rows(self):
|
def test_modifier_options_render_pinned_rows(self):
|
||||||
html = FilterSelect(field_name="platform", modifier_options=self.MODIFIERS)
|
html = FilterSelect(field_name="platform", modifier_options=self.MODIFIERS)
|
||||||
# Pinned pseudo-options carry data-ss-modifier-option, never data-ss-option,
|
# Pinned pseudo-options carry data-search-select-modifier-option, never data-search-select-option,
|
||||||
# so the text filter leaves them visible.
|
# so the text filter leaves them visible.
|
||||||
self.assertIn('data-ss-modifier-option="NOT_NULL"', html)
|
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
|
||||||
self.assertIn('data-ss-modifier-option="IS_NULL"', html)
|
self.assertIn('data-search-select-modifier-option="IS_NULL"', html)
|
||||||
|
|
||||||
def test_active_modifier_replaces_value_pills(self):
|
def test_active_modifier_replaces_value_pills(self):
|
||||||
html = FilterSelect(
|
html = FilterSelect(
|
||||||
@@ -189,11 +189,11 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
# The lone modifier pill is shown; include/exclude pills are suppressed.
|
# The lone modifier pill is shown; include/exclude pills are suppressed.
|
||||||
# (Scope the check to the live pills region — the cloneable pill <template>s
|
# (Scope the check to the live pills region — the cloneable pill <template>s
|
||||||
# legitimately contain data-ss-type.)
|
# legitimately contain data-search-select-type.)
|
||||||
pills_region = html.split("data-ss-template")[0]
|
pills_region = html.split("data-search-select-template")[0]
|
||||||
self.assertIn('data-ss-modifier="IS_NULL"', html)
|
self.assertIn('data-search-select-modifier="IS_NULL"', html)
|
||||||
self.assertIn("(None)", html)
|
self.assertIn("(None)", html)
|
||||||
self.assertNotIn('data-ss-type="include"', pills_region)
|
self.assertNotIn('data-search-select-type="include"', pills_region)
|
||||||
self.assertIn('data-modifier="IS_NULL"', html) # container carries it too
|
self.assertIn('data-modifier="IS_NULL"', html) # container carries it too
|
||||||
|
|
||||||
def test_search_url_omits_value_rows_but_keeps_modifiers(self):
|
def test_search_url_omits_value_rows_but_keeps_modifiers(self):
|
||||||
@@ -205,10 +205,10 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
# No value rows in the live panel (they're fetched); the row prototype
|
# No value rows in the live panel (they're fetched); the row prototype
|
||||||
# lives only in a <template>.
|
# lives only in a <template>.
|
||||||
panel = html.split("data-ss-template")[0]
|
panel = html.split("data-search-select-template")[0]
|
||||||
self.assertNotIn('data-ss-option=""', panel)
|
self.assertNotIn('data-search-select-option=""', panel)
|
||||||
self.assertIn('data-ss-template="row"', html)
|
self.assertIn('data-search-select-template="row"', html)
|
||||||
self.assertIn('data-ss-modifier-option="NOT_NULL"', html) # still pinned
|
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html) # still pinned
|
||||||
self.assertIn('data-prefetch="20"', html)
|
self.assertIn('data-prefetch="20"', html)
|
||||||
|
|
||||||
def test_search_url_pills_use_resolved_labels(self):
|
def test_search_url_pills_use_resolved_labels(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user