Single-source combobox markup via <template> cloning
Eliminate the Python/JS class-string duplication: the server renders hidden <template> prototypes (row, pill, include/exclude/modifier pills) using the same component functions, and search_select.js clones them, filling only the [data-ss-label] slot, value, and data-* attrs. All Tailwind class strings and DOM structure now live solely in the Python components — the JS no longer hardcodes any class. Pill gains an opt-in label_slot; the shell takes a templates list. Companion issue #8 tracks the further HTMX-idiomatic step of returning rendered row HTML from the search endpoint. https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
This commit is contained in:
@@ -386,6 +386,7 @@ def Pill(
|
|||||||
value: str = "",
|
value: str = "",
|
||||||
removable: bool = False,
|
removable: bool = False,
|
||||||
extra_class: str = "",
|
extra_class: str = "",
|
||||||
|
label_slot: bool = False,
|
||||||
attributes: list[HTMLAttribute] | None = None,
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
"""A small label pill, optionally removable (× button).
|
"""A small label pill, optionally removable (× button).
|
||||||
@@ -393,6 +394,10 @@ def Pill(
|
|||||||
Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove``
|
Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove``
|
||||||
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
|
||||||
|
fill it when cloning the pill from a server-rendered ``<template>`` (keeps the
|
||||||
|
markup single-sourced — see ``search_select.py``).
|
||||||
"""
|
"""
|
||||||
attributes = attributes or []
|
attributes = attributes or []
|
||||||
pill_class = f"{_PILL_CLASS} {extra_class}".strip()
|
pill_class = f"{_PILL_CLASS} {extra_class}".strip()
|
||||||
@@ -401,7 +406,12 @@ def Pill(
|
|||||||
pill_attrs.append(("data-value", str(value)))
|
pill_attrs.append(("data-value", str(value)))
|
||||||
pill_attrs.extend(attributes)
|
pill_attrs.extend(attributes)
|
||||||
|
|
||||||
children: list[HTMLTag] = [label]
|
label_child: HTMLTag = (
|
||||||
|
Component(tag_name="span", attributes=[("data-ss-label", "")], children=[label])
|
||||||
|
if label_slot
|
||||||
|
else label
|
||||||
|
)
|
||||||
|
children: list[HTMLTag] = [label_child]
|
||||||
if removable:
|
if removable:
|
||||||
children.append(
|
children.append(
|
||||||
Component(
|
Component(
|
||||||
|
|||||||
@@ -119,6 +119,29 @@ def _hidden_input(name: str, value) -> 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
|
||||||
|
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."""
|
||||||
|
attributes: list[HTMLAttribute] = [("data-ss-label", "")]
|
||||||
|
if extra_class:
|
||||||
|
attributes.append(("class", extra_class))
|
||||||
|
return Component(tag_name="span", attributes=attributes, children=[text])
|
||||||
|
|
||||||
|
|
||||||
|
def _template(name: str, node: SafeText) -> SafeText:
|
||||||
|
"""Wrap a prototype row/pill in an inert ``<template data-ss-tpl=name>`` that
|
||||||
|
the JS clones. Rendering the prototype with the real component keeps the JS
|
||||||
|
free of any markup or class strings."""
|
||||||
|
return Component(
|
||||||
|
tag_name="template", attributes=[("data-ss-tpl", name)], children=[node]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# A placeholder option for rendering template prototypes (JS overwrites it).
|
||||||
|
_BLANK_OPTION: SearchSelectOption = {"value": "", "label": "", "data": {}}
|
||||||
|
|
||||||
|
|
||||||
def _option_row(option: SearchSelectOption) -> SafeText:
|
def _option_row(option: SearchSelectOption) -> SafeText:
|
||||||
return Component(
|
return Component(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
@@ -129,7 +152,7 @@ def _option_row(option: SearchSelectOption) -> SafeText:
|
|||||||
("class", _OPTION_ROW_CLASS),
|
("class", _OPTION_ROW_CLASS),
|
||||||
*_data_attributes(option["data"]),
|
*_data_attributes(option["data"]),
|
||||||
],
|
],
|
||||||
children=[option["label"]],
|
children=[_label_slot(option["label"])],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -141,6 +164,7 @@ def _combobox_shell(
|
|||||||
options_children: list[SafeText],
|
options_children: list[SafeText],
|
||||||
always_visible: bool,
|
always_visible: bool,
|
||||||
items_visible: int,
|
items_visible: int,
|
||||||
|
templates: list[SafeText] | None = None,
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
"""Assemble the shared, domain-agnostic combobox skeleton.
|
"""Assemble the shared, domain-agnostic combobox skeleton.
|
||||||
|
|
||||||
@@ -148,9 +172,11 @@ def _combobox_shell(
|
|||||||
same order: the ``pills`` region, the search box, and the options panel (which
|
same order: the ``pills`` region, the search box, and the options panel (which
|
||||||
always carries a trailing no-results node). Callers supply the already-built
|
always carries a trailing no-results node). Callers supply the already-built
|
||||||
``pills`` region, the ``search_attributes`` for the text box, the
|
``pills`` region, the ``search_attributes`` for the text box, the
|
||||||
``options_children`` (value rows plus any pinned pseudo-options), and the
|
``options_children`` (value rows plus any pinned pseudo-options), the
|
||||||
``container_attributes`` that carry the widget's identity and behaviour flags.
|
``container_attributes`` that carry the widget's identity and behaviour flags,
|
||||||
The shell knows nothing about how individual rows or pills look.
|
and any ``templates`` (inert ``<template>`` prototypes the JS clones for
|
||||||
|
dynamically-added rows/pills). The shell knows nothing about how individual
|
||||||
|
rows or pills look.
|
||||||
"""
|
"""
|
||||||
search = Component(tag_name="input", attributes=search_attributes)
|
search = Component(tag_name="input", attributes=search_attributes)
|
||||||
|
|
||||||
@@ -173,7 +199,7 @@ def _combobox_shell(
|
|||||||
return Component(
|
return Component(
|
||||||
tag_name="div",
|
tag_name="div",
|
||||||
attributes=container_attributes,
|
attributes=container_attributes,
|
||||||
children=[pills, search, options_panel],
|
children=[pills, search, options_panel, *(templates or [])],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -211,6 +237,7 @@ def SearchSelect(
|
|||||||
option["label"],
|
option["label"],
|
||||||
value=str(option["value"]),
|
value=str(option["value"]),
|
||||||
removable=True,
|
removable=True,
|
||||||
|
label_slot=True,
|
||||||
attributes=_data_attributes(option["data"]),
|
attributes=_data_attributes(option["data"]),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -242,6 +269,16 @@ def SearchSelect(
|
|||||||
# ── Options panel (pre-rendered only when there is no search_url) ──
|
# ── Options panel (pre-rendered only when there is no search_url) ──
|
||||||
option_rows = [_option_row(o) for o in options] if not search_url else []
|
option_rows = [_option_row(o) for o in options] if not search_url else []
|
||||||
|
|
||||||
|
# ── Templates the JS clones: a row when results are fetched, a pill when
|
||||||
|
# multi-select adds chosen items. ──
|
||||||
|
templates: list[SafeText] = []
|
||||||
|
if search_url:
|
||||||
|
templates.append(_template("row", _option_row(_BLANK_OPTION)))
|
||||||
|
if multi_select:
|
||||||
|
templates.append(
|
||||||
|
_template("pill", Pill("", value="", removable=True, label_slot=True))
|
||||||
|
)
|
||||||
|
|
||||||
container_attributes: list[HTMLAttribute] = [
|
container_attributes: list[HTMLAttribute] = [
|
||||||
("data-search-select", ""),
|
("data-search-select", ""),
|
||||||
("data-name", name),
|
("data-name", name),
|
||||||
@@ -264,6 +301,7 @@ def SearchSelect(
|
|||||||
options_children=option_rows,
|
options_children=option_rows,
|
||||||
always_visible=always_visible,
|
always_visible=always_visible,
|
||||||
items_visible=items_visible,
|
items_visible=items_visible,
|
||||||
|
templates=templates,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -296,7 +334,7 @@ def _filter_value_pill(option: SearchSelectOption, kind: str) -> SafeText:
|
|||||||
("data-ss-type", kind),
|
("data-ss-type", kind),
|
||||||
*_data_attributes(option["data"]),
|
*_data_attributes(option["data"]),
|
||||||
],
|
],
|
||||||
children=[f"{symbol} {option['label']}", _filter_remove_button()],
|
children=[f"{symbol} ", _label_slot(option["label"]), _filter_remove_button()],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -309,7 +347,7 @@ def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText:
|
|||||||
("data-pill", ""),
|
("data-pill", ""),
|
||||||
("data-ss-modifier", modifier_value),
|
("data-ss-modifier", modifier_value),
|
||||||
],
|
],
|
||||||
children=[label, _filter_remove_button()],
|
children=[_label_slot(label), _filter_remove_button()],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -337,11 +375,7 @@ def _filter_option_row(value: str | int, label: str) -> SafeText:
|
|||||||
("class", _FILTER_OPTION_ROW_CLASS),
|
("class", _FILTER_OPTION_ROW_CLASS),
|
||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
Component(
|
_label_slot(label, extra_class=_FILTER_OPTION_LABEL_CLASS),
|
||||||
tag_name="span",
|
|
||||||
attributes=[("class", _FILTER_OPTION_LABEL_CLASS)],
|
|
||||||
children=[label],
|
|
||||||
),
|
|
||||||
Component(
|
Component(
|
||||||
tag_name="span",
|
tag_name="span",
|
||||||
attributes=[("class", _FILTER_OPTION_BUTTONS_CLASS)],
|
attributes=[("class", _FILTER_OPTION_BUTTONS_CLASS)],
|
||||||
@@ -441,6 +475,17 @@ def FilterSelect(
|
|||||||
else []
|
else []
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Templates the JS clones: include/exclude pills (added on click), the
|
||||||
|
# modifier pill (when modifiers exist), and a value row (when fetched). ──
|
||||||
|
templates: list[SafeText] = [
|
||||||
|
_template("pill-include", _filter_value_pill(_BLANK_OPTION, "include")),
|
||||||
|
_template("pill-exclude", _filter_value_pill(_BLANK_OPTION, "exclude")),
|
||||||
|
]
|
||||||
|
if modifier_options:
|
||||||
|
templates.append(_template("pill-modifier", _filter_modifier_pill("", "")))
|
||||||
|
if search_url:
|
||||||
|
templates.append(_template("row", _filter_option_row("", "")))
|
||||||
|
|
||||||
container_attributes: list[HTMLAttribute] = [
|
container_attributes: list[HTMLAttribute] = [
|
||||||
("data-search-select", ""),
|
("data-search-select", ""),
|
||||||
("data-ss-mode", "filter"),
|
("data-ss-mode", "filter"),
|
||||||
@@ -466,6 +511,7 @@ def FilterSelect(
|
|||||||
options_children=[*modifier_rows, *value_rows],
|
options_children=[*modifier_rows, *value_rows],
|
||||||
always_visible=False,
|
always_visible=False,
|
||||||
items_visible=items_visible,
|
items_visible=items_visible,
|
||||||
|
templates=templates,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,42 +15,15 @@
|
|||||||
* initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with
|
* initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with
|
||||||
* el._ssInit.
|
* el._ssInit.
|
||||||
*
|
*
|
||||||
* The pill / option class strings below are kept byte-identical to the Python
|
* Dynamically-added rows and pills are cloned from hidden <template> elements
|
||||||
* Pill / SearchSelect / FilterSelect components so Tailwind generates the classes
|
* the server renders with the same Python components (Pill / SearchSelect /
|
||||||
* and server-rendered and JS-created rows/pills are indistinguishable.
|
* FilterSelect). The JS only fills in the label slot ([data-ss-label]), value,
|
||||||
|
* and data-* attributes — so all markup and Tailwind class strings live in one
|
||||||
|
* place (the Python components), never duplicated here.
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
var PILL_CLASS =
|
|
||||||
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded " +
|
|
||||||
"bg-brand/15 text-heading";
|
|
||||||
var PILL_REMOVE_CLASS =
|
|
||||||
"ml-1 text-body hover:text-heading font-bold cursor-pointer";
|
|
||||||
var OPTION_ROW_CLASS =
|
|
||||||
"px-3 py-2 text-sm text-heading cursor-pointer hover:bg-brand/15";
|
|
||||||
|
|
||||||
// Filter-mode class strings — byte-identical to the FilterSelect constants in
|
|
||||||
// common/components/search_select.py.
|
|
||||||
var FILTER_INCLUDE_PILL_CLASS =
|
|
||||||
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded " +
|
|
||||||
"bg-brand/15 text-heading";
|
|
||||||
var FILTER_EXCLUDE_PILL_CLASS =
|
|
||||||
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded " +
|
|
||||||
"bg-red-500/15 text-red-600 line-through decoration-red-400";
|
|
||||||
var FILTER_MODIFIER_PILL_CLASS =
|
|
||||||
"inline-flex items-center px-2 py-0.5 text-sm rounded " +
|
|
||||||
"bg-amber-500/15 text-amber-600 cursor-pointer";
|
|
||||||
var FILTER_OPTION_ROW_CLASS =
|
|
||||||
"flex items-center justify-between px-2 py-1 rounded text-sm " +
|
|
||||||
"hover:bg-neutral-secondary-strong cursor-pointer";
|
|
||||||
var FILTER_OPTION_LABEL_CLASS = "truncate text-body";
|
|
||||||
var FILTER_OPTION_BUTTONS_CLASS = "flex gap-1 ml-2 shrink-0";
|
|
||||||
var FILTER_ACTION_BUTTON_CLASS =
|
|
||||||
"w-5 h-5 flex items-center justify-center text-xs font-bold rounded text-body " +
|
|
||||||
"border border-brand " +
|
|
||||||
"hover:bg-brand hover:text-white hover:border-brand-strong";
|
|
||||||
|
|
||||||
var DEBOUNCE_MS = 500;
|
var DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
function initAll() {
|
function initAll() {
|
||||||
@@ -103,60 +76,38 @@
|
|||||||
showPanel();
|
showPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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 tpl = container.querySelector('template[data-ss-tpl="' + name + '"]');
|
||||||
|
return tpl ? tpl.content.firstElementChild.cloneNode(true) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLabel(node, label) {
|
||||||
|
var slot = node.querySelector("[data-ss-label]");
|
||||||
|
if (slot) slot.textContent = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyData(node, data) {
|
||||||
|
data = data || {};
|
||||||
|
Object.keys(data).forEach(function (key) {
|
||||||
|
node.setAttribute("data-" + key, data[key]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build an option row by cloning the "row" template (the same prototype the
|
||||||
|
// server renders, so fetched and pre-rendered rows are identical).
|
||||||
function buildRow(option) {
|
function buildRow(option) {
|
||||||
if (isFilter) return buildFilterOptionRow(option);
|
var row = cloneTemplate("row");
|
||||||
var row = document.createElement("div");
|
if (!row) return document.createComment("ss-row");
|
||||||
row.setAttribute("data-ss-option", "");
|
|
||||||
row.setAttribute("data-value", option.value);
|
row.setAttribute("data-value", option.value);
|
||||||
row.setAttribute("data-label", option.label);
|
row.setAttribute("data-label", option.label);
|
||||||
row.className = OPTION_ROW_CLASS;
|
applyData(row, option.data);
|
||||||
var data = option.data || {};
|
setLabel(row, option.label);
|
||||||
Object.keys(data).forEach(function (key) {
|
|
||||||
row.setAttribute("data-" + key, data[key]);
|
|
||||||
});
|
|
||||||
row.textContent = option.label;
|
|
||||||
row._ssOption = option;
|
row._ssOption = option;
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Filter-mode value row: label + include/exclude buttons (mirrors the
|
|
||||||
// Python _filter_option_row so fetched and server-rendered rows match). ──
|
|
||||||
function buildFilterOptionRow(option) {
|
|
||||||
var row = document.createElement("div");
|
|
||||||
row.setAttribute("data-ss-option", "");
|
|
||||||
row.setAttribute("data-value", option.value);
|
|
||||||
row.setAttribute("data-label", option.label);
|
|
||||||
row.className = FILTER_OPTION_ROW_CLASS;
|
|
||||||
var data = option.data || {};
|
|
||||||
Object.keys(data).forEach(function (key) {
|
|
||||||
row.setAttribute("data-" + key, data[key]);
|
|
||||||
});
|
|
||||||
|
|
||||||
var labelSpan = document.createElement("span");
|
|
||||||
labelSpan.className = FILTER_OPTION_LABEL_CLASS;
|
|
||||||
labelSpan.textContent = option.label;
|
|
||||||
|
|
||||||
var buttons = document.createElement("span");
|
|
||||||
buttons.className = FILTER_OPTION_BUTTONS_CLASS;
|
|
||||||
buttons.appendChild(buildActionButton("include", "+", "Include"));
|
|
||||||
buttons.appendChild(buildActionButton("exclude", "−", "Exclude"));
|
|
||||||
|
|
||||||
row.appendChild(labelSpan);
|
|
||||||
row.appendChild(buttons);
|
|
||||||
row._ssOption = option;
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildActionButton(action, symbol, title) {
|
|
||||||
var button = document.createElement("button");
|
|
||||||
button.type = "button";
|
|
||||||
button.setAttribute("data-ss-action", action);
|
|
||||||
button.className = FILTER_ACTION_BUTTON_CLASS;
|
|
||||||
button.title = title;
|
|
||||||
button.textContent = symbol;
|
|
||||||
return button;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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. ──
|
||||||
function filterRows(query) {
|
function filterRows(query) {
|
||||||
@@ -309,32 +260,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildFilterValuePill(option, kind) {
|
function buildFilterValuePill(option, kind) {
|
||||||
var pill = document.createElement("span");
|
var pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude");
|
||||||
pill.className =
|
|
||||||
kind === "include" ? FILTER_INCLUDE_PILL_CLASS : FILTER_EXCLUDE_PILL_CLASS;
|
|
||||||
pill.setAttribute("data-pill", "");
|
|
||||||
pill.setAttribute("data-value", option.value);
|
pill.setAttribute("data-value", option.value);
|
||||||
pill.setAttribute("data-label", option.label);
|
pill.setAttribute("data-label", option.label);
|
||||||
pill.setAttribute("data-ss-type", kind);
|
applyData(pill, option.data);
|
||||||
var data = option.data || {};
|
setLabel(pill, option.label);
|
||||||
Object.keys(data).forEach(function (key) {
|
|
||||||
pill.setAttribute("data-" + key, data[key]);
|
|
||||||
});
|
|
||||||
var symbol = kind === "include" ? "✓" : "✗";
|
|
||||||
pill.appendChild(document.createTextNode(symbol + " " + option.label));
|
|
||||||
pill.appendChild(buildRemoveButton());
|
|
||||||
return pill;
|
return pill;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the lone modifier pill, clearing all value pills (mutual exclusivity).
|
// Set the lone modifier pill, clearing all value pills (mutual exclusivity).
|
||||||
function setModifier(modifierValue, label) {
|
function setModifier(modifierValue, label) {
|
||||||
pills.innerHTML = "";
|
pills.innerHTML = "";
|
||||||
var pill = document.createElement("span");
|
var pill = cloneTemplate("pill-modifier");
|
||||||
pill.className = FILTER_MODIFIER_PILL_CLASS;
|
|
||||||
pill.setAttribute("data-pill", "");
|
|
||||||
pill.setAttribute("data-ss-modifier", modifierValue);
|
pill.setAttribute("data-ss-modifier", modifierValue);
|
||||||
pill.appendChild(document.createTextNode(label));
|
setLabel(pill, label);
|
||||||
pill.appendChild(buildRemoveButton());
|
|
||||||
pills.appendChild(pill);
|
pills.appendChild(pill);
|
||||||
container.setAttribute("data-modifier", modifierValue);
|
container.setAttribute("data-modifier", modifierValue);
|
||||||
hidePanel();
|
hidePanel();
|
||||||
@@ -347,16 +286,6 @@
|
|||||||
container.removeAttribute("data-modifier");
|
container.removeAttribute("data-modifier");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRemoveButton() {
|
|
||||||
var remove = document.createElement("button");
|
|
||||||
remove.type = "button";
|
|
||||||
remove.setAttribute("data-pill-remove", "");
|
|
||||||
remove.className = PILL_REMOVE_CLASS;
|
|
||||||
remove.setAttribute("aria-label", "Remove");
|
|
||||||
remove.textContent = "×";
|
|
||||||
return remove;
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionFromRow(row) {
|
function optionFromRow(row) {
|
||||||
if (row._ssOption) return row._ssOption;
|
if (row._ssOption) return row._ssOption;
|
||||||
var data = {};
|
var data = {};
|
||||||
@@ -391,27 +320,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addPill(option) {
|
function addPill(option) {
|
||||||
pills.appendChild(buildPill(option));
|
var pill = buildPill(option);
|
||||||
|
if (pill) pills.appendChild(pill);
|
||||||
pills.appendChild(buildHidden(option.value));
|
pills.appendChild(buildHidden(option.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPill(option) {
|
function buildPill(option) {
|
||||||
var pill = document.createElement("span");
|
var pill = cloneTemplate("pill");
|
||||||
pill.className = PILL_CLASS;
|
if (!pill) return null;
|
||||||
pill.setAttribute("data-pill", "");
|
|
||||||
pill.setAttribute("data-value", option.value);
|
pill.setAttribute("data-value", option.value);
|
||||||
var data = option.data || {};
|
applyData(pill, option.data);
|
||||||
Object.keys(data).forEach(function (key) {
|
setLabel(pill, option.label);
|
||||||
pill.setAttribute("data-" + key, data[key]);
|
|
||||||
});
|
|
||||||
pill.appendChild(document.createTextNode(option.label));
|
|
||||||
var remove = document.createElement("button");
|
|
||||||
remove.type = "button";
|
|
||||||
remove.setAttribute("data-pill-remove", "");
|
|
||||||
remove.className = PILL_REMOVE_CLASS;
|
|
||||||
remove.setAttribute("aria-label", "Remove");
|
|
||||||
remove.textContent = "×";
|
|
||||||
pill.appendChild(remove);
|
|
||||||
return pill;
|
return pill;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,19 @@ class SearchSelectComponentTest(unittest.TestCase):
|
|||||||
html = SearchSelect(
|
html = SearchSelect(
|
||||||
name="t", options=[("1", "One")], search_url="/api/games/search"
|
name="t", options=[("1", "One")], search_url="/api/games/search"
|
||||||
)
|
)
|
||||||
self.assertNotIn('data-ss-option=""', html)
|
# No pre-rendered rows in the live panel; the row prototype lives only in
|
||||||
|
# the cloneable <template>.
|
||||||
|
panel = html.split("data-ss-tpl")[0]
|
||||||
|
self.assertNotIn('data-ss-option=""', panel)
|
||||||
|
self.assertIn('data-ss-tpl="row"', html)
|
||||||
|
|
||||||
|
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
|
||||||
|
# only fills text — classes/structure stay server-side.
|
||||||
|
html = SearchSelect(name="t", search_url="/api/games/search", multi_select=True)
|
||||||
|
self.assertIn('data-ss-tpl="row"', html)
|
||||||
|
self.assertIn('data-ss-tpl="pill"', html)
|
||||||
|
self.assertIn("data-ss-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
|
||||||
@@ -150,10 +162,14 @@ 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 ✓/✗
|
||||||
|
# symbol is a sibling text node.
|
||||||
self.assertIn('data-ss-type="include"', html)
|
self.assertIn('data-ss-type="include"', html)
|
||||||
self.assertIn("✓ Steam", html)
|
self.assertIn("✓", html)
|
||||||
|
self.assertIn(">Steam</span>", html)
|
||||||
self.assertIn('data-ss-type="exclude"', html)
|
self.assertIn('data-ss-type="exclude"', html)
|
||||||
self.assertIn("✗ GOG", html)
|
self.assertIn("✗", 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):
|
||||||
@@ -172,9 +188,12 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
modifier_options=self.MODIFIERS,
|
modifier_options=self.MODIFIERS,
|
||||||
)
|
)
|
||||||
# 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
|
||||||
|
# legitimately contain data-ss-type.)
|
||||||
|
pills_region = html.split("data-ss-tpl")[0]
|
||||||
self.assertIn('data-ss-modifier="IS_NULL"', html)
|
self.assertIn('data-ss-modifier="IS_NULL"', html)
|
||||||
self.assertIn("(None)", html)
|
self.assertIn("(None)", html)
|
||||||
self.assertNotIn('data-ss-type="include"', html)
|
self.assertNotIn('data-ss-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):
|
||||||
@@ -184,7 +203,11 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
prefetch=20,
|
prefetch=20,
|
||||||
modifier_options=self.MODIFIERS,
|
modifier_options=self.MODIFIERS,
|
||||||
)
|
)
|
||||||
self.assertNotIn('data-ss-option=""', html) # value rows fetched by JS
|
# No value rows in the live panel (they're fetched); the row prototype
|
||||||
|
# lives only in a <template>.
|
||||||
|
panel = html.split("data-ss-tpl")[0]
|
||||||
|
self.assertNotIn('data-ss-option=""', panel)
|
||||||
|
self.assertIn('data-ss-tpl="row"', html)
|
||||||
self.assertIn('data-ss-modifier-option="NOT_NULL"', html) # still pinned
|
self.assertIn('data-ss-modifier-option="NOT_NULL"', html) # still pinned
|
||||||
self.assertIn('data-prefetch="20"', html)
|
self.assertIn('data-prefetch="20"', html)
|
||||||
|
|
||||||
@@ -195,7 +218,7 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
search_url="/api/games/search",
|
search_url="/api/games/search",
|
||||||
excluded=[{"value": 4172, "label": "Obscure Game", "data": {}}],
|
excluded=[{"value": 4172, "label": "Obscure Game", "data": {}}],
|
||||||
)
|
)
|
||||||
self.assertIn("✗ Obscure Game", html)
|
self.assertIn(">Obscure Game</span>", html)
|
||||||
self.assertIn('data-value="4172"', html)
|
self.assertIn('data-value="4172"', html)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user