feat: granular handling of class and style in {% html_attrs %} (#1066)

* feat: granular handling of class and style in {% html_attrs %}

* refactor: fix linter errors

* docs: document deprecation, fix typos, fix broken table of contents

* refactor: remove classes and styles as lists from docs
This commit is contained in:
Juro Oravec 2025-03-24 17:35:12 +01:00 committed by GitHub
parent 1e71f3d656
commit 328309a81c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1361 additions and 488 deletions

View file

@ -1,5 +1,28 @@
# Release notes
## v0.135
#### Feat
- `{% html_attrs %}` now offers a Vue-like granular control over `class` and `style` HTML attributes,
where each class name or style property can be managed separately.
```django
{% html_attrs
class="foo bar"
class={"baz": True, "foo": False}
class="extra"
%}
```
```django
{% html_attrs
style="text-align: center; background-color: blue;"
style={"background-color": "green", "color": None, "width": False}
style="position: absolute; height: 12px;"
%}
```
## v0.134
#### Fix

View file

@ -201,9 +201,53 @@ class Calendar(Component):
/ %}
```
### Granular HTML attributes
Use the [`{% html_attrs %}`](https://django-components.github.io/django-components/latest/concepts/fundamentals/html_attributes/) template tag to render HTML attributes.
It supports:
- Defining attributes as dictionaries
- Defining attributes as keyword arguments
- Merging attributes from multiple sources
- Boolean attributes
- Appending attributes
- Removing attributes
- Defining default attributes
```django
<div
{% html_attrs
attrs
defaults:class="default-class"
class="extra-class"
%}
>
```
`{% html_attrs %}` offers a Vue-like granular control over `class` and `style` HTML attributes,
where you can use a dictionary to manage each class name or style property separately.
```django
{% html_attrs
class="foo bar"
class={"baz": True, "foo": False}
class="extra"
%}
```
```django
{% html_attrs
style="text-align: center; background-color: blue;"
style={"background-color": "green", "color": None, "width": False}
style="position: absolute; height: 12px;"
%}
```
Read more about [HTML attributes](https://django-components.github.io/django-components/latest/concepts/fundamentals/html_attributes/).
### HTML fragment support
`django-components` makes intergration with HTMX, AlpineJS or jQuery easy by allowing components to be rendered as HTML fragments:
`django-components` makes integration with HTMX, AlpineJS or jQuery easy by allowing components to be rendered as HTML fragments:
- Components's JS and CSS is loaded automatically when the fragment is inserted into the DOM.
@ -345,7 +389,6 @@ def test_my_table():
### Other features
- Vue-like provide / inject system
- Format HTML attributes with `{% html_attrs %}`
## Documentation

View file

@ -1,37 +1,183 @@
_New in version 0.74_:
You can use the `html_attrs` tag to render HTML attributes, given a dictionary
of values.
You can use the [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag to render various data
as `key="value"` HTML attributes.
So if you have a template:
[`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag is versatile, allowing you to define HTML attributes however you need:
- Define attributes within the HTML template
- Define attributes in Python code
- Merge attributes from multiple sources
- Boolean attributes
- Append attributes
- Remove attributes
- Define default attributes
From v0.135 onwards, [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag also supports merging [`style`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) and [`class`](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/class) attributes
the same way [how Vue does](https://vuejs.org/guide/essentials/class-and-style).
To get started, let's consider a simple example. If you have a template:
```django
<div class="{{ classes }}" data-id="{{ my_id }}">
</div>
```
You can simplify it with `html_attrs` tag:
You can rewrite it with the [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag:
```django
<div {% html_attrs class=classes data-id=my_id %}>
</div>
```
The [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag accepts any number of keyword arguments, which will be merged and rendered as HTML attributes:
```django
<div class="text-red" data-id="123">
</div>
```
Moreover, the [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag accepts two positional arguments:
- `attrs` - a dictionary of attributes to be rendered
- `defaults` - a dictionary of default attributes
You can use this for example to allow users of your component to add extra attributes. We achieve this by capturing the extra attributes and passing them to the [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag as a dictionary:
```djc_py
@register("my_comp")
class MyComp(Component):
# Capture extra kwargs in `attrs`
def get_context_data(self, **attrs):
return {
"attrs": attrs,
"classes": "text-red",
"my_id": 123,
}
template: t.django_html = """
{# Pass the extra attributes to `html_attrs` #}
<div {% html_attrs attrs class=classes data-id=my_id %}>
</div>
"""
```
This way you can render `MyComp` with extra attributes:
Either via Django template:
```django
{% component "my_comp"
id="example"
class="pa-4"
style="color: red;"
%}
```
Or via Python:
```py
MyComp.render(
kwargs={
"id": "example",
"class": "pa-4",
"style": "color: red;",
}
)
```
In both cases, the attributes will be merged and rendered as:
```html
<div id="example" class="text-red pa-4" style="color: red;" data-id="123"></div>
```
### Summary
1. The two arguments, `attrs` and `defaults`, can be passed as positional args:
```django
{% html_attrs attrs defaults key=val %}
```
or as kwargs:
```django
{% html_attrs key=val defaults=defaults attrs=attrs %}
```
2. Both `attrs` and `defaults` are optional and can be omitted.
3. Both `attrs` and `defaults` are dictionaries. As such, there's multiple ways to define them:
- By referencing a variable:
```django
{% html_attrs attrs=attrs %}
```
- By defining a literal dictionary:
```django
{% html_attrs attrs={"key": value} %}
```
- Or by defining the [dictionary keys](../template_tag_syntax/#pass-dictonary-by-its-key-value-pairs):
```django
{% html_attrs attrs:key=value %}
```
4. All other kwargs are merged and can be repeated.
```django
{% html_attrs class="text-red" class="pa-4" %}
```
Will render:
```html
<div class="text-red pa-4"></div>
```
## Usage
### Boolean attributes
In HTML, boolean attributes are usually rendered with no value. Consider the example below where the first button is disabled and the second is not:
```html
<button disabled>Click me!</button>
<button>Click me!</button>
```
HTML rendering with [`html_attrs`](../../../reference/template_tags#html_attrs) tag or [`format_attributes`](../../../reference/api#django_components.format_attributes) works the same way - an attribute set to `True` is rendered without the value, and an attribute set to `False` is not rendered at all.
So given this input:
```py
attrs = {
"disabled": True,
"autofocus": False,
}
```
And template:
```django
<div {% html_attrs attrs %}>
</div>
```
where `attrs` is:
Then this renders:
```py
attrs = {
"class": classes,
"data-id": my_id,
}
```html
<div disabled></div>
```
This feature is inspired by [`merge_attrs` tag of django-web-components](https://github.com/Xzya/django-web-components/tree/master?tab=readme-ov-file#default--merged-attributes) and
["fallthrough attributes" feature of Vue](https://vuejs.org/guide/components/attrs).
### Removing attributes
## Removing atttributes
Attributes that are set to `None` or `False` are NOT rendered.
Given how the boolean attributes work, you can "remove" or prevent an attribute from being rendered by setting it to `False` or `None`.
So given this input:
@ -56,41 +202,9 @@ Then this renders:
<div class="text-green"></div>
```
## Boolean attributes
### Default attributes
In HTML, boolean attributes are usually rendered with no value. Consider the example below where the first button is disabled and the second is not:
```html
<button disabled>Click me!</button> <button>Click me!</button>
```
HTML rendering with `html_attrs` tag or `attributes_to_string` works the same way, where `key=True` is rendered simply as `key`, and `key=False` is not render at all.
So given this input:
```py
attrs = {
"disabled": True,
"autofocus": False,
}
```
And template:
```django
<div {% html_attrs attrs %}>
</div>
```
Then this renders:
```html
<div disabled></div>
```
## Default attributes
Sometimes you may want to specify default values for attributes. You can pass a second argument (or kwarg `defaults`) to set the defaults.
Sometimes you may want to specify default values for attributes. You can pass a second positional argument to set the defaults.
```django
<div {% html_attrs attrs defaults %}>
@ -98,20 +212,30 @@ Sometimes you may want to specify default values for attributes. You can pass a
</div>
```
In the example above, if `attrs` contains e.g. the `class` key, `html_attrs` will render:
In the example above, if `attrs` contains a certain key, e.g. the `class` key, [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) will render:
`class="{{ attrs.class }}"`
```html
<div class="{{ attrs.class }}">
...
</div>
```
Otherwise, `html_attrs` will render:
Otherwise, [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) will render:
`class="{{ defaults.class }}"`
```html
<div class="{{ defaults.class }}">
...
</div>
```
## Appending attributes
### Appending attributes
For the `class` HTML attribute, it's common that we want to _join_ multiple values,
instead of overriding them. For example, if you're authoring a component, you may
instead of overriding them.
For example, if you're authoring a component, you may
want to ensure that the component will ALWAYS have a specific class. Yet, you may
want to allow users of your component to supply their own classes.
want to allow users of your component to supply their own `class` attribute.
We can achieve this by adding extra kwargs. These values
will be appended, instead of overwriting the previous value.
@ -124,7 +248,7 @@ attrs = {
}
```
And on `html_attrs` tag, we set the key `class`:
And on [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag, we set the key `class`:
```django
<div {% html_attrs attrs class="some-class" %}>
@ -153,23 +277,179 @@ Renders:
></div>
```
## Rules for `html_attrs`
### Merging `class` attributes
1. Both `attrs` and `defaults` can be passed as positional args
The `class` attribute can be specified as a string of class names as usual.
`{% html_attrs attrs defaults key=val %}`
If you want granular control over individual class names, you can use a dictionary.
or as kwargs
- **String**: Used as is.
`{% html_attrs key=val defaults=defaults attrs=attrs %}`
```django
{% html_attrs class="my-class other-class" %}
```
2. Both `attrs` and `defaults` are optional (can be omitted)
Renders:
3. Both `attrs` and `defaults` are dictionaries, and we can define them the same way [we define dictionaries for the `component` tag](#pass-dictonary-by-its-key-value-pairs). So either as `attrs=attrs` or `attrs:key=value`.
```html
<div class="my-class other-class"></div>
```
4. All other kwargs are appended and can be repeated.
- **Dictionary**: Keys are the class names, and values are booleans. Only keys with truthy values are rendered.
## Examples for `html_attrs`
```django
{% html_attrs class={
"extra-class": True,
"other-class": False,
} %}
```
Renders:
```html
<div class="extra-class"></div>
```
If a certain class is specified multiple times, it's the last instance that decides whether the class is rendered or not.
**Example:**
In this example, the `other-class` is specified twice. The last instance is `{"other-class": False}`, so the class is not rendered.
```django
{% html_attrs
class="my-class other-class"
class={"extra-class": True, "other-class": False}
%}
```
Renders:
```html
<div class="my-class extra-class"></div>
```
### Merging `style` Attributes
The `style` attribute can be specified as a string of style properties as usual.
If you want granular control over individual style properties, you can use a dictionary.
- **String**: Used as is.
```django
{% html_attrs style="color: red; background-color: blue;" %}
```
Renders:
```html
<div style="color: red; background-color: blue;"></div>
```
- **Dictionary**: Keys are the style properties, and values are their values.
```django
{% html_attrs style={
"color": "red",
"background-color": "blue",
} %}
```
Renders:
```html
<div style="color: red; background-color: blue;"></div>
```
If a style property is specified multiple times, the last value is used.
- If the last time the property is set is `False`, the property is removed.
- Properties set to `None` are ignored.
**Example:**
In this example, the `width` property is specified twice. The last instance is `{"width": False}`, so the property is removed.
Secondly, the `background-color` property is also set twice. But the second time it's set to `None`, so that instance is ignored, leaving us only with `background-color: blue`.
The `color` property is set to a valid value in both cases, so the latter (`green`) is used.
```django
{% html_attrs
style="color: red; background-color: blue; width: 100px;"
style={"color": "green", "background-color": None, "width": False}
%}
```
Renders:
```html
<div style="color: green; background-color: blue;"></div>
```
## Usage outside of templates
In some cases, you want to prepare HTML attributes outside of templates.
To achieve the same behavior as [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag, you can use the [`merge_attributes()`](../../../reference/api#django_components.merge_attributes) and [`format_attributes()`](../../../reference/api#django_components.format_attributes) helper functions.
### Merging attributes
[`merge_attributes()`](../../../reference/api#django_components.merge_attributes) accepts any number of dictionaries and merges them together, using the same merge strategy as [`{% html_attrs %}`](../../../reference/template_tags#html_attrs).
```python
from django_components import merge_attributes
merge_attributes(
{"class": "my-class", "data-id": 123},
{"class": "extra-class"},
{"class": {"cool-class": True, "uncool-class": False} },
)
```
Which will output:
```python
{
"class": "my-class extra-class cool-class",
"data-id": 123,
}
```
!!! warning
Unlike [`{% html_attrs %}`](../../../reference/template_tags#html_attrs), where you can pass extra kwargs, [`merge_attributes()`](../../../reference/api#django_components.merge_attributes) requires each argument to be a dictionary.
### Formatting attributes
[`format_attributes()`](../../../reference/api#django_components.format_attributes) serializes attributes the same way as [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag does.
```py
from django_components import format_attributes
format_attributes({
"class": "my-class text-red pa-4",
"data-id": 123,
"required": True,
"disabled": False,
"ignored-attr": None,
})
```
Which will output:
```python
'class="my-class text-red pa-4" data-id="123" required'
```
!!! note
Prior to v0.135, the `format_attributes()` function was named `attributes_to_string()`.
This function is now deprecated and will be removed in v1.0.
## Cheat sheet
Assuming that:
@ -189,67 +469,127 @@ defaults = {
Then:
- Empty tag <br/>
`{% html_attr %}`
- **Empty tag**
```django
<div {% html_attr %}></div>
```
renders (empty string): <br/>
` `
renders nothing:
- Only kwargs <br/>
`{% html_attr class="some-class" class=class_from_var data-id="123" %}`
```html
<div></div>
```
renders: <br/>
`class="some-class from-var" data-id="123"`
- **Only kwargs**
```django
<div {% html_attr class="some-class" class=class_from_var data-id="123" %}></div>
```
- Only attrs <br/>
`{% html_attr attrs %}`
renders:
renders: <br/>
`class="from-attrs" type="submit"`
```html
<div class="some-class from-var" data-id="123"></div>
```
- Attrs as kwarg <br/>
`{% html_attr attrs=attrs %}`
- **Only attrs**
renders: <br/>
`class="from-attrs" type="submit"`
```django
<div {% html_attr attrs %}></div>
```
- Only defaults (as kwarg) <br/>
`{% html_attr defaults=defaults %}`
renders:
renders: <br/>
`class="from-defaults" role="button"`
```html
<div class="from-attrs" type="submit"></div>
```
- Attrs using the `prefix:key=value` construct <br/>
`{% html_attr attrs:class="from-attrs" attrs:type="submit" %}`
- **Attrs as kwarg**
renders: <br/>
`class="from-attrs" type="submit"`
```django
<div {% html_attr attrs=attrs %}></div>
```
- Defaults using the `prefix:key=value` construct <br/>
`{% html_attr defaults:class="from-defaults" %}`
renders:
renders: <br/>
`class="from-defaults" role="button"`
```html
<div class="from-attrs" type="submit"></div>
```
- All together (1) - attrs and defaults as positional args: <br/>
`{% html_attrs attrs defaults class="added_class" class=class_from_var data-id=123 %}`
- **Only defaults (as kwarg)**
renders: <br/>
`class="from-attrs added_class from-var" type="submit" role="button" data-id=123`
```django
<div {% html_attr defaults=defaults %}></div>
```
- All together (2) - attrs and defaults as kwargs args: <br/>
`{% html_attrs class="added_class" class=class_from_var data-id=123 attrs=attrs defaults=defaults %}`
renders:
renders: <br/>
`class="from-attrs added_class from-var" type="submit" role="button" data-id=123`
```html
<div class="from-defaults" role="button"></div>
```
- All together (3) - mixed: <br/>
`{% html_attrs attrs defaults:class="default-class" class="added_class" class=class_from_var data-id=123 %}`
- **Attrs using the `prefix:key=value` construct**
renders: <br/>
`class="from-attrs added_class from-var" type="submit" data-id=123`
```django
<div {% html_attr attrs:class="from-attrs" attrs:type="submit" %}></div>
```
## Full example for `html_attrs`
renders:
```html
<div class="from-attrs" type="submit"></div>
```
- **Defaults using the `prefix:key=value` construct**
```django
<div {% html_attr defaults:class="from-defaults" %}></div>
```
renders:
```html
<div class="from-defaults" role="button"></div>
```
- **All together (1) - attrs and defaults as positional args:**
```django
<div {% html_attrs attrs defaults class="added_class" class=class_from_var data-id=123 %}></div>
```
renders:
```html
<div class="from-attrs added_class from-var" type="submit" role="button" data-id=123></div>
```
- **All together (2) - attrs and defaults as kwargs args:**
```django
<div {% html_attrs class="added_class" class=class_from_var data-id=123 attrs=attrs defaults=defaults %}></div>
```
renders:
```html
<div class="from-attrs added_class from-var" type="submit" role="button" data-id=123></div>
```
- **All together (3) - mixed:**
```django
<div {% html_attrs attrs defaults:class="default-class" class="added_class" class=class_from_var data-id=123 %}></div>
```
renders:
```html
<div class="from-attrs added_class from-var" type="submit" data-id=123></div>
```
## Full example
```djc_py
@register("my_comp")
@ -296,7 +636,9 @@ Note: For readability, we've split the tags across multiple lines.
Inside `MyComp`, we defined a default attribute
`defaults:class="pa-4 text-red"`
```
defaults:class="pa-4 text-red"
```
So if `attrs` includes key `class`, the default above will be ignored.
@ -352,22 +694,3 @@ So in the end `MyComp` will render:
...
</div>
```
## Rendering HTML attributes outside of templates
If you need to use serialize HTML attributes outside of Django template and the `html_attrs` tag, you can use `attributes_to_string`:
```py
from django_components.attributes import attributes_to_string
attrs = {
"class": "my-class text-red pa-4",
"data-id": 123,
"required": True,
"disabled": False,
"ignored-attr": None,
}
attributes_to_string(attrs)
# 'class="my-class text-red pa-4" data-id="123" required'
```

View file

@ -191,9 +191,53 @@ class Calendar(Component):
/ %}
```
### Granular HTML attributes
Use the [`{% html_attrs %}`](../../concepts/fundamentals/html_attributes/) template tag to render HTML attributes.
It supports:
- Defining attributes as dictionaries
- Defining attributes as keyword arguments
- Merging attributes from multiple sources
- Boolean attributes
- Appending attributes
- Removing attributes
- Defining default attributes
```django
<div
{% html_attrs
attrs
defaults:class="default-class"
class="extra-class"
%}
>
```
`{% html_attrs %}` offers a Vue-like granular control over `class` and `style` HTML attributes,
where you can use a dictionary to manage each class name or style property separately.
```django
{% html_attrs
class="foo bar"
class={"baz": True, "foo": False}
class="extra"
%}
```
```django
{% html_attrs
style="text-align: center; background-color: blue;"
style={"background-color": "green", "color": None, "width": False}
style="position: absolute; height: 12px;"
%}
```
Read more about [HTML attributes](../../concepts/fundamentals/html_attributes/).
### HTML fragment support
`django-components` makes intergration with HTMX, AlpineJS or jQuery easy by allowing components to be rendered as HTML fragments:
`django-components` makes integration with HTMX, AlpineJS or jQuery easy by allowing components to be rendered as HTML fragments:
- Components's JS and CSS is loaded automatically when the fragment is inserted into the DOM.
@ -335,7 +379,6 @@ def test_my_table():
### Other features
- Vue-like provide / inject system
- Format HTML attributes with `{% html_attrs %}`
## Performance

View file

@ -171,6 +171,10 @@
options:
show_if_no_docstring: true
::: django_components.format_attributes
options:
show_if_no_docstring: true
::: django_components.get_component_dirs
options:
show_if_no_docstring: true
@ -183,6 +187,10 @@
options:
show_if_no_docstring: true
::: django_components.merge_attributes
options:
show_if_no_docstring: true
::: django_components.register
options:
show_if_no_docstring: true
@ -198,3 +206,4 @@
::: django_components.template_tag
options:
show_if_no_docstring: true

View file

@ -384,7 +384,7 @@ usage: python manage.py components list [-h] [--all] [--columns COLUMNS] [-s]
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/commands/list.py#L136" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/commands/list.py#L141" target="_blank">See source code</a>
@ -461,8 +461,8 @@ ProjectDashboardAction project.components.dashboard_action.ProjectDashboardAc
## `upgradecomponent`
```txt
usage: upgradecomponent [-h] [--path PATH] [--version] [-v {0,1,2,3}] [--settings SETTINGS]
[--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color] [--skip-checks]
usage: upgradecomponent [-h] [--path PATH] [--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color]
[--skip-checks]
```
@ -506,9 +506,8 @@ Deprecated. Use `components upgrade` instead.
## `startcomponent`
```txt
usage: startcomponent [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force] [--verbose]
[--dry-run] [--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH]
[--traceback] [--no-color] [--force-color] [--skip-checks]
usage: startcomponent [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force] [--verbose] [--dry-run] [--version] [-v {0,1,2,3}]
[--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color] [--skip-checks]
name
```

View file

@ -67,7 +67,7 @@ If you insert this tag multiple times, ALL JS scripts will be duplicately insert
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1478" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L1584" target="_blank">See source code</a>
@ -273,7 +273,7 @@ use `{% fill %}` with `name` set to `"default"`:
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L13" target="_blank">See source code</a>
<a href="https://github.com/django-components/django-components/tree/master/src/django_components/templatetags/component_tags.py#L18" target="_blank">See source code</a>

View file

@ -5,6 +5,7 @@
# NOTE: Some of the documentation is generated based on these exports
# isort: off
from django_components.app_settings import ContextBehavior, ComponentsSettings
from django_components.attributes import format_attributes, merge_attributes
from django_components.autodiscovery import autodiscover, import_libraries
from django_components.util.command import (
CommandArg,
@ -90,9 +91,11 @@ __all__ = [
"DynamicComponent",
"EmptyTuple",
"EmptyDict",
"format_attributes",
"get_component_dirs",
"get_component_files",
"import_libraries",
"merge_attributes",
"NotRegistered",
"OnComponentClassCreatedContext",
"OnComponentClassDeletedContext",

View file

@ -2,7 +2,8 @@
# See https://github.com/Xzya/django-web-components/blob/b43eb0c832837db939a6f8c1980334b0adfdd6e4/django_web_components/templatetags/components.py # noqa: E501
# And https://github.com/Xzya/django-web-components/blob/b43eb0c832837db939a6f8c1980334b0adfdd6e4/django_web_components/attributes.py # noqa: E501
from typing import Any, Dict, Mapping, Optional, Tuple
import re
from typing import Any, Dict, List, Literal, Mapping, Optional, Sequence, Union
from django.template import Context
from django.utils.html import conditional_escape, format_html
@ -10,6 +11,10 @@ from django.utils.safestring import SafeString, mark_safe
from django_components.node import BaseNode
ClassValue = Union[Sequence["ClassValue"], str, Dict[str, bool]]
StyleDict = Dict[str, Union[str, int, Literal[False], None]]
StyleValue = Union[Sequence["StyleValue"], str, StyleDict]
class HtmlAttrsNode(BaseNode):
"""
@ -79,14 +84,30 @@ class HtmlAttrsNode(BaseNode):
final_attrs = {}
final_attrs.update(defaults or {})
final_attrs.update(attrs or {})
final_attrs = append_attributes(*final_attrs.items(), *kwargs.items())
final_attrs = merge_attributes(final_attrs, kwargs)
# Render to HTML attributes
return attributes_to_string(final_attrs)
return format_attributes(final_attrs)
def attributes_to_string(attributes: Mapping[str, Any]) -> str:
"""Convert a dict of attributes to a string."""
def format_attributes(attributes: Mapping[str, Any]) -> str:
"""
Format a dict of attributes into an HTML attributes string.
Read more about [HTML attributes](../../concepts/fundamentals/html_attributes).
**Example:**
```python
format_attributes({"class": "my-class", "data-id": "123"})
```
will return
```py
'class="my-class" data-id="123"'
```
"""
attr_list = []
for key, value in attributes.items():
@ -100,19 +121,321 @@ def attributes_to_string(attributes: Mapping[str, Any]) -> str:
return mark_safe(SafeString(" ").join(attr_list))
def append_attributes(*args: Tuple[str, Any]) -> Dict:
"""
Merges the key-value pairs and returns a new dictionary.
# TODO_V1 - Remove in v1, keep only `format_attributes` going forward
attributes_to_string = format_attributes
"""
Deprecated. Use [`format_attributes`](../api#django_components.format_attributes) instead.
"""
If a key is present multiple times, its values are concatenated with a space
character as separator in the final dictionary.
def merge_attributes(*attrs: Dict) -> Dict:
"""
Merge a list of dictionaries into a single dictionary.
The dictionaries are treated as HTML attributes and are merged accordingly:
- If a same key is present in multiple dictionaries, the values are joined with a space
character.
- The `class` and `style` keys are handled specially, similar to
[how Vue does it](https://vuejs.org/api/render-function#mergeprops).
Read more about [HTML attributes](../../concepts/fundamentals/html_attributes).
**Example:**
```python
merge_attributes(
{"my-attr": "my-value", "class": "my-class"},
{"my-attr": "extra-value", "data-id": "123"},
)
```
will result in
```python
{
"my-attr": "my-value extra-value",
"class": "my-class",
"data-id": "123",
}
```
**The `class` attribute**
The `class` attribute can be given as a string, or a dictionary.
- If given as a string, it is used as is.
- If given as a dictionary, only the keys with a truthy value are used.
**Example:**
```python
merge_attributes(
{"class": "my-class extra-class"},
{"class": {"truthy": True, "falsy": False}},
)
```
will result in
```python
{
"class": "my-class extra-class truthy",
}
```
**The `style` attribute**
The `style` attribute can be given as a string, a list, or a dictionary.
- If given as a string, it is used as is.
- If given as a dictionary, it is converted to a style attribute string.
**Example:**
```python
merge_attributes(
{"style": "color: red; background-color: blue;"},
{"style": {"background-color": "green", "color": False}},
)
```
will result in
```python
{
"style": "color: red; background-color: blue; background-color: green;",
}
```
"""
result: Dict = {}
for key, value in args:
if key in result:
result[key] += " " + value
else:
result[key] = value
classes: List[ClassValue] = []
styles: List[StyleValue] = []
for attrs_dict in attrs:
for key, value in attrs_dict.items():
if key == "class":
classes.append(value)
elif key == "style":
styles.append(value)
elif key in result:
# Other keys are concatenated with a space character as separator
# if given multiple times.
result[key] = str(result[key]) + " " + str(value)
else:
result[key] = value
# Style and class have special handling based on how Vue does it.
if classes:
result["class"] = normalize_class(classes)
if styles:
result["style"] = normalize_style(styles)
return result
def normalize_class(value: ClassValue) -> str:
"""
Normalize a class value.
Class may be given as a string, a list, or a dictionary:
- If given as a string, it is used as is.
- If given as a dictionary, only the keys with a truthy value are used.
- If given as a list, each item is converted to a dict, the dicts are merged, and resolved as above.
If a class is given multiple times, the last value is used.
This is based on Vue's [`mergeProps` function](https://vuejs.org/api/render-function#mergeprops).
**Example:**
```python
normalize_class([
"my-class other-class",
{"extra-class": True, "other-class": False}
])
```
will result in
```python
"my-class extra-class"
```
Where:
- `my-class` is used as is
- `extra-class` is used because it has a truthy value
- `other-class` is ignored because it's last value is falsy
"""
res: Dict[str, bool] = {}
if isinstance(value, str):
return value.strip()
elif isinstance(value, (list, tuple)):
# List items may be strings, dicts, or other lists/tuples
for item in value:
# NOTE: One difference from Vue is that if a class is given multiple times,
# and the last value is falsy, then it will be removed.
# E.g.
# `{"class": ["my-class", "extra-class", {"extra-class": False}]}`
# will result in `class="my-class"`
# while in Vue it will result in `class="my-class extra-class"`
normalized = _normalize_class(item)
res.update(normalized)
elif isinstance(value, dict):
# Take only those keys whose value is truthy. So
# `{"class": True, "extra": False}` will result in `class="extra"`
# while
# `{"class": True, "extra": True}` will result in `class="class extra"`
res = value
else:
raise ValueError(f"Invalid class value: {value}")
res_str = ""
for key, val in res.items():
if val:
res_str += key + " "
return res_str.strip()
whitespace_re = re.compile(r"\s+")
# Similar to `normalize_class`, but returns a dict instead of a string.
def _normalize_class(value: ClassValue) -> Dict[str, bool]:
res: Dict[str, bool] = {}
if isinstance(value, str):
class_parts = whitespace_re.split(value)
res.update({part: True for part in class_parts if part})
elif isinstance(value, (list, tuple)):
# List items may be strings, dicts, or other lists/tuples
for item in value:
normalized = _normalize_class(item)
res.update(normalized)
elif isinstance(value, dict):
res = value
else:
raise ValueError(f"Invalid class value: {value}")
return res
def normalize_style(value: StyleValue) -> str:
"""
Normalize a style value.
Style may be given as a string, a list, or a dictionary:
- If given as a string, it is parsed as an inline CSS style,
e.g. `"color: red; background-color: blue;"`.
- If given as a dictionary, it is assumed to be a dict of style properties,
e.g. `{"color": "red", "background-color": "blue"}`.
- If given as a list, each item may itself be a list, string, or a dict.
The items are converted to dicts and merged.
If a style property is given multiple times, the last value is used.
If, after merging, a style property has a literal `False` value, it is removed.
Properties with a value of `None` are ignored.
This is based on Vue's [`mergeProps` function](https://vuejs.org/api/render-function#mergeprops).
**Example:**
```python
normalize_style([
"color: red; background-color: blue; width: 100px;",
{"color": "green", "background-color": None, "width": False},
])
```
will result in
```python
"color: green; background-color: blue;"
```
Where:
- `color: green` overwrites `color: red`
- `background-color": None` is ignored, so `background-color: blue` is used
- `width` is omitted because it is given with a `False` value
"""
res: StyleDict = {}
if isinstance(value, str):
return value.strip()
elif isinstance(value, (list, tuple)):
# List items may be strings, dicts, or other lists/tuples
for item in value:
normalized = _normalize_style(item)
res.update(normalized)
elif isinstance(value, dict):
# Remove entries with `None` value
res = _normalize_style(value)
else:
raise ValueError(f"Invalid style value: {value}")
# By the time we get here, all `None` values have been removed.
# If the final dict has `None` or `False` values, they are removed, so those
# properties are not rendered.
res_parts = []
for key, val in res.items():
if val is not None and val is not False:
res_parts.append(f"{key}: {val};")
return " ".join(res_parts).strip()
def _normalize_style(value: StyleValue) -> StyleDict:
res: StyleDict = {}
if isinstance(value, str):
# Generate a dict of style properties from a string
normalized = parse_string_style(value)
res.update(normalized)
elif isinstance(value, (list, tuple)):
# List items may be strings, dicts, or other lists/tuples
for item in value:
normalized = _normalize_style(item)
res.update(normalized)
elif isinstance(value, dict):
# Skip assigning entries with `None` value
for key, val in value.items():
if val is not None:
res[key] = val
else:
raise ValueError(f"Invalid style value: {value}")
return res
# Match CSS comments `/* ... */`
style_comment_re = re.compile(r"/\*.*?\*/", re.DOTALL)
# Split CSS properties by semicolon, but not inside parentheses
list_delimiter_re = re.compile(r";(?![^(]*\))", re.DOTALL)
# Split CSS property name and value
property_delimiter_re = re.compile(r":(.+)", re.DOTALL)
def parse_string_style(css_text: str) -> StyleDict:
"""
Parse a string of CSS style properties into a dictionary.
**Example:**
```python
parse_string_style("color: red; background-color: blue; /* comment */")
```
will result in
```python
{"color": "red", "background-color": "blue"}
```
"""
# Remove comments
css_text = style_comment_re.sub("", css_text)
ret: StyleDict = {}
# Split by semicolon, but not inside parentheses
for item in list_delimiter_re.split(css_text):
if item:
parts = property_delimiter_re.split(item)
if len(parts) > 1:
ret[parts[0].strip()] = parts[1].strip()
return ret

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@
<a
href="https://example.com"
class="py-2 px-4 bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 transition inline-flex w-full text-sm font-semibold sm:mt-0 sm:w-auto focus-visible:outline-2 focus-visible:outline-offset-2 px-3 py-2 justify-center rounded-md shadow-sm no-underline"
class="py-2 px-4 bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 transition inline-flex w-full text-sm font-semibold sm:mt-0 sm:w-auto focus-visible:outline-2 focus-visible:outline-offset-2 px-3 justify-center rounded-md shadow-sm no-underline"
>

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@
<a
href="https://example.com"
class="py-2 px-4 bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 transition inline-flex w-full text-sm font-semibold sm:mt-0 sm:w-auto focus-visible:outline-2 focus-visible:outline-offset-2 px-3 py-2 justify-center rounded-md shadow-sm no-underline"
class="py-2 px-4 bg-blue-600 text-white hover:bg-blue-500 focus-visible:outline-blue-600 transition inline-flex w-full text-sm font-semibold sm:mt-0 sm:w-auto focus-visible:outline-2 focus-visible:outline-offset-2 px-3 justify-center rounded-md shadow-sm no-underline"
data-djc-id-a1bc3e="">

View file

@ -6,7 +6,7 @@ from django.utils.safestring import SafeString, mark_safe
from pytest_django.asserts import assertHTMLEqual
from django_components import Component, register, types
from django_components.attributes import append_attributes, attributes_to_string
from django_components.attributes import format_attributes, merge_attributes, parse_string_style
from django_components.testing import djc_test
from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config
@ -15,40 +15,114 @@ setup_test_config({"autodiscover": False})
@djc_test
class TestAttributesToString:
class TestFormatAttributes:
def test_simple_attribute(self):
assert attributes_to_string({"foo": "bar"}) == 'foo="bar"'
assert format_attributes({"foo": "bar"}) == 'foo="bar"'
def test_multiple_attributes(self):
assert attributes_to_string({"class": "foo", "style": "color: red;"}) == 'class="foo" style="color: red;"'
assert format_attributes({"class": "foo", "style": "color: red;"}) == 'class="foo" style="color: red;"'
def test_escapes_special_characters(self):
assert attributes_to_string({"x-on:click": "bar", "@click": "'baz'"}) == 'x-on:click="bar" @click="&#x27;baz&#x27;"' # noqa: E501
assert format_attributes({"x-on:click": "bar", "@click": "'baz'"}) == 'x-on:click="bar" @click="&#x27;baz&#x27;"' # noqa: E501
def test_does_not_escape_special_characters_if_safe_string(self):
assert attributes_to_string({"foo": mark_safe("'bar'")}) == "foo=\"'bar'\""
assert format_attributes({"foo": mark_safe("'bar'")}) == "foo=\"'bar'\""
def test_result_is_safe_string(self):
result = attributes_to_string({"foo": mark_safe("'bar'")})
result = format_attributes({"foo": mark_safe("'bar'")})
assert isinstance(result, SafeString)
def test_attribute_with_no_value(self):
assert attributes_to_string({"required": None}) == ""
assert format_attributes({"required": None}) == ""
def test_attribute_with_false_value(self):
assert attributes_to_string({"required": False}) == ""
assert format_attributes({"required": False}) == ""
def test_attribute_with_true_value(self):
assert attributes_to_string({"required": True}) == "required"
assert format_attributes({"required": True}) == "required"
@djc_test
class TestAppendAttributes:
class TestMergeAttributes:
def test_single_dict(self):
assert append_attributes(("foo", "bar")) == {"foo": "bar"}
assert merge_attributes({"foo": "bar"}) == {"foo": "bar"}
def test_appends_dicts(self):
assert append_attributes(("class", "foo"), ("id", "bar"), ("class", "baz")) == {"class": "foo baz", "id": "bar"} # noqa: E501
assert merge_attributes({"class": "foo", "id": "bar"}, {"class": "baz"}) == {
"class": "foo baz",
"id": "bar",
} # noqa: E501
def test_merge_with_empty_dict(self):
assert merge_attributes({}, {"foo": "bar"}) == {"foo": "bar"}
def test_merge_with_overlapping_keys(self):
assert merge_attributes({"foo": "bar"}, {"foo": "baz"}) == {"foo": "bar baz"}
def test_merge_classes(self):
assert merge_attributes(
{"class": "foo"},
{
"class": [
"bar",
"tuna",
"tuna2",
"tuna3",
{"baz": True, "baz2": False, "tuna": False, "tuna2": True, "tuna3": None},
["extra", {"extra2": False, "baz2": True, "tuna": True, "tuna2": False}],
]
},
) == {"class": "foo bar tuna baz baz2 extra"}
def test_merge_styles(self):
assert merge_attributes(
{"style": "color: red; width: 100px; height: 100px;"},
{
"style": [
"background-color: blue;",
{"background-color": "green", "color": None, "width": False},
["position: absolute", {"height": "12px"}],
]
},
) == {"style": "color: red; height: 12px; background-color: green; position: absolute;"}
def test_merge_with_none_values(self):
# Normal attributes merge even `None` values
assert merge_attributes({"foo": None}, {"foo": "bar"}) == {"foo": "None bar"}
assert merge_attributes({"foo": "bar"}, {"foo": None}) == {"foo": "bar None"}
# Classes append the class only if the last value is truthy
assert merge_attributes({"class": {"bar": None}}, {"class": {"bar": True}}) == {"class": "bar"}
assert merge_attributes({"class": {"bar": True}}, {"class": {"bar": None}}) == {"class": ""}
# Styles remove values that are `False` and ignore `None`
assert merge_attributes(
{"style": {"color": None}},
{"style": {"color": "blue"}},
) == {"style": "color: blue;"}
assert merge_attributes(
{"style": {"color": "blue"}},
{"style": {"color": None}},
) == {"style": "color: blue;"}
def test_merge_with_false_values(self):
# Normal attributes merge even `False` values
assert merge_attributes({"foo": False}, {"foo": "bar"}) == {"foo": "False bar"}
assert merge_attributes({"foo": "bar"}, {"foo": False}) == {"foo": "bar False"}
# Classes append the class only if the last value is truthy
assert merge_attributes({"class": {"bar": False}}, {"class": {"bar": True}}) == {"class": "bar"}
assert merge_attributes({"class": {"bar": True}}, {"class": {"bar": False}}) == {"class": ""}
# Styles remove values that are `False` and ignore `None`
assert merge_attributes(
{"style": {"color": False}},
{"style": {"color": "blue"}},
) == {"style": "color: blue;"}
assert merge_attributes(
{"style": {"color": "blue"}},
{"style": {"color": False}},
) == {"style": ""}
@djc_test
@ -417,3 +491,36 @@ class TestHtmlAttrs:
""",
)
assert "override-me" not in rendered
@djc_test
class TestParseStringStyle:
def test_single_style(self):
assert parse_string_style("color: red;") == {"color": "red"}
def test_multiple_styles(self):
assert parse_string_style("color: red; background-color: blue;") == {
"color": "red",
"background-color": "blue",
}
def test_with_comments(self):
assert parse_string_style("color: red /* comment */; background-color: blue;") == {
"color": "red",
"background-color": "blue",
}
def test_with_whitespace(self):
assert parse_string_style(" color: red; background-color: blue; ") == {
"color": "red",
"background-color": "blue",
}
def test_empty_string(self):
assert parse_string_style("") == {}
def test_no_delimiters(self):
assert parse_string_style("color: red background-color: blue") == {"color": "red background-color: blue"}
def test_incomplete_style(self):
assert parse_string_style("color: red; background-color") == {"color": "red"}

View file

@ -684,7 +684,7 @@ class TestSpreadOperator:
assertHTMLEqual(
rendered,
"""
<div test="hi" class="my-class button" style="height: 20px" lol="123">
<div test="hi" class="my-class button" style="height: 20px;" lol="123">
""",
)