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:
Claude
2026-06-08 14:13:05 +00:00
committed by Lukáš Kucharczyk
parent a06e772e42
commit 15bb3ce1b9
7 changed files with 106 additions and 97 deletions
+6 -2
View File
@@ -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
) )
+24 -19
View File
@@ -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"),
+3 -3
View File
@@ -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"]');
+38 -38
View File
@@ -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);
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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
View File
@@ -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):