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:
Claude
2026-06-08 10:26:46 +00:00
committed by Lukáš Kucharczyk
parent 6bc7da9f2f
commit f210f818a9
4 changed files with 139 additions and 141 deletions
+29 -6
View File
@@ -105,7 +105,19 @@ class SearchSelectComponentTest(unittest.TestCase):
html = SearchSelect(
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):
# The shared shell assembles the three regions in a fixed order; option
@@ -150,10 +162,14 @@ class FilterSelectComponentTest(unittest.TestCase):
included=[("1", "Steam")],
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(" Steam", html)
self.assertIn("", html)
self.assertIn(">Steam</span>", 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
def test_modifier_options_render_pinned_rows(self):
@@ -172,9 +188,12 @@ class FilterSelectComponentTest(unittest.TestCase):
modifier_options=self.MODIFIERS,
)
# 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("(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
def test_search_url_omits_value_rows_but_keeps_modifiers(self):
@@ -184,7 +203,11 @@ class FilterSelectComponentTest(unittest.TestCase):
prefetch=20,
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-prefetch="20"', html)
@@ -195,7 +218,7 @@ class FilterSelectComponentTest(unittest.TestCase):
search_url="/api/games/search",
excluded=[{"value": 4172, "label": "Obscure Game", "data": {}}],
)
self.assertIn("Obscure Game", html)
self.assertIn(">Obscure Game</span>", html)
self.assertIn('data-value="4172"', html)