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:
@@ -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`.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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", ""),
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user