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
+3 -3
View File
@@ -59,10 +59,10 @@
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(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) {
var field = widget.getAttribute("data-name");
var included = parseJSONAttr(widget, "data-included");
@@ -86,7 +86,7 @@
// ── Session-specific fields ──
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)
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
* <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
* modifier pseudo-options ((Any)/(None)) that are mutually exclusive with value
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
* state into data-included / data-excluded / data-modifier for the filter bar.
*
* 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
* 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
* place (the Python components), never duplicated here.
*/
@@ -28,28 +28,28 @@
function initAll() {
document.querySelectorAll("[data-search-select]").forEach(function (element) {
if (element._ssInit) return;
element._ssInit = true;
if (element._searchSelectInit) return;
element._searchSelectInit = true;
initWidget(element);
});
}
function initWidget(container) {
var search = container.querySelector("[data-ss-search]");
var options = container.querySelector("[data-ss-options]");
var pills = container.querySelector("[data-ss-pills]");
var search = container.querySelector("[data-search-select-search]");
var options = container.querySelector("[data-search-select-options]");
var pills = container.querySelector("[data-search-select-pills]");
if (!search || !options || !pills) return;
var name = container.getAttribute("data-name");
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 alwaysVisible = container.getAttribute("data-always-visible") === "true";
var itemsScroll = parseInt(container.getAttribute("data-items-scroll"), 10) || 10;
var prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0;
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 pendingRequest = null; // in-flight AbortController, so newer queries win
var hasPrefetched = false;
@@ -67,7 +67,7 @@
// ── Render server-fetched rows into the panel ──
function renderRows(items) {
options.querySelectorAll("[data-ss-option]").forEach(function (row) {
options.querySelectorAll("[data-search-select-option]").forEach(function (row) {
row.remove();
});
items.slice(0, itemsScroll).forEach(function (item) {
@@ -79,14 +79,14 @@
// ── Clone a server-rendered <template> prototype by name. The server emits
// the mode-appropriate prototypes, so the JS never names a class. ──
function cloneTemplate(name) {
var template = container.querySelector('template[data-ss-template="' + name + '"]');
var template = container.querySelector('template[data-search-select-template="' + name + '"]');
return template
? template.content.firstElementChild.cloneNode(true)
: null;
}
function setLabel(node, label) {
var slot = node.querySelector("[data-ss-label]");
var slot = node.querySelector("[data-search-select-label]");
if (slot) slot.textContent = label;
}
@@ -106,7 +106,7 @@
row.setAttribute("data-label", option.label);
applyData(row, option.data);
setLabel(row, option.label);
row._ssOption = option;
row._searchSelectOption = option;
return row;
}
@@ -115,7 +115,7 @@
function filterRows(query) {
var lower = query.toLowerCase();
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 match = label.indexOf(lower) !== -1;
item.style.display = match ? "" : "none";
@@ -170,13 +170,13 @@
// ── Single-select combobox: the search box shows the committed label;
// 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 () {
if (!multi) {
// Hide the committed label so the box becomes a fresh search field.
search.value = "";
container._ssDirty = false;
container._searchSelectDirty = false;
}
showPanel();
if (searchUrl) {
@@ -194,22 +194,22 @@
}
});
search.addEventListener("input", function () {
if (!multi) container._ssDirty = true;
if (!multi) container._searchSelectDirty = true;
runSearch();
});
if (!multi) {
search.addEventListener("blur", function () {
// Defer so an option click (which fires before blur settles) wins.
setTimeout(function () {
if (container._ssDirty && search.value.trim() === "") {
if (container._searchSelectDirty && search.value.trim() === "") {
// User intentionally cleared the box → deselect.
pills.innerHTML = "";
container._ssLabel = "";
container._searchSelectLabel = "";
emitChange(null);
} else {
// Focused-and-left, or typed a partial query without picking →
// restore the committed label (no-op right after a selection).
search.value = container._ssLabel || "";
search.value = container._searchSelectLabel || "";
}
}, 120);
});
@@ -226,27 +226,27 @@
handleFilterOptionClick(e);
return;
}
var row = event.target.closest("[data-ss-option]");
var row = event.target.closest("[data-search-select-option]");
if (!row) return;
selectOption(optionFromRow(row));
});
function handleFilterOptionClick(e) {
// 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) {
setModifier(
modifierRow.getAttribute("data-ss-modifier-option"),
modifierRow.getAttribute("data-search-select-modifier-option"),
modifierRow.getAttribute("data-label")
);
return;
}
// 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;
var row = button.closest("[data-ss-option]");
var row = button.closest("[data-search-select-option]");
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
@@ -274,7 +274,7 @@
function setModifier(modifierValue, label) {
pills.innerHTML = "";
var pill = cloneTemplate("pill-modifier");
pill.setAttribute("data-ss-modifier", modifierValue);
pill.setAttribute("data-search-select-modifier", modifierValue);
setLabel(pill, label);
pills.appendChild(pill);
container.setAttribute("data-modifier", modifierValue);
@@ -283,13 +283,13 @@
}
function clearModifier() {
var modifierPill = pills.querySelector("[data-ss-modifier]");
var modifierPill = pills.querySelector("[data-search-select-modifier]");
if (modifierPill) modifierPill.remove();
container.removeAttribute("data-modifier");
}
function optionFromRow(row) {
if (row._ssOption) return row._ssOption;
if (row._searchSelectOption) return row._searchSelectOption;
var data = {};
Object.keys(row.dataset).forEach(function (key) {
if (key !== "value" && key !== "label" && key !== "ssOption") {
@@ -310,12 +310,12 @@
}
} else {
// 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.appendChild(buildHidden(option.value));
search.value = option.label;
container._ssLabel = option.label;
container._ssDirty = false;
container._searchSelectLabel = option.label;
container._searchSelectDirty = false;
hidePanel();
}
emitChange(option);
@@ -353,7 +353,7 @@
if (isFilter) {
// Filter pills have no hidden input; a modifier pill also clears the
// container flag.
if (pill.hasAttribute("data-ss-modifier")) {
if (pill.hasAttribute("data-search-select-modifier")) {
container.removeAttribute("data-modifier");
}
pill.remove();
@@ -422,20 +422,20 @@
// bar to read.
window.readSearchSelect = function (form) {
form.querySelectorAll("[data-search-select]").forEach(function (container) {
var pills = container.querySelector("[data-ss-pills]");
if (container.getAttribute("data-ss-mode") === "filter") {
var pills = container.querySelector("[data-search-select-pills]");
if (container.getAttribute("data-search-select-mode") === "filter") {
var included = [];
var excluded = [];
var modifier = "";
if (pills) {
pills.querySelectorAll("[data-pill]").forEach(function (pill) {
var pillModifier = pill.getAttribute("data-ss-modifier");
var pillModifier = pill.getAttribute("data-search-select-modifier");
if (pillModifier) {
modifier = pillModifier;
return;
}
var value = pill.getAttribute("data-value");
if (pill.getAttribute("data-ss-type") === "exclude") {
if (pill.getAttribute("data-search-select-type") === "exclude") {
excluded.push(value);
} else {
included.push(value);