Use element primitives instead of inline Component; add Template primitive

Add a Template() primitive for the standard <template> tag and export it. Replace
inline Component(tag_name="div"/"span"/"input"/"template") in search_select.py
and Pill with Div/Span/Input/Template; drop the private _template helper in favour
of Template at the call sites. Bare custom-styled <button>s stay on Component
(the opinionated Button() would inject unwanted classes). Document the
prefer-primitives convention in CLAUDE.md.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
This commit is contained in:
Claude
2026-06-08 14:49:48 +00:00
committed by Lukáš Kucharczyk
parent 15bb3ce1b9
commit 79fa4bef44
5 changed files with 62 additions and 56 deletions
+1
View File
@@ -161,6 +161,7 @@ Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJAN
- **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch. - **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch.
- **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`. - **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`.
- **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped. - **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped.
- **Prefer the named element primitives over raw `Component(tag_name=…)`** — use `Div()`, `Span()`, `Input()`, `Label()`, `Template()`, etc. from `common.components` instead of `Component(tag_name="div")`. Reach for `Component` directly only when no primitive fits (e.g. a bare, custom-styled `<button>` where the opinionated `Button()` would inject unwanted classes). Add a new primitive rather than repeating an inline `Component` for a standard tag.
- **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`. - **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`.
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete. - **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`. - **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
+2
View File
@@ -36,6 +36,7 @@ from common.components.primitives import (
TableHeader, TableHeader,
TableRow, TableRow,
TableTd, TableTd,
Template,
YearPicker, YearPicker,
paginated_table_content, paginated_table_content,
) )
@@ -95,6 +96,7 @@ __all__ = [
"TableHeader", "TableHeader",
"TableRow", "TableRow",
"TableTd", "TableTd",
"Template",
"YearPicker", "YearPicker",
"paginated_table_content", "paginated_table_content",
"GameLink", "GameLink",
+12 -6
View File
@@ -369,6 +369,16 @@ def Label(
return Component(tag_name="label", attributes=attributes, children=children) return Component(tag_name="label", attributes=attributes, children=children)
def Template(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""An inert ``<template>`` whose contents are not rendered until cloned by JS."""
attributes = attributes or []
children = children or []
return Component(tag_name="template", attributes=attributes, children=children)
# Inline Tailwind utilities for Pill (mirrors the .sf-tag / .sf-remove rules in # Inline Tailwind utilities for Pill (mirrors the .sf-tag / .sf-remove rules in
# input.css, written inline so styling stays encapsulated in the component). The # input.css, written inline so styling stays encapsulated in the component). The
# JS that builds pills client-side (search_select.js) MUST emit these exact class # JS that builds pills client-side (search_select.js) MUST emit these exact class
@@ -407,11 +417,7 @@ def Pill(
pill_attrs.extend(attributes) pill_attrs.extend(attributes)
label_child: HTMLTag = ( label_child: HTMLTag = (
Component( Span(attributes=[("data-search-select-label", "")], children=[label])
tag_name="span",
attributes=[("data-search-select-label", "")],
children=[label],
)
if label_slot if label_slot
else label else label
) )
@@ -430,7 +436,7 @@ def Pill(
) )
) )
return Component(tag_name="span", attributes=pill_attrs, children=children) return Span(attributes=pill_attrs, children=children)
def CsrfInput(request) -> SafeText: def CsrfInput(request) -> SafeText:
+45 -48
View File
@@ -24,7 +24,7 @@ from typing import TypedDict
from django.utils.safestring import SafeText from django.utils.safestring import SafeText
from common.components.core import Component, HTMLAttribute from common.components.core import Component, HTMLAttribute
from common.components.primitives import Pill from common.components.primitives import Div, Input, Pill, Span, Template
class SearchSelectOption(TypedDict): class SearchSelectOption(TypedDict):
@@ -114,10 +114,7 @@ def _data_attributes(data: dict[str, str]) -> list[HTMLAttribute]:
def _hidden_input(name: str, value) -> SafeText: def _hidden_input(name: str, value) -> SafeText:
return Component( return Input(type="hidden", attributes=[("name", name), ("value", str(value))])
tag_name="input",
attributes=[("type", "hidden"), ("name", name), ("value", str(value))],
)
def _label_slot(text: str, *, extra_class: str = "") -> SafeText: def _label_slot(text: str, *, extra_class: str = "") -> SafeText:
@@ -127,18 +124,7 @@ def _label_slot(text: str, *, extra_class: str = "") -> SafeText:
attributes: list[HTMLAttribute] = [("data-search-select-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 Span(attributes=attributes, children=[text])
def _template(name: str, node: SafeText) -> SafeText:
"""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
free of any markup or class strings."""
return Component(
tag_name="template",
attributes=[("data-search-select-template", name)],
children=[node],
)
# A placeholder option for rendering template prototypes (JS overwrites it). # A placeholder option for rendering template prototypes (JS overwrites it).
@@ -146,8 +132,7 @@ _BLANK_OPTION: SearchSelectOption = {"value": "", "label": "", "data": {}}
def _option_row(option: SearchSelectOption) -> SafeText: def _option_row(option: SearchSelectOption) -> SafeText:
return Component( return Div(
tag_name="div",
attributes=[ attributes=[
("data-search-select-option", ""), ("data-search-select-option", ""),
("data-value", str(option["value"])), ("data-value", str(option["value"])),
@@ -181,10 +166,9 @@ def _combobox_shell(
dynamically-added rows/pills). The shell knows nothing about how individual dynamically-added rows/pills). The shell knows nothing about how individual
rows or pills look. rows or pills look.
""" """
search = Component(tag_name="input", attributes=search_attributes) search = Input(attributes=search_attributes)
no_results = Component( no_results = Div(
tag_name="div",
attributes=[ attributes=[
("data-search-select-no-results", ""), ("data-search-select-no-results", ""),
("class", _NO_RESULTS_CLASS), ("class", _NO_RESULTS_CLASS),
@@ -192,8 +176,7 @@ def _combobox_shell(
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 = Div(
tag_name="div",
attributes=[ attributes=[
("data-search-select-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"),
@@ -202,8 +185,7 @@ def _combobox_shell(
children=[*options_children, no_results], children=[*options_children, no_results],
) )
return Component( return Div(
tag_name="div",
attributes=container_attributes, attributes=container_attributes,
children=[pills, search, options_panel, *(templates or [])], children=[pills, search, options_panel, *(templates or [])],
) )
@@ -253,8 +235,7 @@ def SearchSelect(
pills_children.append(_hidden_input(name, option["value"])) pills_children.append(_hidden_input(name, option["value"]))
search_value = option["label"] search_value = option["label"]
pills = Component( pills = Div(
tag_name="div",
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)], attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
children=pills_children, children=pills_children,
) )
@@ -262,7 +243,6 @@ def SearchSelect(
# ── 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-search-select-search", ""), ("data-search-select-search", ""),
("type", "text"),
("placeholder", placeholder), ("placeholder", placeholder),
("autocomplete", "off"), ("autocomplete", "off"),
("class", _SEARCH_CLASS), ("class", _SEARCH_CLASS),
@@ -279,10 +259,18 @@ def SearchSelect(
# multi-select adds chosen items. ── # multi-select adds chosen items. ──
templates: list[SafeText] = [] templates: list[SafeText] = []
if search_url: if search_url:
templates.append(_template("row", _option_row(_BLANK_OPTION))) templates.append(
Template(
attributes=[("data-search-select-template", "row")],
children=[_option_row(_BLANK_OPTION)],
)
)
if multi_select: if multi_select:
templates.append( templates.append(
_template("pill", Pill("", value="", removable=True, label_slot=True)) Template(
attributes=[("data-search-select-template", "pill")],
children=[Pill("", value="", removable=True, label_slot=True)],
)
) )
container_attributes: list[HTMLAttribute] = [ container_attributes: list[HTMLAttribute] = [
@@ -330,8 +318,7 @@ def _filter_value_pill(option: SearchSelectOption, kind: str) -> SafeText:
css = ( css = (
_FILTER_INCLUDE_PILL_CLASS if kind == "include" else _FILTER_EXCLUDE_PILL_CLASS _FILTER_INCLUDE_PILL_CLASS if kind == "include" else _FILTER_EXCLUDE_PILL_CLASS
) )
return Component( return Span(
tag_name="span",
attributes=[ attributes=[
("class", css), ("class", css),
("data-pill", ""), ("data-pill", ""),
@@ -346,8 +333,7 @@ def _filter_value_pill(option: SearchSelectOption, kind: str) -> SafeText:
def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText: def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText:
"""The lone, sticky modifier pill (e.g. "(Any)"/"(None)").""" """The lone, sticky modifier pill (e.g. "(Any)"/"(None)")."""
return Component( return Span(
tag_name="span",
attributes=[ attributes=[
("class", _FILTER_MODIFIER_PILL_CLASS), ("class", _FILTER_MODIFIER_PILL_CLASS),
("data-pill", ""), ("data-pill", ""),
@@ -372,8 +358,7 @@ def _filter_action_button(action: str, symbol: str, title: str) -> SafeText:
def _filter_option_row(value: str | int, label: str) -> SafeText: def _filter_option_row(value: str | int, label: str) -> SafeText:
"""A value row with include (+) and exclude () buttons.""" """A value row with include (+) and exclude () buttons."""
return Component( return Div(
tag_name="div",
attributes=[ attributes=[
("data-search-select-option", ""), ("data-search-select-option", ""),
("data-value", str(value)), ("data-value", str(value)),
@@ -382,8 +367,7 @@ def _filter_option_row(value: str | int, label: str) -> SafeText:
], ],
children=[ children=[
_label_slot(label, extra_class=_FILTER_OPTION_LABEL_CLASS), _label_slot(label, extra_class=_FILTER_OPTION_LABEL_CLASS),
Component( Span(
tag_name="span",
attributes=[("class", _FILTER_OPTION_BUTTONS_CLASS)], attributes=[("class", _FILTER_OPTION_BUTTONS_CLASS)],
children=[ children=[
_filter_action_button("include", "+", "Include"), _filter_action_button("include", "+", "Include"),
@@ -397,8 +381,7 @@ 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-search-select-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 Div(
tag_name="div",
attributes=[ attributes=[
("data-search-select-modifier-option", modifier_value), ("data-search-select-modifier-option", modifier_value),
("data-label", label), ("data-label", label),
@@ -457,8 +440,7 @@ def FilterSelect(
for option in excluded: for option in excluded:
pills_children.append(_filter_value_pill(option, "exclude")) pills_children.append(_filter_value_pill(option, "exclude"))
pills = Component( pills = Div(
tag_name="div",
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)], attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
children=pills_children, children=pills_children,
) )
@@ -466,7 +448,6 @@ def FilterSelect(
# ── 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-search-select-search", ""), ("data-search-select-search", ""),
("type", "text"),
("placeholder", placeholder), ("placeholder", placeholder),
("autocomplete", "off"), ("autocomplete", "off"),
("class", _SEARCH_CLASS), ("class", _SEARCH_CLASS),
@@ -486,13 +467,29 @@ def FilterSelect(
# ── Templates the JS clones: include/exclude pills (added on click), the # ── Templates the JS clones: include/exclude pills (added on click), the
# modifier pill (when modifiers exist), and a value row (when fetched). ── # modifier pill (when modifiers exist), and a value row (when fetched). ──
templates: list[SafeText] = [ templates: list[SafeText] = [
_template("pill-include", _filter_value_pill(_BLANK_OPTION, "include")), Template(
_template("pill-exclude", _filter_value_pill(_BLANK_OPTION, "exclude")), attributes=[("data-search-select-template", "pill-include")],
children=[_filter_value_pill(_BLANK_OPTION, "include")],
),
Template(
attributes=[("data-search-select-template", "pill-exclude")],
children=[_filter_value_pill(_BLANK_OPTION, "exclude")],
),
] ]
if modifier_options: if modifier_options:
templates.append(_template("pill-modifier", _filter_modifier_pill("", ""))) templates.append(
Template(
attributes=[("data-search-select-template", "pill-modifier")],
children=[_filter_modifier_pill("", "")],
)
)
if search_url: if search_url:
templates.append(_template("row", _filter_option_row("", ""))) templates.append(
Template(
attributes=[("data-search-select-template", "row")],
children=[_filter_option_row("", "")],
)
)
container_attributes: list[HTMLAttribute] = [ container_attributes: list[HTMLAttribute] = [
("data-search-select", ""), ("data-search-select", ""),
+2 -2
View File
@@ -71,7 +71,7 @@ class SearchSelectComponentTest(unittest.TestCase):
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}], selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
) )
self.assertIn("data-pill", html) self.assertIn("data-pill", html)
self.assertIn('<input type="hidden" name="games" value="7">', html) self.assertIn('<input name="games" value="7" type="hidden">', html)
self.assertIn('data-platform="2"', html) self.assertIn('data-platform="2"', html)
# exactly one submitted value (the hidden input) — the search box has no # exactly one submitted value (the hidden input) — the search box has no
# name. The leading space avoids matching the container's data-name. # name. The leading space avoids matching the container's data-name.
@@ -86,7 +86,7 @@ class SearchSelectComponentTest(unittest.TestCase):
self.assertNotIn("data-pill", html) self.assertNotIn("data-pill", html)
self.assertIn('value="Game A"', html) self.assertIn('value="Game A"', html)
# the value is still submitted via a lone hidden input # the value is still submitted via a lone hidden input
self.assertIn('<input type="hidden" name="games" value="7">', html) self.assertIn('<input name="games" value="7" type="hidden">', html)
self.assertEqual(html.count(' name="games"'), 1) self.assertEqual(html.count(' name="games"'), 1)
def test_search_box_has_no_name(self): def test_search_box_has_no_name(self):