docs: add Form and Tabs examples (#1411)

This commit is contained in:
Juro Oravec 2025-09-29 15:58:47 +02:00 committed by GitHub
parent b3ea50572d
commit 9afc89ead1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1237 additions and 7 deletions

View file

@ -1,3 +1,6 @@
# `.nav.yml` is provided by https://lukasgeiter.github.io/mkdocs-awesome-nav
nav:
- Examples: overview.md
- index.md
- Components:
- ./form.md
- ./tabs.md

14
docs/examples/README.md Normal file
View file

@ -0,0 +1,14 @@
# Adding examples
Rule of thumb:
If the example is a 3rd party package or it lives under a different URL, only link to it in `overview.md`.
If the example is file(s) that we wrote:
1. Define the component in `sampleproject/examples/components/<component_name>/<component_name>.py`.
2. Define a view / page component in `sampleproject/examples/pages/<component_name>/<component_name>.py`.
3. Add a new page here in the documentation named `<component_name>.md`, similarly to [Tabs](./alpine/tabs.md).
4. Link to that new page from `index.md`.
5. Update `.nav.yml` if needed.
6. Add a corresponding test file in `tests/test_example_<component_name>.py`.

136
docs/examples/form.md Normal file
View file

@ -0,0 +1,136 @@
# Form
A `Form` component that automatically generates labels and arranges fields in a grid. It simplifies form creation by handling the layout for you.
![Form example](./images/form.png)
To get started, use the following example to create a simple form with 2 fields - `project` and `option`:
```django
{% component "form" %}
{% fill "field:project" %}
<input name="project" required>
{% endfill %}
{% fill "field:option" %}
<select name="option" required>
<option value="1">Option 1</option>
<option value="2">Option 2</option>
<option value="3">Option 3</option>
</select>
{% endfill %}
{% endcomponent %}
```
This will render a `<form>` where fields are defined using `field:<field_name>` slots.
Labels are automatically generated from the field name. If you want to define a custom label for a field,
you can use the `label:<field_name>` slot.
```django
{% component "form" %}
{# Custom label for "description" field #}
{% fill "label:description" %}
{% component "form_label"
field_name="description"
title="Marvelous description"
/ %}
{% endfill %}
{% fill "field:description" %}
<textarea name="description" required></textarea>
{% endfill %}
{% endcomponent %}
```
Whether you define custom labels or not, the form will have the following structure:
![Form structure](./images/form_structure.png)
## API
### `Form` component
The `Form` component is the main container for your form fields. It accepts the following arguments:
- **`editable`** (optional, default `True`): A boolean that determines if the form is editable.
- **`method`** (optional, default `"post"`): The HTTP method for the form submission.
- **`form_content_attrs`** (optional): A dictionary of HTML attributes to be added to the form's content container.
- **`attrs`** (optional): A dictionary of HTML attributes to be added to the `<form>` element itself.
To define the fields, you define a slot for each field.
**Slots:**
- **`field:<field_name>`**: Use this slot to define a form field. The component will automatically generate a label for it based on `<field_name>`.
- **`label:<field_name>`**: If you need a custom label for a field, you can define it using this slot.
- **`prepend`**: Content in this slot will be placed at the beginning of the form, before the main fields.
- **`append`**: Content in this slot will be placed at the end of the form, after the main fields. This is a good place for submit buttons.
### `FormLabel` component
When `Form` component automatically generates labels for fields, it uses the `FormLabel` component.
When you need a custom label for a field, you can use the `FormLabel` component explicitly in `label:<field_name>` slots.
The `FormLabel` component accepts the following arguments:
- **`field_name`** (required): The name of the field that this label is for. This will be used as the `for` attribute of the label.
- **`title`** (optional): Custom text for the label. If not provided, the component will automatically generate a title from the `field_name` by replacing underscores and hyphens with spaces and applying title case.
**Example:**
```django
{% component "form_label"
field_name="user_name"
title="Your Name"
/ %}
```
This will render:
```html
<label for="user_name" class="font-semibold text-gray-700">
Your Name
</label>
```
If `title` is not provided, `field_name="user_name"` would automatically generate the title "User Name",
converting snake_case to "Title Case".
## Example
To see the component in action, you can set up a view and a URL pattern as shown below.
### `views.py`
```djc_py
--8<-- "sampleproject/examples/pages/form.py"
```
### `urls.py`
```python
from django.urls import path
from examples.pages.form import FormPage
urlpatterns = [
path("examples/form", FormPage.as_view(), name="form"),
]
```
## Definition
### `form.py`
```djc_py
--8<-- "sampleproject/examples/components/form/form.py"
```
### `form.html`
```django
{% load component_tags %}
--8<-- "sampleproject/examples/components/form/form.html"
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

View file

@ -1,14 +1,20 @@
# Overview
`django-components` makes it easy to share components between projects
([See how to package components](../concepts/advanced/component_libraries.md)).
Here you will find public examples of components and component libraries.
If you have components that would be useful to others, open a pull request to add them to this collection.
If you have components that would be useful to others, open a [pull request](https://github.com/django-components/django-components/pulls) to add them to this collection.
### Icons
## Components
- [Form](./form.md) - A form component that automatically generates labels and arranges fields in a grid.
- [Tabs](./tabs.md) - Dynamic tabs with [AlpineJS](https://alpinejs.dev/).
## Packages
Packages or projects that define components for django-components:
- [djc-heroicons](https://pypi.org/project/djc-heroicons/) - Icons from HeroIcons.com for django-components.
### HTMX
- [`django-htmx-components`](https://github.com/iwanalabs/django-htmx-components) - A set of components for use with [htmx](https://htmx.org/).

100
docs/examples/tabs.md Normal file
View file

@ -0,0 +1,100 @@
# Tabs (AlpineJS)
_(By [@JuroOravec](https://github.com/JuroOravec) and [@mscheper](https://github.com/mscheper))_
This example defines a tabs component. Tabs are dynamic - to change the currently
opened tab, click on the tab headers.
To get started, use the following example to create a simple container with 2 tabs:
```django
{% component "Tablist" id="my-tablist" name="My Tabs" %}
{% component "Tab" header="Tab 1" %}
This is the content of Tab 1
{% endcomponent %}
{% component "Tab" header="Tab 2" disabled=True %}
This is the content of Tab 2
{% endcomponent %}
{% endcomponent %}
```
![Tabs example](./images/tabs.gif)
## API
The tab component is composed of two parts: `Tablist` and `Tab`. Here's how you can customize them.
### `Tablist` component
The `Tablist` component is the main container for the tabs. It accepts the following arguments:
- **`id`** (optional): A unique ID for the tab list. If not provided, it's generated from the `name`.
- **`name`**: The name of the tab list, used as a WAI-ARIA label for accessibility.
- **`selected_tab`** (optional): The `id` of the tab that should be selected by default.
- **`container_attrs`**, **`tablist_attrs`**, **`tab_attrs`**, **`tabpanel_attrs`** (optional): Dictionaries of HTML attributes to be added to the corresponding elements.
Inside the `Tablist`'s default slot you will define the individual tabs.
### `Tab` component
The `Tab` component defines an individual tab. It MUST be nested inside a `Tablist`. It accepts the following arguments:
- **`header`**: The text to be displayed in the tab's header.
- **`disabled`** (optional): A boolean that disables the tab if `True`.
- **`id`** (optional): A unique ID for the tab. If not provided, it's generated from the header.
Use the `Tab`'s default slot to define the content of the tab.
## Example
To see the component in action, you can set up a view and a URL pattern as shown below.
### `views.py`
This example shows how to render a full page with the tab component.
```djc_py
--8<-- "sampleproject/examples/pages/tabs.py"
```
### `urls.py`
```python
from django.urls import path
from examples.pages.tabs import TabsPage
urlpatterns = [
path("examples/tabs", TabsPage.as_view(), name="tabs"),
]
```
## How it works
At the start of rendering, `Tablist` defines special context that `Tab`s recognize.
When a `Tab` component is nested and rendered inside a `Tablist`, it registers itself with the parent `Tablist` component.
After the rendering of `Tablist`'s body is done, we end up with list of rendered `Tabs` that were encountered.
`Tablist` then uses this information to dynamically render the tab HTML.
## Definition
### `tabs.py`
```djc_py
--8<-- "sampleproject/examples/components/tabs/tabs.py"
```
### `tabs.html`
```django
--8<-- "sampleproject/examples/components/tabs/tabs.html"
```
### `tabs.css`
```css
--8<-- "sampleproject/examples/components/tabs/tabs.css"
```

View file

@ -0,0 +1,3 @@
# Examples
This directory contains example components from the "Examples" documentation section.

View file

View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ExamplesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "examples"

View file

@ -0,0 +1,19 @@
<form
{% if submit_href and editable %} action="{{ submit_href }}" {% endif %}
method="{{ method }}"
{% html_attrs attrs %}
>
{% slot "prepend" / %}
<div {% html_attrs form_content_attrs %}>
{# Generate a grid of fields and labels out of given slots #}
<div class="grid grid-cols-[auto,1fr] gap-x-4 gap-y-2 items-center">
{% for field_name, label in fields %}
{{ label }}
{% slot name=field_name / %}
{% endfor %}
</div>
</div>
{% slot "append" / %}
</form>

View file

@ -0,0 +1,126 @@
from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple
from django_components import Component, Slot, register, types
@register("form")
class Form(Component):
template_file = "form.html"
class Kwargs(NamedTuple):
editable: bool = True
method: str = "post"
form_content_attrs: Optional[dict] = None
attrs: Optional[dict] = None
def get_template_data(self, args, kwargs: Kwargs, slots: Dict[str, Slot], context):
fields = prepare_form_grid(slots)
return {
"form_content_attrs": kwargs.form_content_attrs,
"method": kwargs.method,
"editable": kwargs.editable,
"attrs": kwargs.attrs,
"fields": fields,
}
# Users of this component can define form fields as slots.
#
# For example:
# ```django
# {% component "form" %}
# {% fill "field:field_1" / %}
# <textarea name="field_1" />
# {% endfill %}
# {% fill "field:field_2" / %}
# <select name="field_2">
# <option value="1">Option 1</option>
# <option value="2">Option 2</option>
# </select>
# {% endfill %}
# {% endcomponent %}
# ```
#
# The above will automatically generate labels for the fields,
# and the form will be aligned with a grid.
#
# To explicitly define a label, use `label:<field_name>` slot name.
#
# For example:
# ```django
# {% component "form" %}
# {% fill "label:field_1" / %}
# <label for="field_1">Label 1</label>
# {% endfill %}
# {% fill "field:field_1" / %}
# <textarea name="field_1" />
# {% endfill %}
# {% endcomponent %}
# ```
def prepare_form_grid(slots: Dict[str, Slot]):
used_labels: Set[str] = set()
unused_labels: Set[str] = set()
fields: List[Tuple[str, str]] = []
for slot_name in slots:
# Case: Label slot
is_label = slot_name.startswith("label:")
if is_label and slot_name not in used_labels:
unused_labels.add(slot_name)
continue
# Case: non-field, non-label slot
is_field = slot_name.startswith("field:")
if not is_field:
continue
# Case: Field slot
field_name = slot_name.split(":", 1)[1]
label_slot_name = f"label:{field_name}"
label = None
if label_slot_name in slots:
# Case: Component user explicitly defined how to render the label
label_slot: Slot[Any] = slots[label_slot_name]
label = label_slot()
unused_labels.discard(label_slot_name)
used_labels.add(slot_name)
else:
# Case: Component user didn't explicitly define how to render the label
# We will create the label for the field automatically
label = FormLabel.render(
kwargs=FormLabel.Kwargs(field_name=field_name),
deps_strategy="ignore",
)
fields.append((slot_name, label))
if unused_labels:
raise ValueError(f"Unused labels: {unused_labels}")
return fields
@register("form_label")
class FormLabel(Component):
template: types.django_html = """
<label for="{{ field_name }}" class="font-semibold text-gray-700">
{{ title }}
</label>
"""
class Kwargs(NamedTuple):
field_name: str
title: Optional[str] = None
def get_template_data(self, args, kwargs: Kwargs, slots, context):
if kwargs.title:
title = kwargs.title
else:
title = kwargs.field_name.replace("_", " ").replace("-", " ").title()
return {
"field_name": kwargs.field_name,
"title": title,
}

View file

@ -0,0 +1,100 @@
/* based on https://codepen.io/brettsmason/pen/zYGEgZP */
[role="tablist"] {
margin: 0 0 -0.1em;
overflow: visible;
}
[role="tab"] {
position: relative;
margin: 0;
padding: 0.3em 0.5em 0.4em;
border: 1px solid hsl(219, 1%, 72%);
border-radius: 0.2em 0.2em 0 0;
box-shadow: 0 0 0.2em hsl(219, 1%, 72%);
overflow: visible;
font-family: inherit;
font-size: inherit;
background: hsl(220, 20%, 94%);
}
[role="tab"]:hover::before,
[role="tab"]:focus::before,
[role="tab"][aria-selected="true"]::before {
position: absolute;
bottom: 100%;
right: -1px;
left: -1px;
border-radius: 0.2em 0.2em 0 0;
border-top: 3px solid LinkText;
content: '';
}
[role="tab"][aria-selected="true"] {
border-radius: 0;
background: hsl(220, 43%, 99%);
outline: 0;
}
[role="tab"][aria-selected="true"]:not(:focus):not(:hover)::before {
border-top: 5px solid SelectedItem;
}
[role="tab"][aria-selected="true"]::after {
position: absolute;
z-index: 3;
bottom: -1px;
right: 0;
left: 0;
height: 0.3em;
background: hsl(220, 43%, 99%);
box-shadow: none;
content: '';
}
[role="tab"]:hover,
[role="tab"]:focus,
[role="tab"]:active {
outline: 0;
border-radius: 0;
color: inherit;
}
[role="tab"]:hover::before,
[role="tab"]:focus::before {
border-color: LinkText;
}
[role="tabpanel"] {
position: relative;
z-index: 2;
padding: 0.5em 0.5em 0.7em;
border: 1px solid hsl(219, 1%, 72%);
border-radius: 0 0.2em 0.2em 0.2em;
box-shadow: 0 0 0.2em hsl(219, 1%, 72%);
background: hsl(220, 43%, 99%);
}
[role="tabpanel"]:focus {
border-color: LinkText;
box-shadow: 0 0 0.2em LinkText;
outline: 0;
}
[role="tabpanel"]:focus::after {
position: absolute;
bottom: 0;
right: -1px;
left: -1px;
border-bottom: 3px solid LinkText;
border-radius: 0 0 0.2em 0.2em;
content: '';
}
[role="tabpanel"] p {
margin: 0;
}
[role="tabpanel"] * + p {
margin-top: 1em;
}

View file

@ -0,0 +1,43 @@
{% load component_tags %}
<div
x-data="{ selectedTab: '{{ selected_tab }}' }"
{% html_attrs
container_attrs
id=id
%}
>
<div
{% html_attrs
tablist_attrs
role="tablist"
aria-label=name
%}>
{% for tab_datum, is_hidden in tab_data %}
<button
:aria-selected="selectedTab === '{{ tab_datum.tab_id }}'"
@click="selectedTab = '{{ tab_datum.tab_id }}'"
{% html_attrs
tab_attrs
id=tab_datum.tab_id
role="tab"
aria-controls=tab_datum.tabpanel_id
disabled=tab_datum.disabled
%}>
{{ tab_datum.header }}
</button>
{% endfor %}
</div>
{% for tab_datum, is_hidden in tab_data %}
<article
:hidden="selectedTab != '{{ tab_datum.tab_id }}'"
{% html_attrs
tabpanel_attrs
hidden=is_hidden
role="tabpanel"
id=tab_datum.tabpanel_id
aria-labelledby=tab_datum.tab_id
%}>
{{ tab_datum.content }}
</article>
{% endfor %}
</div>

View file

@ -0,0 +1,260 @@
"""
Alpine-based tab components: Tablist and Tab.
Based on https://github.com/django-components/django-components/discussions/540
"""
from typing import List, NamedTuple, Optional
from django.utils.safestring import mark_safe
from django.utils.text import slugify
from django_components import Component, register
from django_components import types as t
class TabDatum(NamedTuple):
"""Datum for an individual tab."""
tab_id: str
tabpanel_id: str
header: str
content: str
disabled: bool = False
class TabContext(NamedTuple):
id: str
tab_data: List[TabDatum]
enabled: bool
@register("_tabset")
class _TablistImpl(Component):
"""
Delegated Tablist component.
Refer to `Tablist` API below.
"""
template_file = "tabs.html"
css_file = "tabs.css"
class Media:
js = (
# `mark_safe` is used so the script tag is usd as is, so we can add `defer` flag.
# `defer` is used so that AlpineJS is actually loaded only after all plugins are registered
mark_safe('<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>'),
)
class Kwargs(NamedTuple):
tab_data: List[TabDatum]
id: Optional[str] = None
name: Optional[str] = None
selected_tab: Optional[str] = None
container_attrs: Optional[dict] = None
tablist_attrs: Optional[dict] = None
tab_attrs: Optional[dict] = None
tabpanel_attrs: Optional[dict] = None
def get_template_data(self, args, kwargs: Kwargs, slots, context):
selected_tab = kwargs.selected_tab if kwargs.selected_tab is not None else kwargs.tab_data[0].tab_id
tab_data = [
(tab, tab.tab_id != selected_tab) # (tab, is_hidden)
for tab in kwargs.tab_data
]
return {
"id": kwargs.id,
"name": kwargs.name,
"container_attrs": kwargs.container_attrs,
"tablist_attrs": kwargs.tablist_attrs,
"tab_attrs": kwargs.tab_attrs,
"tabpanel_attrs": kwargs.tabpanel_attrs,
"tab_data": tab_data,
"selected_tab": selected_tab,
}
@register("Tablist")
class Tablist(Component):
"""
Tablist role component comprised of nested tab components.
After the input is processed, this component delegates to an internal implementation
component that renders the content.
`name` identifies the tablist and is used as a WAI-ARIA label
`id`, by default, is a sligified `name`, we could be used to preselect a tab based
on query parameters (TODO)
Example:
```
{% component "Tablist" id="my-tablist" name="My Tabs" %}
{% component Tab header="Tab 1" %}
This is the content of Tab 1
{% endcomponent %}
{% component Tab header="Tab 2" disabled=True %}
This is the content of Tab 2
{% endcomponent %}
{% endcomponent %}
```
"""
template: t.django_html = """
{% load component_tags %}
{% provide "_tab" ...tab_context %}
{% slot "content" default / %}
{% endprovide %}
"""
class Kwargs(NamedTuple):
id: Optional[str] = None
name: str = "Tabs"
selected_tab: Optional[str] = None
container_attrs: Optional[dict] = None
tablist_attrs: Optional[dict] = None
tab_attrs: Optional[dict] = None
tabpanel_attrs: Optional[dict] = None
def get_template_data(self, args, kwargs: Kwargs, slots, context):
self.tablist_id: str = kwargs.id or slugify(kwargs.name)
self.tab_data: List[TabDatum] = []
tab_context = TabContext(
id=self.tablist_id,
tab_data=self.tab_data,
enabled=True,
)
return {
"tab_context": tab_context._asdict(),
}
def on_render_after(self, context, template, result, error) -> Optional[str]:
"""
Render the tab set.
By the time we get here, all child Tab components should have been rendered,
and they should've populated the tablist.
"""
if error or result is None:
return None
kwargs: Tablist.Kwargs = self.kwargs
# Render the TablistImpl component in place of Tablist.
return _TablistImpl.render(
kwargs=_TablistImpl.Kwargs(
# Access variables we've defined in get_template_data
id=self.tablist_id,
tab_data=self.tab_data,
name=kwargs.name,
selected_tab=kwargs.selected_tab,
container_attrs=kwargs.container_attrs,
tablist_attrs=kwargs.tablist_attrs,
tab_attrs=kwargs.tab_attrs,
tabpanel_attrs=kwargs.tabpanel_attrs,
),
deps_strategy="ignore",
)
@register("Tab")
class Tab(Component):
"""
Individual tab, inside the default slot of the `Tablist` component.
Example:
```
{% component "Tablist" id="my-tablist" name="My Tabs" %}
{% component Tab header="Tab 1" %}
This is the content of Tab 1
{% endcomponent %}
{% component Tab header="Tab 2" disabled=True %}
This is the content of Tab 2
{% endcomponent %}
{% endcomponent %}
```
"""
template: t.django_html = """
{% load component_tags %}
{% provide "_tab" ...overriding_tab_context %}
{% slot "content" default / %}
{% endprovide %}
"""
class Kwargs(NamedTuple):
header: str
disabled: bool = False
id: Optional[str] = None
def get_template_data(self, args, kwargs: Kwargs, slots, context):
"""
Access the tab data registered for the parent Tablist component.
This raises if we're not nested inside a Tablist component.
"""
tab_ctx: TabContext = self.inject("_tab")
# We accessed the _tab context, but we're inside ANOTHER Tab
if not tab_ctx.enabled:
raise RuntimeError(
f"Component '{self.name}' was called with no parent Tablist component. "
f"Either wrap '{self.name}' in Tablist component, or check if the "
f"component is not a descendant of another instance of '{self.name}'"
)
if kwargs.id:
slug = kwargs.id
else:
group_slug = slugify(tab_ctx.id)
tab_slug = slugify(kwargs.header)
slug = f"{group_slug}_{tab_slug}"
self.tab_id = f"{slug}_tab"
self.tabpanel_id = f"{slug}_content"
self.parent_tabs: List[TabDatum] = tab_ctx.tab_data
# Prevent Tab's children from accessing the parent Tablist context.
# If we didn't do this, then you could place a Tab inside another Tab,
# ```
# {% component Tablist %}
# {% component Tab header="Tab 1" %}
# {% component Tab header="Tab 2" %}
# This is the content of Tab 2
# {% endcomponent %}
# {% endcomponent %}
# {% endcomponent %}
# ```
overriding_tab_context = TabContext(
id=self.tab_id,
tab_data=[],
enabled=False,
)
return {
"overriding_tab_context": overriding_tab_context._asdict(),
}
# This runs when the Tab component is rendered and the content is returned.
# We add the TabDatum to the parent Tablist component.
def on_render_after(self, context, template, result, error) -> None:
if error or result is None:
return
kwargs: Tab.Kwargs = self.kwargs
self.parent_tabs.append(
TabDatum(
tab_id=self.tab_id,
tabpanel_id=self.tabpanel_id,
header=kwargs.header,
disabled=kwargs.disabled,
content=mark_safe(result.strip()),
),
)

View file

View file

@ -0,0 +1,88 @@
from django.http import HttpRequest
from django.utils.safestring import mark_safe
from django_components import Component, types
class FormPage(Component):
class Media:
js = (
# AlpineJS
mark_safe('<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>'),
# TailwindCSS
"https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4",
)
template: types.django_html = """
<html>
<head>
<title>Form</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp,container-queries"></script>
</head>
<body>
<div x-data="{
onSubmit: () => {
alert('Submitted!');
}
}">
<div class="prose-xl p-6">
<h3>Submit form</h3>
</div>
{% component "form"
attrs:class="pb-4 px-4 pt-6 sm:px-6 lg:px-8 flex-auto flex flex-col"
attrs:style="max-width: 600px;"
attrs:@submit.prevent="onSubmit"
%}
{% fill "field:project" %}
<input
name="project"
required
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
>
{% endfill %}
{% fill "field:option" %}
<select
name="option"
required
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:max-w-xs sm:text-sm sm:leading-6"
>
<option value="1">Option 1</option>
<option value="2">Option 2</option>
<option value="3">Option 3</option>
</select>
{% endfill %}
{# Defined both label and field because label name is different from field name #}
{% fill "label:description" %}
{% component "form_label" field_name="description" title="Marvelous description" / %}
{% endfill %}
{% fill "field:description" %}
<textarea
name="description"
id="description"
rows="5"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
></textarea>
{% endfill %}
{% fill "append" %}
<div class="flex justify-end items-center gap-x-6 border-t border-gray-900/10 py-4">
<button type="submit" class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">
Submit
</button>
<button type="button" class="text-sm font-semibold leading-6 text-gray-900">
Cancel
</button>
</div>
{% endfill %}
{% endcomponent %}
</div>
</body>
</html>
""" # noqa: E501
class View:
def get(self, request: HttpRequest):
return FormPage.render_to_response(request=request)

View file

@ -0,0 +1,39 @@
from django.http import HttpRequest
from django_components import Component, types
class TabsPage(Component):
template: types.django_html = """
<html>
<head>
<title>Tabs</title>
</head>
<body>
{% component "Tablist"
id="optional-tablist-id"
name="Bonza tablist"
container_attrs:class="optional-container-attrs"
tablist_attrs:class="optional-tablist-attrs"
tab_attrs:class="optional-tab-attrs"
tabpanel_attrs:class="optional-panel-attrs"
%}
{% component "Tab" id="optional-tab-id" header="I'm a tab!" %}
{% lorem %}
{% endcomponent %}
{% component "Tab" header="I'm also a tab!" %}
<p>{% lorem %}</p>
<p>{% lorem %}</p>
{% endcomponent %}
{% component "Tab" header="I am a gorilla!" %}
<p>{% lorem %}</p>
<p>I wonder if anyone got the Monty Python reference. 🤔</p>
{% endcomponent %}
{% endcomponent %}
</body>
</html>
"""
class View:
def get(self, request: HttpRequest):
return TabsPage.render_to_response(request=request)

View file

@ -0,0 +1,9 @@
from django.urls import path
from examples.pages.form import FormPage
from examples.pages.tabs import TabsPage
urlpatterns = [
path("examples/tabs", TabsPage.as_view(), name="tabs"),
path("examples/form", FormPage.as_view(), name="form"),
]

View file

@ -32,6 +32,7 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"django_components",
"examples",
"calendarapp",
]
# Application definition
@ -92,7 +93,7 @@ WSGI_APPLICATION = "sampleproject.wsgi.application"
COMPONENTS = ComponentsSettings(
# autodiscover=True,
dirs=[BASE_DIR / "components"],
# app_dirs=["components"],
app_dirs=["components", "pages"],
# libraries=[],
# context_behavior="isolated", # "django" | "isolated"
)

View file

@ -3,5 +3,6 @@ from django.urls import include, path
urlpatterns = [
path("", include("calendarapp.urls")),
path("", include("components.urls")),
path("", include("examples.urls")),
path("", include("django_components.urls")),
]

113
tests/test_example_form.py Normal file
View file

@ -0,0 +1,113 @@
from pathlib import Path
import pytest
from django.template import Context, Template
from pytest_django.asserts import assertHTMLEqual
from django_components import registry, types
from django_components.testing import djc_test
from tests.testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config
setup_test_config({"autodiscover": False})
# Instead of having to re-define the components from the examples section in documentation,
# we import them directly from sampleproject.
def _create_tab_components():
# Imported lazily, so we import it only once settings are set
from sampleproject.examples.components.form.form import Form, FormLabel
# NOTE: We're importing the component classes from the sampleproject, so we're
# testing the actual implementation.
registry.register("form", Form)
registry.register("form_label", FormLabel)
@djc_test(
parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR,
components_settings={
"dirs": [
Path(__file__).parent / "components",
# Include the directory where example components are defined
Path(__file__).parent.parent / "sampleproject/examples/components",
],
},
)
class TestExampleForm:
def test_render_simple_form(self, components_settings):
_create_tab_components()
template_str: types.django_html = """
{% load component_tags %}
{% component "form" %}
{% fill "field:project" %}<input name="project">{% endfill %}
{% fill "field:option" %}<select name="option"></select>{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assertHTMLEqual(
rendered,
"""
<form method="post" data-djc-id-ca1bc41>
<div>
<div class="grid grid-cols-[auto,1fr] gap-x-4 gap-y-2 items-center">
<label for="project" class="font-semibold text-gray-700" data-djc-id-ca1bc42>
Project
</label>
<input name="project">
<label for="option" class="font-semibold text-gray-700" data-djc-id-ca1bc43>
Option
</label>
<select name="option"></select>
</div>
</div>
</form>
""",
)
def test_custom_label(self, components_settings):
_create_tab_components()
template_str = """
{% load component_tags %}
{% component "form" %}
{% fill "label:project" %}<strong>Custom Project Label</strong>{% endfill %}
{% fill "field:project" %}<input name="project">{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assert "<strong>Custom Project Label</strong>" in rendered
assert '<label for="project"' not in rendered
def test_unused_label_raises_error(self, components_settings):
_create_tab_components()
template_str = """
{% load component_tags %}
{% component "form" %}
{% fill "label:project" %}Custom Project Label{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
with pytest.raises(ValueError, match=r"Unused labels: {'label:project'}"):
template.render(Context({}))
def test_prepend_append_slots(self, components_settings):
_create_tab_components()
template_str = """
{% load component_tags %}
{% component "form" %}
{% fill "prepend" %}<div>Prepended content</div>{% endfill %}
{% fill "field:project" %}<input name="project">{% endfill %}
{% fill "append" %}<div>Appended content</div>{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assert "<div>Prepended content</div>" in rendered
assert "<div>Appended content</div>" in rendered
assert rendered.find("Prepended content") < rendered.find("project")
assert rendered.find("Appended content") > rendered.find("project")

163
tests/test_example_tabs.py Normal file
View file

@ -0,0 +1,163 @@
from pathlib import Path
import pytest
from django.template import Context, Template
from pytest_django.asserts import assertHTMLEqual
from django_components import registry, types
from django_components.testing import djc_test
from tests.testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config
setup_test_config({"autodiscover": False})
# Instead of having to re-define the components from the examples section in documentation,
# we import them directly from sampleproject.
def _create_tab_components():
# Imported lazily, so we import it only once settings are set
from sampleproject.examples.components.tabs.tabs import Tab, Tablist, _TablistImpl
# NOTE: We're importing the component classes from the sampleproject, so we're
# testing the actual implementation.
registry.register("Tab", Tab)
registry.register("Tablist", Tablist)
registry.register("_tabset", _TablistImpl)
@djc_test(
parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR,
components_settings={
"dirs": [
Path(__file__).parent / "components",
# Include the directory where example components are defined
Path(__file__).parent.parent / "sampleproject/examples/components",
],
},
)
class TestExampleTabs:
def test_render_simple_tabs(self, components_settings):
_create_tab_components()
template_str: types.django_html = """
{% load component_tags %}
{% component "Tablist" name="My Tabs" %}
{% component "Tab" header="Tab 1" %}Content 1{% endcomponent %}
{% component "Tab" header="Tab 2" %}Content 2{% endcomponent %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
if components_settings["context_behavior"] == "django":
comp_id = "ca1bc4b"
else:
comp_id = "ca1bc47"
assertHTMLEqual(
rendered,
f"""
<div x-data="{{ selectedTab: 'my-tabs_tab-1_tab' }}" id="my-tabs" data-djc-id-{comp_id}>
<div role="tablist" aria-label="My Tabs">
<button
:aria-selected="selectedTab === 'my-tabs_tab-1_tab'"
@click="selectedTab = 'my-tabs_tab-1_tab'"
id="my-tabs_tab-1_tab"
role="tab"
aria-controls="my-tabs_tab-1_content">
Tab 1
</button>
<button
:aria-selected="selectedTab === 'my-tabs_tab-2_tab'"
@click="selectedTab = 'my-tabs_tab-2_tab'"
id="my-tabs_tab-2_tab"
role="tab"
aria-controls="my-tabs_tab-2_content">
Tab 2
</button>
</div>
<article
:hidden="selectedTab != 'my-tabs_tab-1_tab'"
role="tabpanel"
id="my-tabs_tab-1_content"
aria-labelledby="my-tabs_tab-1_tab">
Content 1
</article>
<article
:hidden="selectedTab != 'my-tabs_tab-2_tab'"
role="tabpanel"
id="my-tabs_tab-2_content"
aria-labelledby="my-tabs_tab-2_tab"
hidden>
Content 2
</article>
</div>
""",
)
def test_disabled_tab(self, components_settings):
_create_tab_components()
template_str = """
{% load component_tags %}
{% component "Tablist" name="My Tabs" %}
{% component "Tab" header="Tab 1" %}Content 1{% endcomponent %}
{% component "Tab" header="Tab 2" disabled=True %}Content 2{% endcomponent %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assert "disabled" in rendered
assert "Content 2" in rendered
def test_custom_ids(self, components_settings):
_create_tab_components()
template_str = """
{% load component_tags %}
{% component "Tablist" id="custom-list" name="My Tabs" %}
{% component "Tab" id="custom-tab" header="Tab 1" %}Content 1{% endcomponent %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assert 'id="custom-list"' in rendered
assert 'id="custom-tab_tab"' in rendered
assert 'aria-controls="custom-tab_content"' in rendered
assert 'id="custom-tab_content"' in rendered
assert 'aria-labelledby="custom-tab_tab"' in rendered
def test_tablist_in_tab_raise_error(self, components_settings):
_create_tab_components()
template_str = """
{% load component_tags %}
{% component "Tablist" name="Outer Tabs" %}
{% component "Tab" header="Outer 1" %}
{% component "Tablist" name="Inner Tabs" %}
{% component "Tab" header="Inner 1" %}
Inner Content
{% endcomponent %}
{% endcomponent %}
{% endcomponent %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
assert "Inner Content" in rendered
def test_tab_in_tab_raise_error(self, components_settings):
_create_tab_components()
template_str = """
{% load component_tags %}
{% component "Tablist" name="Outer Tabs" %}
{% component "Tab" header="Outer 1" %}
{% component "Tab" header="Inner 1" %}
Inner Content
{% endcomponent %}
{% endcomponent %}
{% endcomponent %}
"""
template = Template(template_str)
with pytest.raises(RuntimeError, match="Component 'Tab' was called with no parent Tablist component"):
template.render(Context({}))