diff --git a/common/components/core.py b/common/components/core.py index 8088355..939e090 100644 --- a/common/components/core.py +++ b/common/components/core.py @@ -14,6 +14,7 @@ Nodes are *lazy*: they hold structure and render to HTML only when asked """ import hashlib +from collections.abc import Sequence from functools import lru_cache from django.utils.html import escape @@ -126,6 +127,31 @@ class Node: return mark_safe(self._render()) +# A renderable child is a node or a string (plain strings are escaped, SafeText +# and nodes pass through). ``Children`` is the type for a builder's ``children`` +# parameter: a sequence of child nodes/strings, a bare string, or nothing. The +# sequence is a covariant ``Sequence`` so ``list[Element]`` / ``list[Node]`` are +# accepted (a plain ``list[str]`` would be invariant and reject them). A single +# bare ``Node`` is accepted only by ``Element`` itself (which wraps it); the +# higher-level builders take ``Children``. +Child = Node | str +Children = Sequence[Child] | str | None + + +def as_children(children: "Children | Node") -> list[Child]: + """Normalise a builder's ``children`` argument to a flat list. + + Accepts ``None`` (→ empty), a single node/string (→ one-element list), or a + sequence of them. Lets builders drop the ``children if isinstance(children, + list) else [children]`` dance and get a properly typed ``list[Child]``. + """ + if children is None: + return [] + if isinstance(children, (str, Node)): + return [children] + return list(children) + + def _child_key(child: object) -> tuple[str, bool]: """Normalise a child to a ``(text, is_safe)`` pair. @@ -176,7 +202,7 @@ class Element(Node): self, tag_name: str, attributes: list[HTMLAttribute] | None = None, - children: "list | Node | str | None" = None, + children: "Children | Node" = None, ) -> None: if not tag_name: raise ValueError("tag_name is required.") diff --git a/common/components/domain.py b/common/components/domain.py index b5b0bd3..b77a9a2 100644 --- a/common/components/domain.py +++ b/common/components/domain.py @@ -6,7 +6,7 @@ from django.template.defaultfilters import floatformat from django.urls import reverse from django.utils.safestring import SafeText, mark_safe -from common.components.core import HTMLTag, Node +from common.components.core import Children, Node, as_children from common.components.primitives import ( A, Div, @@ -21,13 +21,12 @@ from games.models import Game, Purchase, Session def GameLink( game_id: int, name: str = "", - children: list[HTMLTag] | HTMLTag | None = None, + children: Children = None, ) -> Node: """Link to a game's detail page. Uses children (slot) if provided, otherwise name.""" from django.urls import reverse - children = children or [] - display = children if children else [name] + display = as_children(children) or [name] link = reverse("games:view_game", args=[game_id]) return Span( @@ -38,7 +37,7 @@ def GameLink( attributes=[ ("class", "underline decoration-slate-500 sm:decoration-2"), ], - children=display if isinstance(display, list) else [display], + children=display, ), ], ) @@ -54,7 +53,7 @@ _STATUS_COLORS = { def GameStatus( - children: list[HTMLTag] | HTMLTag | None = None, + children: Children = None, status: str = "u", display: str = "", class_: str = "", @@ -76,12 +75,12 @@ def GameStatus( return Span( attributes=[("class", outer_class)], - children=[dot] + (children if isinstance(children, list) else [children]), + children=[dot] + as_children(children), ) def PriceConverted( - children: list[HTMLTag] | HTMLTag | None = None, + children: Children = None, ) -> Node: """Wrap content in a span that indicates the price was converted.""" children = children or [] @@ -90,7 +89,7 @@ def PriceConverted( ("title", "Price is a result of conversion and rounding."), ("class", "decoration-dotted underline"), ], - children=children if isinstance(children, list) else [children], + children=as_children(children), ) diff --git a/common/components/primitives.py b/common/components/primitives.py index 90de14c..99a3669 100644 --- a/common/components/primitives.py +++ b/common/components/primitives.py @@ -14,13 +14,14 @@ from django.utils.html import conditional_escape from django.utils.safestring import SafeText, mark_safe from common.components.core import ( + Children, Element, Fragment, HTMLAttribute, - HTMLTag, Media, Node, Safe, + as_children, randomid, ) from common.icons import get_icon @@ -53,7 +54,7 @@ def _html_element(tag_name: str): def element( attributes: list[HTMLAttribute] | None = None, - children: "list[HTMLTag] | HTMLTag | Node | None" = None, + children: Children = None, ) -> Element: return Element(tag_name, attributes, children) @@ -113,7 +114,7 @@ def _popover_html( children=[popover_content], ), Div(attributes=[("data-popper-arrow", "")]), - mark_safe( # nosec — intentional HTML comment for Tailwind JIT + Safe( # nosec — intentional HTML comment for Tailwind JIT "" ), @@ -130,7 +131,7 @@ def Popover( popover_content: str, wrapped_content: str = "", wrapped_classes: str = "", - children: list[HTMLTag] | None = None, + children: Children = None, attributes: list[HTMLAttribute] | None = None, id: str = "", ) -> Node: @@ -184,7 +185,7 @@ def PopoverTruncated( def A( attributes: list[HTMLAttribute] | None = None, - children: list[HTMLTag] | HTMLTag | None = None, + children: Children = None, url_name: str | None = None, href: str | None = None, ) -> Element: @@ -212,7 +213,7 @@ def A( def Button( attributes: list[HTMLAttribute] | None = None, - children: list[HTMLTag] | HTMLTag | None = None, + children: Children = None, size: str = "base", icon: bool = False, color: str = "blue", @@ -373,7 +374,7 @@ def ButtonGroup(buttons: list[dict] | None = None) -> Element: def Input( type: str = "text", attributes: list[HTMLAttribute] | None = None, - children: list[HTMLTag] | HTMLTag | None = None, + children: Children = None, ) -> Element: attributes = attributes or [] children = children or [] @@ -481,12 +482,12 @@ def Pill( pill_attrs.append(("data-value", str(value))) pill_attrs.extend(attributes) - label_child: HTMLTag = ( + label_child: "Node | str" = ( Span(attributes=[("data-search-select-label", "")], children=[label]) if label_slot else label ) - children: list[HTMLTag] = [label_child] + children: list["Node | str"] = [label_child] if removable: children.append( Element( @@ -634,7 +635,7 @@ def AddForm( is applied to the main Submit button (the session form passes "" to match its original markup). """ - field_markup = fields if fields is not None else mark_safe(form.as_div()) + field_markup = fields if fields is not None else Safe(form.as_div()) submit_attrs = [("class", submit_class)] if submit_class else [] inner_form = Element( @@ -682,7 +683,7 @@ def SearchField( Div( attributes=[("class", "relative")], children=[ - mark_safe( + Safe( '