diff --git a/README.md b/README.md index 9fa97f5..daaeede 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,19 @@ def current_user_panel(): The `render` function takes an `indent` argument, which is a integer used to control how many spaces are used as indentation in the generated HTML. The default is 0, meaning the entire HTML string will be returned on a single line. You may wish to use (say) `indent=2` for development, and `indent=0` for production (essentially minifying your HTML). +## Generating class names + +A function is provided that can be used to generate strings of class names based on various arguments. This is closely based on the [classnames](https://github.com/JedWatson/classnames/) JavaScript library. + +```python +from hotmetal.utils.classnames import classnames + + +def header(title): + title_is_green = title.lower() == "green" + return ("h1", {"class": classnames("title", {"green": title_is_green})}, [title]) +``` + ## Testing tools When writing tests for components, it's often useful to be able to search through a tree to find particular nodes and make assertions about them. To help with this, `hotmetal` provides a `find` function, which takes an iterable of nodes and a predicate callable, and returns a generator that yields nodes that match the predicate (using depth-first pre-order traversal of the nodes, much like the browser's [`querySelectorAll`](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll) function). diff --git a/hotmetal/utils/classnames.py b/hotmetal/utils/classnames.py new file mode 100644 index 0000000..1efb78b --- /dev/null +++ b/hotmetal/utils/classnames.py @@ -0,0 +1,18 @@ +def classnames(*args): + classes = [] + for arg in args: + if isinstance(arg, bool): + continue + if isinstance(arg, dict): + for key, value in arg.items(): + if value: + classes.append(key) + elif isinstance(arg, str) and arg.strip(): + classes.append(arg.strip()) + elif isinstance(arg, (int, float)) and arg: + classes.append(str(arg)) + elif isinstance(arg, (list, set)): + child = classnames(*arg) + if child: + classes.append(child) + return " ".join(classes) diff --git a/tests/utils/test_classnames.py b/tests/utils/test_classnames.py new file mode 100644 index 0000000..80401a9 --- /dev/null +++ b/tests/utils/test_classnames.py @@ -0,0 +1,79 @@ +from hotmetal.utils.classnames import classnames +from unittest import TestCase + + +class ClassnamesTestCase(TestCase): + def test_dict_keys_with_truthy_values(self): + self.assertEqual( + classnames({"a": True, "b": False, "c": 0, "d": None, "e": 1}), + "a e", + ) + + def test_joins_multiple_classnames_and_ignore_falsy_values(self): + self.assertEqual( + classnames("a", 0, None, True, 1, "b"), + "a 1 b", + ) + + def test_heterogenous_args(self): + self.assertEqual( + classnames({"a": True}, "b", 0), + "a b", + ) + + def test_result_trimmed(self): + self.assertEqual( + classnames("", "b", {}, ""), + "b", + ) + + def test_returns_empty_string_for_empty_args(self): + self.assertEqual( + classnames({}), + "", + ) + + def test_supports_list_of_classnames(self): + self.assertEqual( + classnames(["a", "b"]), + "a b", + ) + + def test_supports_sets_of_classnames(self): + self.assertTrue( + classnames({"a", "b"}) in {"a b", "b a"}, + ) + + def test_joins_lists_args_with_string_args(self): + self.assertEqual( + classnames(["a", "b"], "c"), + "a b c", + ) + self.assertEqual( + classnames("c", ["a", "b"]), + "c a b", + ) + + def test_handles_lists_that_include_falsy_and_true_values(self): + self.assertEqual( + classnames(["a", 0, None, False, True, "b"]), + "a b", + ) + + def test_handles_lists_that_include_lists(self): + self.assertEqual( + classnames(["a", ["b", "c"]]), + "a b c", + ) + + def test_handles_lists_that_include_dicts(self): + self.assertEqual( + classnames(["a", {"b": True, "c": False}]), + "a b", + ) + + def test_handles_nested_lists_that_include_empty_nested_lists(self): + self.assertEqual( + classnames(["a", [[]]]), + "a", + )