mirror of
https://github.com/django-components/django-components.git
synced 2025-09-22 13:42:27 +00:00
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:
parent
1e71f3d656
commit
328309a81c
15 changed files with 1361 additions and 488 deletions
23
CHANGELOG.md
23
CHANGELOG.md
|
@ -1,5 +1,28 @@
|
||||||
# Release notes
|
# 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
|
## v0.134
|
||||||
|
|
||||||
#### Fix
|
#### Fix
|
||||||
|
|
47
README.md
47
README.md
|
@ -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
|
### 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.
|
- 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
|
### Other features
|
||||||
|
|
||||||
- Vue-like provide / inject system
|
- Vue-like provide / inject system
|
||||||
- Format HTML attributes with `{% html_attrs %}`
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|
|
@ -1,37 +1,183 @@
|
||||||
_New in version 0.74_:
|
_New in version 0.74_:
|
||||||
|
|
||||||
You can use the `html_attrs` tag to render HTML attributes, given a dictionary
|
You can use the [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag to render various data
|
||||||
of values.
|
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
|
```django
|
||||||
<div class="{{ classes }}" data-id="{{ my_id }}">
|
<div class="{{ classes }}" data-id="{{ my_id }}">
|
||||||
</div>
|
</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
|
```django
|
||||||
<div {% html_attrs attrs %}>
|
<div {% html_attrs attrs %}>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
where `attrs` is:
|
Then this renders:
|
||||||
|
|
||||||
```py
|
```html
|
||||||
attrs = {
|
<div disabled></div>
|
||||||
"class": classes,
|
|
||||||
"data-id": my_id,
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
### Removing attributes
|
||||||
["fallthrough attributes" feature of Vue](https://vuejs.org/guide/components/attrs).
|
|
||||||
|
|
||||||
## Removing atttributes
|
Given how the boolean attributes work, you can "remove" or prevent an attribute from being rendered by setting it to `False` or `None`.
|
||||||
|
|
||||||
Attributes that are set to `None` or `False` are NOT rendered.
|
|
||||||
|
|
||||||
So given this input:
|
So given this input:
|
||||||
|
|
||||||
|
@ -56,41 +202,9 @@ Then this renders:
|
||||||
<div class="text-green"></div>
|
<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:
|
Sometimes you may want to specify default values for attributes. You can pass a second positional argument to set the defaults.
|
||||||
|
|
||||||
```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.
|
|
||||||
|
|
||||||
```django
|
```django
|
||||||
<div {% html_attrs attrs defaults %}>
|
<div {% html_attrs attrs defaults %}>
|
||||||
|
@ -98,20 +212,30 @@ Sometimes you may want to specify default values for attributes. You can pass a
|
||||||
</div>
|
</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,
|
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 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
|
We can achieve this by adding extra kwargs. These values
|
||||||
will be appended, instead of overwriting the previous value.
|
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
|
```django
|
||||||
<div {% html_attrs attrs class="some-class" %}>
|
<div {% html_attrs attrs class="some-class" %}>
|
||||||
|
@ -153,23 +277,179 @@ Renders:
|
||||||
></div>
|
></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:
|
Assuming that:
|
||||||
|
|
||||||
|
@ -189,67 +469,127 @@ defaults = {
|
||||||
|
|
||||||
Then:
|
Then:
|
||||||
|
|
||||||
- Empty tag <br/>
|
- **Empty tag**
|
||||||
`{% html_attr %}`
|
|
||||||
|
```django
|
||||||
|
<div {% html_attr %}></div>
|
||||||
|
```
|
||||||
|
|
||||||
renders (empty string): <br/>
|
renders nothing:
|
||||||
` `
|
|
||||||
|
|
||||||
- Only kwargs <br/>
|
```html
|
||||||
`{% html_attr class="some-class" class=class_from_var data-id="123" %}`
|
<div></div>
|
||||||
|
```
|
||||||
|
|
||||||
renders: <br/>
|
- **Only kwargs**
|
||||||
`class="some-class from-var" data-id="123"`
|
|
||||||
|
```django
|
||||||
|
<div {% html_attr class="some-class" class=class_from_var data-id="123" %}></div>
|
||||||
|
```
|
||||||
|
|
||||||
- Only attrs <br/>
|
renders:
|
||||||
`{% html_attr attrs %}`
|
|
||||||
|
|
||||||
renders: <br/>
|
```html
|
||||||
`class="from-attrs" type="submit"`
|
<div class="some-class from-var" data-id="123"></div>
|
||||||
|
```
|
||||||
|
|
||||||
- Attrs as kwarg <br/>
|
- **Only attrs**
|
||||||
`{% html_attr attrs=attrs %}`
|
|
||||||
|
|
||||||
renders: <br/>
|
```django
|
||||||
`class="from-attrs" type="submit"`
|
<div {% html_attr attrs %}></div>
|
||||||
|
```
|
||||||
|
|
||||||
- Only defaults (as kwarg) <br/>
|
renders:
|
||||||
`{% html_attr defaults=defaults %}`
|
|
||||||
|
|
||||||
renders: <br/>
|
```html
|
||||||
`class="from-defaults" role="button"`
|
<div class="from-attrs" type="submit"></div>
|
||||||
|
```
|
||||||
|
|
||||||
- Attrs using the `prefix:key=value` construct <br/>
|
- **Attrs as kwarg**
|
||||||
`{% html_attr attrs:class="from-attrs" attrs:type="submit" %}`
|
|
||||||
|
|
||||||
renders: <br/>
|
```django
|
||||||
`class="from-attrs" type="submit"`
|
<div {% html_attr attrs=attrs %}></div>
|
||||||
|
```
|
||||||
|
|
||||||
- Defaults using the `prefix:key=value` construct <br/>
|
renders:
|
||||||
`{% html_attr defaults:class="from-defaults" %}`
|
|
||||||
|
|
||||||
renders: <br/>
|
```html
|
||||||
`class="from-defaults" role="button"`
|
<div class="from-attrs" type="submit"></div>
|
||||||
|
```
|
||||||
|
|
||||||
- All together (1) - attrs and defaults as positional args: <br/>
|
- **Only defaults (as kwarg)**
|
||||||
`{% html_attrs attrs defaults class="added_class" class=class_from_var data-id=123 %}`
|
|
||||||
|
|
||||||
renders: <br/>
|
```django
|
||||||
`class="from-attrs added_class from-var" type="submit" role="button" data-id=123`
|
<div {% html_attr defaults=defaults %}></div>
|
||||||
|
```
|
||||||
|
|
||||||
- All together (2) - attrs and defaults as kwargs args: <br/>
|
renders:
|
||||||
`{% html_attrs class="added_class" class=class_from_var data-id=123 attrs=attrs defaults=defaults %}`
|
|
||||||
|
|
||||||
renders: <br/>
|
```html
|
||||||
`class="from-attrs added_class from-var" type="submit" role="button" data-id=123`
|
<div class="from-defaults" role="button"></div>
|
||||||
|
```
|
||||||
|
|
||||||
- All together (3) - mixed: <br/>
|
- **Attrs using the `prefix:key=value` construct**
|
||||||
`{% html_attrs attrs defaults:class="default-class" class="added_class" class=class_from_var data-id=123 %}`
|
|
||||||
|
|
||||||
renders: <br/>
|
```django
|
||||||
`class="from-attrs added_class from-var" type="submit" data-id=123`
|
<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
|
```djc_py
|
||||||
@register("my_comp")
|
@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
|
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.
|
So if `attrs` includes key `class`, the default above will be ignored.
|
||||||
|
|
||||||
|
@ -352,22 +694,3 @@ So in the end `MyComp` will render:
|
||||||
...
|
...
|
||||||
</div>
|
</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'
|
|
||||||
```
|
|
||||||
|
|
|
@ -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
|
### 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.
|
- 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
|
### Other features
|
||||||
|
|
||||||
- Vue-like provide / inject system
|
- Vue-like provide / inject system
|
||||||
- Format HTML attributes with `{% html_attrs %}`
|
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
|
|
|
@ -171,6 +171,10 @@
|
||||||
options:
|
options:
|
||||||
show_if_no_docstring: true
|
show_if_no_docstring: true
|
||||||
|
|
||||||
|
::: django_components.format_attributes
|
||||||
|
options:
|
||||||
|
show_if_no_docstring: true
|
||||||
|
|
||||||
::: django_components.get_component_dirs
|
::: django_components.get_component_dirs
|
||||||
options:
|
options:
|
||||||
show_if_no_docstring: true
|
show_if_no_docstring: true
|
||||||
|
@ -183,6 +187,10 @@
|
||||||
options:
|
options:
|
||||||
show_if_no_docstring: true
|
show_if_no_docstring: true
|
||||||
|
|
||||||
|
::: django_components.merge_attributes
|
||||||
|
options:
|
||||||
|
show_if_no_docstring: true
|
||||||
|
|
||||||
::: django_components.register
|
::: django_components.register
|
||||||
options:
|
options:
|
||||||
show_if_no_docstring: true
|
show_if_no_docstring: true
|
||||||
|
@ -198,3 +206,4 @@
|
||||||
::: django_components.template_tag
|
::: django_components.template_tag
|
||||||
options:
|
options:
|
||||||
show_if_no_docstring: true
|
show_if_no_docstring: true
|
||||||
|
|
||||||
|
|
|
@ -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`
|
## `upgradecomponent`
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
usage: upgradecomponent [-h] [--path PATH] [--version] [-v {0,1,2,3}] [--settings SETTINGS]
|
usage: upgradecomponent [-h] [--path PATH] [--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color]
|
||||||
[--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color] [--skip-checks]
|
[--skip-checks]
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -506,9 +506,8 @@ Deprecated. Use `components upgrade` instead.
|
||||||
## `startcomponent`
|
## `startcomponent`
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
usage: startcomponent [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force] [--verbose]
|
usage: startcomponent [-h] [--path PATH] [--js JS] [--css CSS] [--template TEMPLATE] [--force] [--verbose] [--dry-run] [--version] [-v {0,1,2,3}]
|
||||||
[--dry-run] [--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH]
|
[--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color] [--skip-checks]
|
||||||
[--traceback] [--no-color] [--force-color] [--skip-checks]
|
|
||||||
name
|
name
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
# NOTE: Some of the documentation is generated based on these exports
|
# NOTE: Some of the documentation is generated based on these exports
|
||||||
# isort: off
|
# isort: off
|
||||||
from django_components.app_settings import ContextBehavior, ComponentsSettings
|
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.autodiscovery import autodiscover, import_libraries
|
||||||
from django_components.util.command import (
|
from django_components.util.command import (
|
||||||
CommandArg,
|
CommandArg,
|
||||||
|
@ -90,9 +91,11 @@ __all__ = [
|
||||||
"DynamicComponent",
|
"DynamicComponent",
|
||||||
"EmptyTuple",
|
"EmptyTuple",
|
||||||
"EmptyDict",
|
"EmptyDict",
|
||||||
|
"format_attributes",
|
||||||
"get_component_dirs",
|
"get_component_dirs",
|
||||||
"get_component_files",
|
"get_component_files",
|
||||||
"import_libraries",
|
"import_libraries",
|
||||||
|
"merge_attributes",
|
||||||
"NotRegistered",
|
"NotRegistered",
|
||||||
"OnComponentClassCreatedContext",
|
"OnComponentClassCreatedContext",
|
||||||
"OnComponentClassDeletedContext",
|
"OnComponentClassDeletedContext",
|
||||||
|
|
|
@ -2,7 +2,8 @@
|
||||||
# See https://github.com/Xzya/django-web-components/blob/b43eb0c832837db939a6f8c1980334b0adfdd6e4/django_web_components/templatetags/components.py # noqa: E501
|
# 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
|
# 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.template import Context
|
||||||
from django.utils.html import conditional_escape, format_html
|
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
|
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):
|
class HtmlAttrsNode(BaseNode):
|
||||||
"""
|
"""
|
||||||
|
@ -79,14 +84,30 @@ class HtmlAttrsNode(BaseNode):
|
||||||
final_attrs = {}
|
final_attrs = {}
|
||||||
final_attrs.update(defaults or {})
|
final_attrs.update(defaults or {})
|
||||||
final_attrs.update(attrs 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
|
# Render to HTML attributes
|
||||||
return attributes_to_string(final_attrs)
|
return format_attributes(final_attrs)
|
||||||
|
|
||||||
|
|
||||||
def attributes_to_string(attributes: Mapping[str, Any]) -> str:
|
def format_attributes(attributes: Mapping[str, Any]) -> str:
|
||||||
"""Convert a dict of attributes to a string."""
|
"""
|
||||||
|
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 = []
|
attr_list = []
|
||||||
|
|
||||||
for key, value in attributes.items():
|
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))
|
return mark_safe(SafeString(" ").join(attr_list))
|
||||||
|
|
||||||
|
|
||||||
def append_attributes(*args: Tuple[str, Any]) -> Dict:
|
# TODO_V1 - Remove in v1, keep only `format_attributes` going forward
|
||||||
"""
|
attributes_to_string = format_attributes
|
||||||
Merges the key-value pairs and returns a new dictionary.
|
"""
|
||||||
|
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 = {}
|
result: Dict = {}
|
||||||
|
|
||||||
for key, value in args:
|
classes: List[ClassValue] = []
|
||||||
if key in result:
|
styles: List[StyleValue] = []
|
||||||
result[key] += " " + value
|
for attrs_dict in attrs:
|
||||||
else:
|
for key, value in attrs_dict.items():
|
||||||
result[key] = value
|
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
|
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
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="https://example.com"
|
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
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="https://example.com"
|
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="">
|
data-djc-id-a1bc3e="">
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ from django.utils.safestring import SafeString, mark_safe
|
||||||
from pytest_django.asserts import assertHTMLEqual
|
from pytest_django.asserts import assertHTMLEqual
|
||||||
|
|
||||||
from django_components import Component, register, types
|
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 django_components.testing import djc_test
|
||||||
|
|
||||||
from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config
|
from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config
|
||||||
|
@ -15,40 +15,114 @@ setup_test_config({"autodiscover": False})
|
||||||
|
|
||||||
|
|
||||||
@djc_test
|
@djc_test
|
||||||
class TestAttributesToString:
|
class TestFormatAttributes:
|
||||||
def test_simple_attribute(self):
|
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):
|
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):
|
def test_escapes_special_characters(self):
|
||||||
assert attributes_to_string({"x-on:click": "bar", "@click": "'baz'"}) == 'x-on:click="bar" @click="'baz'"' # noqa: E501
|
assert format_attributes({"x-on:click": "bar", "@click": "'baz'"}) == 'x-on:click="bar" @click="'baz'"' # noqa: E501
|
||||||
|
|
||||||
def test_does_not_escape_special_characters_if_safe_string(self):
|
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):
|
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)
|
assert isinstance(result, SafeString)
|
||||||
|
|
||||||
def test_attribute_with_no_value(self):
|
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):
|
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):
|
def test_attribute_with_true_value(self):
|
||||||
assert attributes_to_string({"required": True}) == "required"
|
assert format_attributes({"required": True}) == "required"
|
||||||
|
|
||||||
|
|
||||||
@djc_test
|
@djc_test
|
||||||
class TestAppendAttributes:
|
class TestMergeAttributes:
|
||||||
def test_single_dict(self):
|
def test_single_dict(self):
|
||||||
assert append_attributes(("foo", "bar")) == {"foo": "bar"}
|
assert merge_attributes({"foo": "bar"}) == {"foo": "bar"}
|
||||||
|
|
||||||
def test_appends_dicts(self):
|
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
|
@djc_test
|
||||||
|
@ -417,3 +491,36 @@ class TestHtmlAttrs:
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
assert "override-me" not in rendered
|
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"}
|
||||||
|
|
|
@ -684,7 +684,7 @@ class TestSpreadOperator:
|
||||||
assertHTMLEqual(
|
assertHTMLEqual(
|
||||||
rendered,
|
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">
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue