Skip to content

Commit f1b1e97

Browse files
authored
add class_names (#50)
1 parent bf71d1e commit f1b1e97

File tree

5 files changed

+153
-2
lines changed

5 files changed

+153
-2
lines changed

docs/source/dom_id.md renamed to docs/source/dom_helper.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
# dom_id
1+
# DOM Helper
2+
3+
## dom_id
24

35
`dom_id` is a helper method that returns a unique DOM ID based on the object's class name and ID
46

@@ -22,3 +24,17 @@ from turbo_helper import dom_id
2224

2325
target = dom_id(instance, "detail_container")
2426
```
27+
28+
## class_names
29+
30+
Inspired by JS [classnames](https://www.npmjs.com/package/classnames) and Rails `class_names`
31+
32+
`class_names` can help conditionally render css classes
33+
34+
```javascript
35+
<div class="{% class_names test1=True 'test2' ring-slate-900/5=True already-sign-in=request.user.is_authenticated %}"></div>
36+
37+
'<div class="test1 test2 ring-slate-900/5 dark:bg-slate-800 %}"></div>'
38+
```
39+
40+
It can also work well with TailwindCSS's some special css char such as `/` and `:`

docs/source/index.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Topics
1818

1919
install.md
2020
form-submission.md
21-
dom_id.md
21+
dom_helper.md
2222
turbo_frame.md
2323
turbo_stream.md
2424
real-time-updates.md

src/turbo_helper/templatetags/turbo_helper.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
from typing import Any, Optional
23

34
from django import template
@@ -60,6 +61,87 @@ def dom_id(instance: Any, prefix: Optional[str] = "") -> str:
6061
return identifier
6162

6263

64+
ATTRIBUTE_RE = re.compile(
65+
r"""
66+
(?P<attr>
67+
[@\w:_\.\/-]+
68+
)
69+
(?P<sign>
70+
\+?=
71+
)
72+
(?P<value>
73+
['"]? # start quote
74+
[^"']*
75+
['"]? # end quote
76+
)
77+
""",
78+
re.VERBOSE | re.UNICODE,
79+
)
80+
81+
82+
VALUE_RE = re.compile(
83+
r"""
84+
['"] # start quote (required)
85+
(?P<value>
86+
[^"']* # match any character except quotes
87+
)
88+
['"] # end quote (required)
89+
""",
90+
re.VERBOSE | re.UNICODE,
91+
)
92+
93+
94+
@register.tag
95+
def class_names(parser, token):
96+
error_msg = f"{token.split_contents()[0]!r} tag requires " "a list of css classes"
97+
try:
98+
bits = token.split_contents()
99+
tag_name = bits[0] # noqa
100+
attr_list = bits[1:]
101+
except ValueError as exc:
102+
raise TemplateSyntaxError(error_msg) from exc
103+
104+
css_ls = []
105+
css_dict = {}
106+
for pair in attr_list:
107+
attribute_match = ATTRIBUTE_RE.match(pair) or VALUE_RE.match(pair)
108+
109+
if attribute_match:
110+
dct = attribute_match.groupdict()
111+
attr = dct.get("attr", None)
112+
# sign = dct.get("sign", None)
113+
value = parser.compile_filter(dct["value"])
114+
if attr:
115+
css_dict[attr] = value
116+
else:
117+
css_ls.append(value)
118+
else:
119+
raise TemplateSyntaxError("class_names found supported token: " + f"{pair}")
120+
121+
return ClassNamesNode(css_ls=css_ls, css_dict=css_dict)
122+
123+
124+
class ClassNamesNode(Node):
125+
def __init__(self, css_ls, css_dict):
126+
self.css_ls = css_ls
127+
self.css_dict = css_dict
128+
129+
def render(self, context):
130+
final_css = []
131+
132+
# for common css classes
133+
for value in self.css_ls:
134+
final_css.append(value.token)
135+
136+
# for conditionals
137+
for attr, expression in self.css_dict.items():
138+
real_value = expression.resolve(context)
139+
if real_value:
140+
final_css.append(attr)
141+
142+
return " ".join(final_css)
143+
144+
63145
class TurboFrameTagNode(Node):
64146
def __init__(self, frame_id, nodelist, extra_context=None):
65147
self.frame_id = frame_id

tests/test_tags.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from django.template import Context, Template
33

44
from tests.testapp.models import TodoItem
5+
from tests.utils import assert_dom_equal
56
from turbo_helper.templatetags.turbo_helper import dom_id
67

78
pytestmark = pytest.mark.django_db
@@ -32,6 +33,44 @@ def test_prefix(self, todo):
3233
result = dom_id(todo, "test")
3334
assert "test_todoitem_1" == result
3435

36+
def test_value_override(self):
37+
template = """
38+
{% load turbo_helper %}
39+
40+
{% dom_id first as dom_id %}
41+
<div id="{{ dom_id }}"></div>
42+
43+
{% dom_id second as dom_id %}
44+
<div id="{{ dom_id }}"></div>
45+
46+
<div id="{{ dom_id }}"></div>
47+
"""
48+
output = render(
49+
template,
50+
{
51+
"first": "first",
52+
"second": "second",
53+
},
54+
).strip()
55+
assert_dom_equal(
56+
output,
57+
'<div id="first"></div> <div id="second"></div> <div id="second"></div>',
58+
)
59+
60+
61+
class TestClassNames:
62+
def test_logic(self):
63+
template = """
64+
{% load turbo_helper %}
65+
66+
<div class="{% class_names test1=True 'test2' "test3" test5=False ring-slate-900/5=True dark:bg-slate-800=True %}"></div>
67+
"""
68+
output = render(template, {}).strip()
69+
assert_dom_equal(
70+
output,
71+
'<div class="test1 test2 test3 ring-slate-900/5 dark:bg-slate-800"></div>',
72+
)
73+
3574

3675
class TestFrame:
3776
def test_string(self):

tests/utils.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
11
from bs4 import BeautifulSoup
22

33

4+
def normalize_classes(soup):
5+
"""Normalize the order of CSS classes in the BeautifulSoup object."""
6+
for tag in soup.find_all(class_=True):
7+
classes = tag.get("class", [])
8+
sorted_classes = sorted(classes)
9+
tag["class"] = " ".join(sorted_classes)
10+
return soup
11+
12+
413
def assert_dom_equal(expected_html, actual_html):
14+
"""Assert that two HTML strings are equal, ignoring differences in class order."""
515
expected_soup = BeautifulSoup(expected_html, "html.parser")
616
actual_soup = BeautifulSoup(actual_html, "html.parser")
717

18+
# Normalize the class attribute order
19+
expected_soup = normalize_classes(expected_soup)
20+
actual_soup = normalize_classes(actual_soup)
21+
822
expected_str = expected_soup.prettify()
923
actual_str = actual_soup.prettify()
1024

0 commit comments

Comments
 (0)