Skip to content

Commit

Permalink
Add CssClass for runtime CSS definitions (#60)
Browse files Browse the repository at this point in the history
Also refactor tests into separate integration and unittest dirs, for easiness
  • Loading branch information
kkinder authored Sep 7, 2024
1 parent e8cd52d commit c09aa2f
Show file tree
Hide file tree
Showing 15 changed files with 179 additions and 40 deletions.
31 changes: 28 additions & 3 deletions examples/tutorial/06_components/components.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,40 @@
from puepy import Application, Page, Component, t
from puepy import Application, Page, Component, t, CssClass

app = Application()

#
# These are Python-defined css classes. You can use them instead of strings when specifying CSS classes:
# Arguments can be either strings or keyword arguments
warning = CssClass(color="darkorange")
error = CssClass("color: red")
success = CssClass("color: darkgreen")


# Using the @t.component decorator registers the component for use elsewhere
@t.component()
class Card(Component):
props = ["type", "button_text"]
default_classes = ["card"]

#
# For CSS specific to a component, put it here. The generated class name is unique per CssClass instance,
# so this can work to "scope" CSS.
card = CssClass(
margin="1em",
padding="1em",
background_color="#efefef",
border="solid 2px #333",
)

default_classes = [card]

type_styles = {
"success": success,
"warning": warning,
"error": error,
}

def populate(self):
with t.h2(classes=[self.type]):
with t.h2(classes=[self.type_styles[self.type]]):
self.insert_slot("card-header")
with t.p():
self.insert_slot() # If you don't pass a name, it defaults to the main slot
Expand All @@ -27,6 +51,7 @@ def initial(self):

def populate(self):
t.h1("Components are useful")
t.message()

with t.card(type="success", button_text="Okay Then", on_my_custom_event=self.handle_custom_event) as card:
with card.slot("card-header"):
Expand Down
31 changes: 7 additions & 24 deletions examples/tutorial/06_components/index.html
Original file line number Diff line number Diff line change
@@ -1,31 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PuePy Components</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="stylesheet" href="https://pyscript.net/releases/2024.8.2/core.css">
<script type="module" src="https://pyscript.net/releases/2024.8.2/core.js"></script>
<style>
.card {
margin: 1em;
padding: 1em;
background-color: #efefef;
border: solid 2px #333;
}
.warning {
color: darkorange;
}
.error {
color: red;
}
.success {
color: darkgreen;
}
</style>
<title>PuePy Components</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="stylesheet" href="https://pyscript.net/releases/2024.8.2/core.css">
<script type="module" src="https://pyscript.net/releases/2024.8.2/core.js"></script>
</head>
<body>
<div id="app">Loading...</div>
<script type="mpy" src="./components.py" config="../../pyscript.json"></script>
<div id="app">Loading...</div>
<script type="mpy" src="./components.py" config="../../pyscript.json"></script>
</body>
</html>
2 changes: 1 addition & 1 deletion puepy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from puepy.core import Component, Page, Prop, t
from puepy.core import Component, Page, Prop, CssClass, t
from puepy.application import Application
from .version import __version__
52 changes: 51 additions & 1 deletion puepy/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@
)


class CssClass:
def __init__(self, *rules, **kw_rules):
self.rules = list(rules)

for k, v in kw_rules.items():
k = k.replace("_", "-")
self.rules.append(f"{k}: {v}")

self.class_name = f"-ps-{id(self)}"

def __str__(self):
return self.class_name

def render_css(self):
return f".{self.class_name} {{ {';'.join(self.rules)} }}"


class Prop:
"""
Class representing a prop for a component.
Expand Down Expand Up @@ -348,12 +365,14 @@ def render_unknown_child(self, element, child):
raise Exception(f"Unknown child type {type(child)} onto {self}")

def get_render_classes(self, attrs):
return merge_classes(
class_names, python_css_classes = merge_classes(
set(self.get_default_classes()),
attrs.pop("class_name", []),
attrs.pop("classes", []),
attrs.pop("class", []),
)
self.page.python_css_classes.update(python_css_classes)
return class_names

def get_default_classes(self):
"""
Expand Down Expand Up @@ -417,6 +436,13 @@ def mount(self, selector_or_element):
element.innerHTML = ""
element.appendChild(self.render())
self.recursive_call("on_ready")
self.add_python_css_classes()

def add_python_css_classes(self):
"""
This is only done at the page level.
"""
pass

def recursive_call(self, method, *args, **kwargs):
"""
Expand Down Expand Up @@ -752,6 +778,8 @@ def __init__(self, matched_route=None, application=None, **kwargs):
self.matched_route = matched_route
self._application = application

self.python_css_classes = set()

self._redraw_timeout_set = False
self.redraw_list = set()

Expand Down Expand Up @@ -799,6 +827,28 @@ def _do_redraw(self):
except Exception as e:
self.application.handle_error(e)

def add_python_css_classes(self):
"""
Iterates over Python css classes defined by elements and puts them in the head of the document.
"""
css_class: CssClass
css_rules = []
for css_class in self.python_css_classes:
css_rules.append(css_class.render_css())

if css_rules:
el = self.document.getElementById("puepy-runtime-css")
if el:
created = False
else:
el = self.document.createElement("style")
el.type = "text/css"
created = True
el.innerHTML = ""
el.appendChild(self.document.createTextNode("\n".join(css_rules)))
if created:
self.document.head.appendChild(el)


class Builder:
def __init__(self):
Expand Down
14 changes: 13 additions & 1 deletion puepy/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ def mixed_to_underscores(input_string, separator="_"):


def merge_classes(*items):
from .core import CssClass

classes = set()
python_css_classes = []
exclude_classes = set()

for class_option in items:
Expand All @@ -31,14 +34,23 @@ def merge_classes(*items):
exclude_classes.update([key for key, value in class_option.items() if not value])
elif class_option is None:
pass
elif isinstance(class_option, CssClass):
classes.add(class_option)
else:
classes.update(list(class_option))

for c in list(classes):
if isinstance(c, CssClass):
classes.remove(c)
classes.add(c.class_name)
python_css_classes.append(c)

for c in classes:
if c.startswith("/"):
exclude_classes.add(c)
exclude_classes.add(c[1:])
classes.difference_update(exclude_classes)
return classes
return classes, python_css_classes


def jsobj(**kwargs):
Expand Down
Empty file added tests/unittests/__init__.py
Empty file.
File renamed without changes.
File renamed without changes.
File renamed without changes.
60 changes: 60 additions & 0 deletions tests/test_core.py → tests/unittests/test_core.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import unittest
from unittest.mock import MagicMock

import pytest

from .dom_test import DomTest
from .dom_tools import node_to_dict
Expand Down Expand Up @@ -121,5 +124,62 @@ def test_mount_and_redraw(self):
)


class TestCssClass:
def test_cssclass_init(self):
css_class = core.CssClass("margin: 10px", "font-size: 12px", color="red")

assert len(css_class.rules) == 3
assert "margin: 10px" in css_class.rules
assert "font-size: 12px" in css_class.rules
assert "color: red" in css_class.rules
assert css_class.class_name.startswith("-ps-")

def test_cssclass_str(self):
css_class = core.CssClass("margin: 10px", "font-size: 12px", color="red")
class_name_str = str(css_class)

assert class_name_str == css_class.class_name

def test_cssclass_render_css(self):
css_class = core.CssClass("margin: 10px", "font-size: 12px", color="red")
rendered_css = css_class.render_css()

assert rendered_css.startswith(f".{css_class.class_name} {{")
assert "margin: 10px;" in rendered_css
assert "font-size: 12px;" in rendered_css
assert "color: red" in rendered_css
assert rendered_css.endswith(" }")


class TestPage:
@pytest.fixture
def page(self):
return core.Page()

@pytest.fixture
def css_class(self):
return core.CssClass(border="solid 1px blue")

def test_add_python_css_classes(self, page, css_class):
page.python_css_classes = [css_class]
page.document = MagicMock()
page.document.getElementById.return_value = None

page.add_python_css_classes()

page.document.getElementById.assert_called_once_with("puepy-runtime-css")
page.document.createElement.assert_called_once_with("style")
page.document.createTextNode.assert_called_once()

def test_add_python_css_classes_existing_element(self, page, css_class):
page.python_css_classes = [css_class]
page.document = MagicMock()

page.add_python_css_classes()

page.document.getElementById.assert_called_once_with("puepy-runtime-css")
page.document.createElement.assert_not_called()


if __name__ == "__main__":
unittest.main()
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
29 changes: 19 additions & 10 deletions tests/test_utils.py → tests/unittests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,45 +1,54 @@
import unittest
from xml.dom import getDOMImplementation

from puepy import CssClass
from puepy.util import merge_classes, _extract_event_handlers, patch_dom_element
from .dom_tools import node_to_dict


class TestMergeClasses(unittest.TestCase):
class TestMergeCssClasses(unittest.TestCase):
def test_basic_usage(self):
test1 = CssClass(border="solid 1px silver")
css_classes, runtime_css = merge_classes([test1])

self.assertEqual(css_classes.pop(), runtime_css[0].class_name)


class TestMergeStringClasses(unittest.TestCase):
def test_single_str_input(self):
result = merge_classes("class1")
result = merge_classes("class1")[0]
self.assertSetEqual(result, {"class1"})

def test_multiple_str_input(self):
result = merge_classes("class1 class2")
result = merge_classes("class1 class2")[0]
self.assertSetEqual(result, {"class1", "class2"})

def test_multiple_different_input(self):
result = merge_classes("class1", ["class2", "class3"])
result = merge_classes("class1", ["class2", "class3"])[0]
self.assertSetEqual(result, {"class1", "class2", "class3"})

def test_dict_input_true_values(self):
result = merge_classes({"class1": True, "class2": True})
result = merge_classes({"class1": True, "class2": True})[0]
self.assertSetEqual(result, {"class1", "class2"})

def test_dict_input_false_values(self):
result = merge_classes({"class1": False, "class2": False})
result = merge_classes({"class1": False, "class2": False})[0]
self.assertSetEqual(result, set())

def test_dict_input_mixed_values(self):
result = merge_classes({"class1": True, "class2": False})
result = merge_classes({"class1": True, "class2": False})[0]
self.assertSetEqual(result, {"class1"})

def test_exclude_class(self):
result = merge_classes("/class1", "class1 class2")
result = merge_classes("/class1", "class1 class2")[0]
self.assertEqual(result, {"class2"})

def test_none_input(self):
result = merge_classes(None)
result = merge_classes(None)[0]
self.assertSetEqual(result, set())

def test_class_list_input(self):
result = merge_classes(("class1", "class2"))
result = merge_classes(("class1", "class2"))[0]
self.assertSetEqual(result, {"class1", "class2"})


Expand Down

0 comments on commit c09aa2f

Please sign in to comment.