diff --git a/common/components/core.py b/common/components/core.py index 0e518e5..43763c1 100644 --- a/common/components/core.py +++ b/common/components/core.py @@ -235,6 +235,16 @@ class Element(Node): children = [children] self.children = children + def __getitem__(self, children: "Children | Node") -> "Element": + """htpy-style children: ``Div(class_="x")[child1, child2]``. + + Returns an Element with the same tag/attributes/media and these + children, so the tree stays walkable (Media still bubbles).""" + items = children if isinstance(children, tuple) else (children,) + clone = Element(self.tag_name, self.attributes, list(items)) + clone.media = self.media + return clone + def collect_media(self) -> Media: media = self.media for child in self.children: diff --git a/common/components/primitives.py b/common/components/primitives.py index 46341f8..b43fa29 100644 --- a/common/components/primitives.py +++ b/common/components/primitives.py @@ -52,14 +52,31 @@ _SIZE_CLASSES = { # tag name is data, not a separate class/function body. Add a tag = one line. +def _attrs_from_kwargs(attrs: dict[str, object]) -> list[HTMLAttribute]: + """Translate htpy-style attribute kwargs to (name, value) pairs. + + ``class_`` -> ``class`` (trailing underscore stripped); ``hx_get`` -> + ``hx-get`` (inner underscores to hyphens); ``True`` -> bare attribute; + ``False`` / ``None`` -> omitted.""" + result: list[HTMLAttribute] = [] + for key, value in attrs.items(): + if value is None or value is False: + continue + name = key.rstrip("_").replace("_", "-") + result.append((name, name if value is True else value)) # type: ignore[arg-type] + return result + + def _html_element(tag_name: str): """Build a generic element builder for ``tag_name`` (the whitelist factory).""" def element( attributes: Attributes | None = None, children: Children = None, + **attrs: object, ) -> Element: - return Element(tag_name, attributes, children) + merged = as_attributes(attributes) + _attrs_from_kwargs(attrs) + return Element(tag_name, merged, children) element.__name__ = element.__qualname__ = tag_name[:1].upper() + tag_name[1:] element.__doc__ = f"Builder for the <{tag_name}> element." diff --git a/tests/test_node_tree.py b/tests/test_node_tree.py index 27db6a2..fe137d5 100644 --- a/tests/test_node_tree.py +++ b/tests/test_node_tree.py @@ -174,5 +174,48 @@ class RealComponentMediaTest(unittest.TestCase): self.assertIn("range_slider.js", media.js) +class HtpyStyleSugarTest(unittest.TestCase): + def test_getitem_sets_children(self): + from common.components import Div, Span + + self.assertEqual( + render(Div(class_="card")[Span()["hi"]]), + '
hi
', + ) + + def test_getitem_multiple_children(self): + from common.components import Div + + self.assertEqual(render(Div()["a", "b"]), "
a\nb
") + + def test_kwargs_class_underscore_becomes_class(self): + from common.components import Div + + self.assertIn('class="x"', render(Div(class_="x"))) + + def test_kwargs_inner_underscore_becomes_hyphen(self): + from common.components import Div + + self.assertIn('hx-get="/y"', render(Div(hx_get="/y"))) + + def test_kwargs_true_renders_bare_attr(self): + from common.components import Div + + self.assertIn('hidden="hidden"', render(Div(hidden=True))) + + def test_kwargs_false_and_none_omitted(self): + from common.components import Div + + html = render(Div(hidden=False, title=None)) + self.assertNotIn("hidden", html) + self.assertNotIn("title", html) + + def test_getitem_preserves_media(self): + from common.components import Div, Media, collect_media + + node = Div(class_="x").with_media(Media(js=("a.js",)))["child"] + self.assertEqual(collect_media(node).js, ("a.js",)) + + if __name__ == "__main__": unittest.main()