diff --git a/common/components/core.py b/common/components/core.py index 939e090..25c119f 100644 --- a/common/components/core.py +++ b/common/components/core.py @@ -24,6 +24,13 @@ from django.utils.safestring import SafeText, mark_safe HTMLAttribute = tuple[str, str | int | bool] +# Type for a builder's ``attributes`` parameter. Covariant ``Sequence`` so a +# caller's ``list[tuple[str, str]]`` is accepted (a plain ``list[HTMLAttribute]`` +# would be invariant and reject it). Locals that get ``.append()``-ed should +# stay a concrete ``list[HTMLAttribute]``. +Attributes = Sequence[HTMLAttribute] + + HTMLTag = str @@ -152,6 +159,16 @@ def as_children(children: "Children | Node") -> list[Child]: return list(children) +def as_attributes(attributes: "Attributes | None") -> list[HTMLAttribute]: + """Normalise an ``attributes`` argument to a mutable ``list[HTMLAttribute]``. + + Builders take a covariant ``Attributes`` (so callers can pass a + ``list[tuple[str, str]]``) but often append to or concatenate the value; + this turns it into a concrete list they can mutate. + """ + return list(attributes) if attributes else [] + + def _child_key(child: object) -> tuple[str, bool]: """Normalise a child to a ``(text, is_safe)`` pair. @@ -201,7 +218,7 @@ class Element(Node): def __init__( self, tag_name: str, - attributes: list[HTMLAttribute] | None = None, + attributes: Attributes | None = None, children: "Children | Node" = None, ) -> None: if not tag_name: diff --git a/common/components/primitives.py b/common/components/primitives.py index 99a3669..99c7fdc 100644 --- a/common/components/primitives.py +++ b/common/components/primitives.py @@ -14,6 +14,7 @@ from django.utils.html import conditional_escape from django.utils.safestring import SafeText, mark_safe from common.components.core import ( + Attributes, Children, Element, Fragment, @@ -21,6 +22,7 @@ from common.components.core import ( Media, Node, Safe, + as_attributes, as_children, randomid, ) @@ -53,7 +55,7 @@ def _html_element(tag_name: str): """Build a generic element builder for ``tag_name`` (the whitelist factory).""" def element( - attributes: list[HTMLAttribute] | None = None, + attributes: Attributes | None = None, children: Children = None, ) -> Element: return Element(tag_name, attributes, children) @@ -132,7 +134,7 @@ def Popover( wrapped_content: str = "", wrapped_classes: str = "", children: Children = None, - attributes: list[HTMLAttribute] | None = None, + attributes: Attributes | None = None, id: str = "", ) -> Node: children = children or [] @@ -184,7 +186,7 @@ def PopoverTruncated( def A( - attributes: list[HTMLAttribute] | None = None, + attributes: Attributes | None = None, children: Children = None, url_name: str | None = None, href: str | None = None, @@ -196,7 +198,7 @@ def A( - url_name: URL pattern name, resolved via reverse() - href: Literal path string passed through as-is """ - attributes = attributes or [] + attributes = as_attributes(attributes) children = children or [] if url_name is not None and href is not None: raise ValueError("Provide exactly one of 'url_name' or 'href', not both.") @@ -212,7 +214,7 @@ def A( def Button( - attributes: list[HTMLAttribute] | None = None, + attributes: Attributes | None = None, children: Children = None, size: str = "base", icon: bool = False, @@ -225,7 +227,7 @@ def Button( onclick: str = "", name: str = "", ) -> Element: - attributes = attributes or [] + attributes = as_attributes(attributes) children = children or [] # Separate custom class from other generic attributes @@ -373,10 +375,10 @@ def ButtonGroup(buttons: list[dict] | None = None) -> Element: def Input( type: str = "text", - attributes: list[HTMLAttribute] | None = None, + attributes: Attributes | None = None, children: Children = None, ) -> Element: - attributes = attributes or [] + attributes = as_attributes(attributes) children = children or [] return Element("input", attributes=attributes + [("type", type)], children=children) @@ -386,10 +388,10 @@ def Checkbox( label: str | None = None, checked: bool = False, value: str = "1", - attributes: list[HTMLAttribute] | None = None, + attributes: Attributes | None = None, ) -> Node: """A filter-agnostic Checkbox component.""" - attributes = attributes or [] + attributes = as_attributes(attributes) input_attrs = [ ("name", name), ("value", value), @@ -418,10 +420,10 @@ def Radio( label: str | None = None, checked: bool = False, value: str = "", - attributes: list[HTMLAttribute] | None = None, + attributes: Attributes | None = None, ) -> Node: """A filter-agnostic Radio component.""" - attributes = attributes or [] + attributes = as_attributes(attributes) input_attrs = [ ("name", name), ("value", value), @@ -463,7 +465,7 @@ def Pill( removable: bool = False, extra_class: str = "", label_slot: bool = False, - attributes: list[HTMLAttribute] | None = None, + attributes: Attributes | None = None, ) -> Node: """A small label pill, optionally removable (× button). @@ -475,7 +477,7 @@ def Pill( fill it when cloning the pill from a server-rendered ``