Skip to content

Commit

Permalink
add class_names (#50)
Browse files Browse the repository at this point in the history
  • Loading branch information
michael-yin authored Aug 26, 2024
1 parent bf71d1e commit f1b1e97
Show file tree
Hide file tree
Showing 5 changed files with 153 additions and 2 deletions.
18 changes: 17 additions & 1 deletion docs/source/dom_id.md → docs/source/dom_helper.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# dom_id
# DOM Helper

## dom_id

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

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

target = dom_id(instance, "detail_container")
```

## class_names

Inspired by JS [classnames](https://www.npmjs.com/package/classnames) and Rails `class_names`

`class_names` can help conditionally render css classes

```javascript
<div class="{% class_names test1=True 'test2' ring-slate-900/5=True already-sign-in=request.user.is_authenticated %}"></div>

'<div class="test1 test2 ring-slate-900/5 dark:bg-slate-800 %}"></div>'
```

It can also work well with TailwindCSS's some special css char such as `/` and `:`
2 changes: 1 addition & 1 deletion docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Topics

install.md
form-submission.md
dom_id.md
dom_helper.md
turbo_frame.md
turbo_stream.md
real-time-updates.md
Expand Down
82 changes: 82 additions & 0 deletions src/turbo_helper/templatetags/turbo_helper.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
from typing import Any, Optional

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


ATTRIBUTE_RE = re.compile(
r"""
(?P<attr>
[@\w:_\.\/-]+
)
(?P<sign>
\+?=
)
(?P<value>
['"]? # start quote
[^"']*
['"]? # end quote
)
""",
re.VERBOSE | re.UNICODE,
)


VALUE_RE = re.compile(
r"""
['"] # start quote (required)
(?P<value>
[^"']* # match any character except quotes
)
['"] # end quote (required)
""",
re.VERBOSE | re.UNICODE,
)


@register.tag
def class_names(parser, token):
error_msg = f"{token.split_contents()[0]!r} tag requires " "a list of css classes"
try:
bits = token.split_contents()
tag_name = bits[0] # noqa
attr_list = bits[1:]
except ValueError as exc:
raise TemplateSyntaxError(error_msg) from exc

css_ls = []
css_dict = {}
for pair in attr_list:
attribute_match = ATTRIBUTE_RE.match(pair) or VALUE_RE.match(pair)

if attribute_match:
dct = attribute_match.groupdict()
attr = dct.get("attr", None)
# sign = dct.get("sign", None)
value = parser.compile_filter(dct["value"])
if attr:
css_dict[attr] = value
else:
css_ls.append(value)
else:
raise TemplateSyntaxError("class_names found supported token: " + f"{pair}")

return ClassNamesNode(css_ls=css_ls, css_dict=css_dict)


class ClassNamesNode(Node):
def __init__(self, css_ls, css_dict):
self.css_ls = css_ls
self.css_dict = css_dict

def render(self, context):
final_css = []

# for common css classes
for value in self.css_ls:
final_css.append(value.token)

# for conditionals
for attr, expression in self.css_dict.items():
real_value = expression.resolve(context)
if real_value:
final_css.append(attr)

return " ".join(final_css)


class TurboFrameTagNode(Node):
def __init__(self, frame_id, nodelist, extra_context=None):
self.frame_id = frame_id
Expand Down
39 changes: 39 additions & 0 deletions tests/test_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from django.template import Context, Template

from tests.testapp.models import TodoItem
from tests.utils import assert_dom_equal
from turbo_helper.templatetags.turbo_helper import dom_id

pytestmark = pytest.mark.django_db
Expand Down Expand Up @@ -32,6 +33,44 @@ def test_prefix(self, todo):
result = dom_id(todo, "test")
assert "test_todoitem_1" == result

def test_value_override(self):
template = """
{% load turbo_helper %}
{% dom_id first as dom_id %}
<div id="{{ dom_id }}"></div>
{% dom_id second as dom_id %}
<div id="{{ dom_id }}"></div>
<div id="{{ dom_id }}"></div>
"""
output = render(
template,
{
"first": "first",
"second": "second",
},
).strip()
assert_dom_equal(
output,
'<div id="first"></div> <div id="second"></div> <div id="second"></div>',
)


class TestClassNames:
def test_logic(self):
template = """
{% load turbo_helper %}
<div class="{% class_names test1=True 'test2' "test3" test5=False ring-slate-900/5=True dark:bg-slate-800=True %}"></div>
"""
output = render(template, {}).strip()
assert_dom_equal(
output,
'<div class="test1 test2 test3 ring-slate-900/5 dark:bg-slate-800"></div>',
)


class TestFrame:
def test_string(self):
Expand Down
14 changes: 14 additions & 0 deletions tests/utils.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
from bs4 import BeautifulSoup


def normalize_classes(soup):
"""Normalize the order of CSS classes in the BeautifulSoup object."""
for tag in soup.find_all(class_=True):
classes = tag.get("class", [])
sorted_classes = sorted(classes)
tag["class"] = " ".join(sorted_classes)
return soup


def assert_dom_equal(expected_html, actual_html):
"""Assert that two HTML strings are equal, ignoring differences in class order."""
expected_soup = BeautifulSoup(expected_html, "html.parser")
actual_soup = BeautifulSoup(actual_html, "html.parser")

# Normalize the class attribute order
expected_soup = normalize_classes(expected_soup)
actual_soup = normalize_classes(actual_soup)

expected_str = expected_soup.prettify()
actual_str = actual_soup.prettify()

Expand Down

0 comments on commit f1b1e97

Please sign in to comment.