Repository: joshuadavidthomas/django-bird

Issue:
Create `bird:prop` templatetag for components
Related to the roadmap item [Overriding Default Attributes](https://github.com/joshuadavidthomas/django-bird/blob/main/ROADMAP.md#overriding-default-attributes), I think creating a `bird:prop` templatetag is probably the easiest path to accomplish this.

In my mind, the functionality will be very similar to django-cotton's `<c-vars />` -- props defined are not included in the `attrs` template context. However, in addition to a different naming scheme that more aligns with "component" thinking (e.g. React props), I think the big difference will be instead of a single definition, as in `c-vars`, we should instead have each prop be a different declaration. For example:

```htmldjango
{# django-cotton #}
<c-vars class="bg-red-500" data-slot="foobar" />

{# django-bird #}
{% bird:prop class="bg-red-500" %}
{% bird:prop data-slot="foobar" %}
```

This could allow for adding additional functionality much easier, e.g. some sort of basic type checking, marking props as required/optional, including choices, etc...

Though, this does make the implementation a bit harder, as off the top of my head I'm not aware of a straightforward way to add to the template context from a templatetag that does not have a `nodelist`, aka a self-contained templatetag as above. I'd like to try to maintain this API though, and take the complexity on in the library rather than pushing it off on users, so surely there's a work-around in here somewhere.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f548db5..e31c6ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,10 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
 
 ## [Unreleased]
 
+### Added
+
+- Created `{% bird:prop %}` tag for defining properties within components. These operate similarly to the `{{ attrs }}` template context variable, but allow for setting defaults. Any attributes passed to a component will override the prop's default value, and props defined in a component template are automatically removed from the component's `attrs`. Props are accessible in templates via the `props` context variable (e.g. `{{ props.id }}`)
+
 ## [0.2.0]
 
 🚨 This release contains a breaking change. See the Changed section for more information. 🚨
diff --git a/docs/attrs.md b/docs/attrs.md
deleted file mode 100644
index e1b5ced..0000000
--- a/docs/attrs.md
+++ /dev/null
@@ -1,52 +0,0 @@
-# Attributes
-
-Attributes let you pass additional HTML attributes to your components. This feature provides flexibility and customization without modifying the component template.
-
-In your component template, the `{{ attrs }}` variable is a special variable that contains all the attributes passed to the component. It automatically handles both key-value attributes (like `class="btn"`) and boolean attributes (like `disabled`).
-
-## Basic Usage
-
-Here's a simple example of a button component that accepts attributes:
-
-```{code-block} htmldjango
-:caption: templates/bird/button.html
-
-<button {{ attrs }}>
-    {{ slot }}
-</button>
-```
-
-Use this component and pass attributes like this:
-
-```htmldjango
-{% bird button class="btn" %}
-    Click me!
-{% endbird %}
-```
-
-It will render as:
-
-```html
-<button class="btn">
-    Click me!
-</button>
-
-```
-
-## Multiple Attributes
-
-You can pass multiple attributes to a component:
-
-```htmldjango
-{% bird button class="btn btn-primary" id="submit-btn" disabled %}
-    Submit
-{% endbird %}
-```
-
-This will render as:
-
-```html
-<button class="btn btn-primary" id="submit-btn" disabled>
-    Submit
-</button>
-```
diff --git a/docs/index.md b/docs/index.md
index ffda223..7a4a1ee 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -12,7 +12,7 @@
 :hidden:
 :maxdepth: 3
 Naming <naming>
-attrs
+Attributes/Properties <params>
 slots
 Organization <organization>
 configuration
diff --git a/docs/params.md b/docs/params.md
new file mode 100644
index 0000000..44c7e41
--- /dev/null
+++ b/docs/params.md
@@ -0,0 +1,183 @@
+# Passing Parameters to Components
+
+django-bird provides two ways to pass parameters to your components: attributes and properties. While attributes and properties may look similar when using a component, they serve different purposes.
+
+Attributes are made available to your component template as a flattened string via the `{{ attrs }}` template context variable which can be used to apply HTML attributes to elements, while properties are accessible as individual values (e.g. `{{ props.variant }}`) that your component can use internally to control its rendering logic.
+
+Note that these parameters are distinct from [Slots](slots.md) - they are used to configured how your component behaves or renders, while slots define where content should be inserted into your component's template.
+
+For example, a button component might use properties to control its styling and attributes to set HTML attributes, while using slots to define its content:
+
+```htmldjango
+{% bird button variant="primary" data-analytics="signup" %}
+    Click here  {# This content will go in the default slot #}
+{% endbird %}
+```
+
+## Attributes
+
+Attributes (i.e. `attrs`) let you pass additional HTML attributes to your components. This feature provides flexibility and customization without modifying the component template.
+
+In your component template, the `{{ attrs }}` variable is a special variable that contains all the attributes passed to the component as a pre-rendered string. Unlike props which can be accessed individually, attributes are flattened into a single string ready to be inserted into an HTML element. The `{{ attrs }}` variable automatically handles both key-value attributes (like `class="btn"`) and boolean attributes (like `disabled`).
+
+### Basic Usage
+
+Here's a simple example of a button component that accepts attributes:
+
+```{code-block} htmldjango
+:caption: templates/bird/button.html
+
+<button {{ attrs }}>
+    {{ slot }}
+</button>
+```
+
+Use this component and pass attributes like this:
+
+```htmldjango
+{% bird button class="btn" %}
+    Click me!
+{% endbird %}
+```
+
+It will render as:
+
+```html
+<button class="btn">
+    Click me!
+</button>
+
+```
+
+### Multiple Attributes
+
+You can pass multiple attributes to a component:
+
+```htmldjango
+{% bird button class="btn btn-primary" id="submit-btn" disabled %}
+    Submit
+{% endbird %}
+```
+
+This will render as:
+
+```html
+<button class="btn btn-primary" id="submit-btn" disabled>
+    Submit
+</button>
+```
+
+## Properties
+
+Properties (i.e. `props`) allow you to define parameters that your component expects, with optional default values. Unlike attributes which are provided as a flattened string via `{{ attrs }}`, props are processed by the component and made available as individual values (e.g. `{{ props.variant }}`) that can be used to control rendering logic.
+
+In your component template, props are defined using the `{% bird:prop %}` tag and accessed via the `{{ props }}` context variable. You can define as many props as needed using separate `{% bird:prop %}` tags. When a prop is defined, any matching attribute passed to the component will be removed from `{{ attrs }}` and made available in `{{ props }}` instead.
+
+### Basic Usage
+
+Here's a simple example of a button component that uses props:
+
+```{code-block} htmldjango
+:caption: templates/bird/button.html
+
+{% bird:prop variant='primary' %}
+<button class="btn btn-{{ props.variant }}" {{ attrs }}>
+    {{ slot }}
+</button>
+```
+
+Use this component and override the default variant like this:
+
+```htmldjango
+{% bird button variant="secondary" id="secondary-button" %}
+    Click me!
+{% endbird %}
+```
+
+It will render as:
+
+```html
+<button class="btn btn-secondary" id="secondary-button">
+    Click me!
+</button>
+```
+
+Notice how this works:
+
+- The `variant` attribute is removed from `attrs` because it matches a defined prop
+- Its value "secondary" is made available as `props.variant`
+- The `id` attribute remains in `attrs` since it's not defined as a prop
+- The final HTML only includes `variant`'s value as part of the class name, while `id` appears as a direct attribute
+
+This separation allows you to use props to control your component's logic while still accepting arbitrary HTML attributes.
+
+### Multiple Props
+
+Components often need multiple props to control different aspects of their behavior. Each prop is defined with its own `{% bird:prop %}` tag:
+
+```{code-block} htmldjango
+:caption: templates/bird/button.html
+
+{% bird:prop variant='primary' %}
+{% bird:prop size='md' %}
+<button
+    class="btn btn-{{ props.variant }} btn-{{ props.size }}"
+    {{ attrs }}
+>
+    {{ slot }}
+</button>
+```
+
+Use the component by setting any combination of these props:
+
+```htmldjango
+{% bird button variant="secondary" size="lg" disabled=True %}
+    Click me!
+{% endbird %}
+```
+
+It will render as:
+
+```html
+<button class="btn btn-secondary btn-lg" disabled>
+    Click me!
+</button>
+```
+
+This approach of using separate tags for each prop makes it easier to expand the prop system in the future - for example, adding features like type validation or choice constraints while maintaining a clean syntax.
+
+### Props with Defaults
+
+Props can be defined with or without default values:
+
+```htmldjango
+{% bird:prop id %}                 {#  No default value  #}
+{% bird:prop variant='primary' %}  {# With default value #}
+<button
+    id="{{ props.id }}"
+    class="btn btn-{{ props.variant }}"
+    {{ attrs }}
+>
+    {{ slot }}
+</button>
+```
+
+When used, props will take their value from either the passed attribute or fall back to their default:
+
+```htmldjango
+{% bird button variant="secondary" %}
+    Submit
+{% endbird %}
+```
+
+This will render as:
+
+```html
+<button id="" class="btn btn-secondary">
+    Submit
+</button>
+```
+
+```{note}
+Props defined without a default value will render as an empty string if no value is provided when using the component. This behavior may change in a future version to either require default values or handle undefined props differently.
+```
diff --git a/src/django_bird/components/params.py b/src/django_bird/components/params.py
index 921ac4d..57e35c1 100644
--- a/src/django_bird/components/params.py
+++ b/src/django_bird/components/params.py
@@ -1,8 +1,10 @@
 from __future__ import annotations
 
 from dataclasses import dataclass
+from dataclasses import field
 
 from django import template
+from django.template.base import NodeList
 from django.template.context import Context
 from django.utils.safestring import SafeString
 from django.utils.safestring import mark_safe
@@ -39,7 +41,35 @@ class Param:
 
 @dataclass
 class Params:
-    attrs: list[Param]
+    attrs: list[Param] = field(default_factory=list)
+    props: list[Param] = field(default_factory=list)
+
+    def render_props(self, nodelist: NodeList | None, context: Context):
+        from django_bird.templatetags.tags.prop import PropNode
+
+        if nodelist is None:
+            return
+
+        attrs_to_remove = set()
+
+        for node in nodelist:
+            if not isinstance(node, PropNode):
+                continue
+
+            value: str | bool | None = node.default
+
+            for idx, attr in enumerate(self.attrs):
+                if node.name == attr.name:
+                    if attr.value:
+                        value = attr.value
+                    attrs_to_remove.add(idx)
+
+            self.props.append(Param(name=node.name, value=value))
+
+        for idx in sorted(attrs_to_remove, reverse=True):
+            self.attrs.pop(idx)
+
+        return {prop.name: prop.value for prop in self.props}
 
     def render_attrs(self, context: Context) -> SafeString:
         rendered = " ".join(attr.render(context) for attr in self.attrs)
diff --git a/src/django_bird/templatetags/django_bird.py b/src/django_bird/templatetags/django_bird.py
index f855c3b..678694f 100644
--- a/src/django_bird/templatetags/django_bird.py
+++ b/src/django_bird/templatetags/django_bird.py
@@ -3,10 +3,12 @@ from __future__ import annotations
 from django import template
 
 from .tags import bird
+from .tags import prop
 from .tags import slot
 
 register = template.Library()
 
 
 register.tag(bird.TAG, bird.do_bird)
+register.tag(prop.TAG, prop.do_prop)
 register.tag(slot.TAG, slot.do_slot)
diff --git a/src/django_bird/templatetags/tags/__init__.py b/src/django_bird/templatetags/tags/__init__.py
index f917576..e69de29 100644
--- a/src/django_bird/templatetags/tags/__init__.py
+++ b/src/django_bird/templatetags/tags/__init__.py
@@ -1,9 +0,0 @@
-from __future__ import annotations
-
-from .bird import do_bird
-from .slot import do_slot
-
-__all__ = [
-    "do_bird",
-    "do_slot",
-]
diff --git a/src/django_bird/templatetags/tags/bird.py b/src/django_bird/templatetags/tags/bird.py
index bc1d88a..8f2793e 100644
--- a/src/django_bird/templatetags/tags/bird.py
+++ b/src/django_bird/templatetags/tags/bird.py
@@ -6,6 +6,7 @@ from typing import Any
 from django import template
 from django.template.base import NodeList
 from django.template.base import Parser
+from django.template.base import Template
 from django.template.base import Token
 from django.template.context import Context
 from django.template.loader import select_template
@@ -61,9 +62,9 @@ class BirdNode(template.Node):
     @override
     def render(self, context: Context) -> SafeString:
         component_name = self.get_component_name(context)
-        component_context = self.get_component_context_data(context)
         template_names = get_template_names(component_name)
         template = select_template(template_names)
+        component_context = self.get_component_context_data(template.template, context)
         return template.render(component_context)
 
     def get_component_name(self, context: Context) -> str:
@@ -73,12 +74,16 @@ class BirdNode(template.Node):
             name = self.name
         return name
 
-    def get_component_context_data(self, context: Context) -> dict[str, Any]:
+    def get_component_context_data(
+        self, template: Template, context: Context
+    ) -> dict[str, Any]:
+        props = self.params.render_props(template.nodelist, context)
         attrs = self.params.render_attrs(context)
         slots = Slots.collect(self.nodelist, context).render()
         default_slot = slots.get(DEFAULT_SLOT) or context.get("slot")
         return {
             "attrs": attrs,
+            "props": props,
             "slot": default_slot,
             "slots": slots,
         }
diff --git a/src/django_bird/templatetags/tags/prop.py b/src/django_bird/templatetags/tags/prop.py
new file mode 100644
index 0000000..a2acaf8
--- /dev/null
+++ b/src/django_bird/templatetags/tags/prop.py
@@ -0,0 +1,46 @@
+# pyright: reportAny=false
+from __future__ import annotations
+
+from django import template
+from django.template.base import Parser
+from django.template.base import Token
+from django.template.context import Context
+
+from django_bird._typing import TagBits
+from django_bird._typing import override
+
+TAG = "bird:prop"
+
+
+def do_prop(parser: Parser, token: Token) -> PropNode:
+    bits = token.split_contents()
+    name, default = parse_prop_name(bits)
+    attrs = parse_attrs(bits)
+    return PropNode(name, default, attrs)
+
+
+def parse_prop_name(bits: TagBits) -> tuple[str, str | None]:
+    if len(bits) <= 1:
+        msg = f"{TAG} tag requires at least one argument"
+        raise template.TemplateSyntaxError(msg)
+
+    try:
+        name, default = bits[1].split("=")
+        return name, default.strip("'\"")
+    except ValueError:
+        return bits[1], None
+
+
+def parse_attrs(bits: TagBits) -> TagBits:
+    return bits[2:]
+
+
+class PropNode(template.Node):
+    def __init__(self, name: str, default: str | None, attrs: TagBits):
+        self.name = name
+        self.default = default
+        self.attrs = attrs
+
+    @override
+    def render(self, context: Context) -> str:
+        return ""


Test patch:
diff --git a/tests/components/test_params.py b/tests/components/test_params.py
index ba03344..f995911 100644
--- a/tests/components/test_params.py
+++ b/tests/components/test_params.py
@@ -4,6 +4,7 @@ import pytest
 
 from django_bird.components.params import Param
 from django_bird.components.params import Params
+from django_bird.templatetags.tags.prop import PropNode
 
 
 class TestParam:
@@ -32,6 +33,38 @@ class TestParam:
 
 
 class TestParams:
+    @pytest.mark.parametrize(
+        "params,nodelist,context,expected_props,expected_attrs",
+        [
+            (
+                Params(attrs=[Param(name="class", value=None)]),
+                [PropNode(name="class", default="btn", attrs=[])],
+                {},
+                {"class": "btn"},
+                [],
+            ),
+            (
+                Params(attrs=[Param(name="class", value="btn")]),
+                [PropNode(name="class", default=None, attrs=[])],
+                {},
+                {"class": "btn"},
+                [],
+            ),
+            (
+                Params(attrs=[Param(name="class", value="btn")]),
+                None,
+                {},
+                None,
+                [Param(name="class", value="btn")],
+            ),
+        ],
+    )
+    def test_render_props(
+        self, params, nodelist, context, expected_props, expected_attrs
+    ):
+        assert params.render_props(nodelist, context) == expected_props
+        assert params.attrs == expected_attrs
+
     @pytest.mark.parametrize(
         "params,context,expected",
         [
diff --git a/tests/templatetags/test_bird.py b/tests/templatetags/test_bird.py
index 8fb5076..bb69b7d 100644
--- a/tests/templatetags/test_bird.py
+++ b/tests/templatetags/test_bird.py
@@ -254,6 +254,49 @@ class TestTemplateTag:
             rendered = t.render(context=Context(context))
             assert normalize_whitespace(rendered) == expected
 
+    @pytest.mark.parametrize(
+        "component,template,context,expected",
+        [
+            (
+                "{% bird:prop id %}<button id='{{ props.id }}'>{{ slot }}</button>",
+                "{% bird button id='foo' %}Click me{% endbird %}",
+                {},
+                "<button id='foo'>Click me</button>",
+            ),
+            (
+                "{% bird:prop id='default' %}<button id='{{ props.id }}'>{{ slot }}</button>",
+                "{% bird button %}Click me{% endbird %}",
+                {},
+                "<button id='default'>Click me</button>",
+            ),
+            (
+                "{% bird:prop id='default' %}<button id='{{ props.id }}'>{{ slot }}</button>",
+                "{% bird button id='foo' %}Click me{% endbird %}",
+                {},
+                "<button id='foo'>Click me</button>",
+            ),
+            (
+                '{% bird:prop id="default" %}{% bird:prop class="btn" %}<button id="{{ props.id }}" class="{{ props.class }}" {{ attrs }}>{{ slot }}</button>',
+                "{% bird button data-test='value' %}Click me{% endbird %}",
+                {},
+                '<button id="default" class="btn" data-test="value">Click me</button>',
+            ),
+        ],
+    )
+    def test_with_props(
+        self,
+        component,
+        template,
+        context,
+        expected,
+        create_bird_template,
+        normalize_whitespace,
+    ):
+        create_bird_template("button", component)
+        t = Template(template)
+        rendered = t.render(context=Context(context))
+        assert normalize_whitespace(rendered) == expected
+
 
 @pytest.mark.xfail(reason="Feature not implemented yet")
 class TestTemplateTagFutureFeatures:
diff --git a/tests/templatetags/test_prop.py b/tests/templatetags/test_prop.py
new file mode 100644
index 0000000..936bc43
--- /dev/null
+++ b/tests/templatetags/test_prop.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+import pytest
+from django.template.exceptions import TemplateSyntaxError
+
+from django_bird.templatetags.tags.prop import parse_prop_name
+
+
+@pytest.mark.parametrize(
+    "bits,expected",
+    [
+        (["bird:prop", "id"], ("id", None)),
+        (["bird:prop", "class='btn'", "foo"], ("class", "btn")),
+    ],
+)
+def test_parse_prop_name(bits, expected):
+    assert parse_prop_name(bits) == expected
+
+
+def test_parse_prop_name_no_args():
+    with pytest.raises(TemplateSyntaxError):
+        parse_prop_name([])