mirror of
https://github.com/django-components/django-components.git
synced 2025-11-18 14:10:19 +00:00
docs: self-contained examples (#1436)
This commit is contained in:
parent
48adaf98f1
commit
9877cf30ed
71 changed files with 673 additions and 361 deletions
|
|
@ -1,3 +1,47 @@
|
|||
# Examples
|
||||
|
||||
This directory contains example components from the "Examples" documentation section.
|
||||
This Django app dynamically discovers and registers example components from the documentation (`docs/examples/`).
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Discovery**: At startup, the app scans `docs/examples/*/` directories for:
|
||||
|
||||
- `component.py` - Component definitions with inline templates
|
||||
- `page.py` - Page views for live demos
|
||||
|
||||
2. **Registration**: Found modules are imported as:
|
||||
|
||||
- `examples.dynamic.<example_name>.component`
|
||||
- `examples.dynamic.<example_name>.page`
|
||||
|
||||
3. **Components**: Are automatically registered with django-components registry via `@register()` decorators
|
||||
|
||||
4. **URLs**: Page views are automatically registered as URL patterns at `examples/<example_name>`
|
||||
|
||||
## Structure
|
||||
|
||||
Each example in `docs/examples/` follows this structure:
|
||||
|
||||
```
|
||||
docs/examples/form/
|
||||
├── README.md # Documentation
|
||||
├── component.py # Component with inline templates
|
||||
├── page.py # Page view for live demo
|
||||
├── test.py # Tests
|
||||
└── images/ # Screenshots/assets
|
||||
```
|
||||
|
||||
## Live examples
|
||||
|
||||
All examples are available as live demos:
|
||||
|
||||
- **Index page**: [http://localhost:8000/examples/](http://localhost:8000/examples/) - Lists all available examples
|
||||
- **Individual examples**: `http://localhost:8000/examples/<example_name>`
|
||||
- [http://localhost:8000/examples/form](http://localhost:8000/examples/form)
|
||||
- [http://localhost:8000/examples/tabs](http://localhost:8000/examples/tabs)
|
||||
|
||||
## Adding new examples
|
||||
|
||||
1. Create a new directory in `docs/examples/<example_name>/`
|
||||
2. Add `component.py`, `page.py`, and other files as seen above.
|
||||
3. Start the server and open `http://localhost:8000/examples/<example_name>` to see the example.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
from .utils import discover_example_modules
|
||||
|
||||
|
||||
# This adds finds all examples defined in `docs/examples/` and for each of them
|
||||
# adds a URL that renders that example's live demo. These are available under
|
||||
# `http://localhost:8000/examples/<example_name>`.
|
||||
#
|
||||
# Overview of all examples is available under `http://localhost:8000/examples/`.
|
||||
class ExamplesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "examples"
|
||||
|
||||
def ready(self):
|
||||
# Auto-discover and register example components and pages
|
||||
discover_example_modules()
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
/* 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;
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
{% 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>
|
||||
|
|
@ -1,260 +0,0 @@
|
|||
"""
|
||||
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()),
|
||||
),
|
||||
)
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,9 +1,57 @@
|
|||
import importlib
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from examples.pages.form import FormPage
|
||||
from examples.pages.tabs import TabsPage
|
||||
from django_components.component import Component
|
||||
|
||||
urlpatterns = [
|
||||
path("examples/tabs", TabsPage.as_view(), name="tabs"),
|
||||
path("examples/form", FormPage.as_view(), name="form"),
|
||||
]
|
||||
from .utils import discover_example_modules
|
||||
from .views import ExamplesIndexPage
|
||||
|
||||
|
||||
# For each example in `docs/examples/*`, register a URL pattern that points to the example's view.
|
||||
# The example will be available at `http://localhost:8000/examples/<example_name>`.
|
||||
# The view is the first Component class that we find in example's `page.py` module.
|
||||
#
|
||||
# So if we have an example called `form`:
|
||||
# 1. We look for a module `examples.dynamic.form.page`,
|
||||
# 2. We find the first Component class in that module (in this case `FormPage`),
|
||||
# 3. We register a URL pattern that points to that view (in this case `http://localhost:8000/examples/form`).
|
||||
def get_example_urls():
|
||||
# First, ensure all example modules are discovered and imported
|
||||
examples_names = discover_example_modules()
|
||||
|
||||
urlpatterns = [
|
||||
# Index page that lists all examples
|
||||
path("examples/", ExamplesIndexPage.as_view(), name="examples_index"),
|
||||
]
|
||||
for example_name in examples_names:
|
||||
try:
|
||||
# Import the page module (should already be loaded by discover_example_modules)
|
||||
module_name = f"examples.dynamic.{example_name}.page"
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
# Find the view class (assume it's the first Component class)
|
||||
view_class = None
|
||||
for attr_name in dir(module):
|
||||
attr = getattr(module, attr_name)
|
||||
if issubclass(attr, Component) and attr_name != "Component":
|
||||
view_class = attr
|
||||
break
|
||||
|
||||
if not view_class:
|
||||
raise ValueError(f"No Component class found in {module_name}")
|
||||
|
||||
# Make the example availble under localhost:8000/examples/<example_name>
|
||||
url_pattern = f"examples/{example_name}"
|
||||
view_name = example_name
|
||||
|
||||
urlpatterns.append(path(url_pattern, view_class.as_view(), name=view_name))
|
||||
print(f"Registered URL: {url_pattern} -> {view_class.__name__}")
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"Failed to register URL for {example_name}: {e}")
|
||||
|
||||
return urlpatterns
|
||||
|
||||
|
||||
urlpatterns = get_example_urls()
|
||||
|
|
|
|||
93
sampleproject/examples/utils.py
Normal file
93
sampleproject/examples/utils.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Set
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
# Keep track of what we've already discovered to make subsequent calls a noop
|
||||
_discovered_examples: Set[str] = set()
|
||||
|
||||
|
||||
def discover_example_modules() -> List[str]:
|
||||
"""
|
||||
Find and import `component.py` and `page.py` files from example directories
|
||||
`docs/examples/*/` (e.g. `docs/examples/form/component.py`).
|
||||
|
||||
These files will be importable from other modules like:
|
||||
|
||||
```python
|
||||
from examples.dynamic.form.component import Form
|
||||
# or
|
||||
from examples.dynamic.form.page import FormPage
|
||||
```
|
||||
|
||||
Components will be also registered with the ComponentRegistry, so they can be used
|
||||
in the templates via the `{% component %}` tag like:
|
||||
|
||||
```django
|
||||
{% component "form" / %}
|
||||
```
|
||||
|
||||
This function is idempotent - calling it multiple times will not re-import modules.
|
||||
"""
|
||||
# Skip if we've already discovered examples
|
||||
if _discovered_examples:
|
||||
return list(_discovered_examples)
|
||||
|
||||
docs_examples_dir: Path = settings.EXAMPLES_DIR
|
||||
if not docs_examples_dir.exists():
|
||||
raise FileNotFoundError(f"Docs examples directory not found: {docs_examples_dir}")
|
||||
|
||||
for example_dir in docs_examples_dir.iterdir():
|
||||
if not example_dir.is_dir():
|
||||
continue
|
||||
|
||||
example_name = example_dir.name
|
||||
|
||||
component_file = example_dir / "component.py"
|
||||
if component_file.exists():
|
||||
_import_module_file(component_file, example_name, "component")
|
||||
|
||||
page_file = example_dir / "page.py"
|
||||
if page_file.exists():
|
||||
_import_module_file(page_file, example_name, "page")
|
||||
|
||||
# Mark this example as discovered
|
||||
_discovered_examples.add(example_name)
|
||||
|
||||
return list(_discovered_examples)
|
||||
|
||||
|
||||
def _import_module_file(py_file: Path, example_name: str, module_type: str):
|
||||
"""
|
||||
Dynamically import a python file as a module.
|
||||
|
||||
This file will then be importable from other modules like:
|
||||
|
||||
```python
|
||||
from examples.dynamic.form.component import Form
|
||||
# or
|
||||
from examples.dynamic.form.page import FormPage
|
||||
```
|
||||
"""
|
||||
module_name = f"examples.dynamic.{example_name}.{module_type}"
|
||||
|
||||
# Skip if module is already imported
|
||||
if module_name in sys.modules:
|
||||
return
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, py_file)
|
||||
if not spec or not spec.loader:
|
||||
raise ValueError(f"Failed to load {module_type} {example_name}/{py_file.name}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
# Add to sys.modules so the contents can be imported from other modules
|
||||
# via Python import system.
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
print(f"Loaded example {module_type}: {example_name}/{py_file.name}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"Failed to load {module_type} {example_name}/{py_file.name}: {e}")
|
||||
122
sampleproject/examples/views.py
Normal file
122
sampleproject/examples/views.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
from django.http import HttpRequest
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from django_components import Component, types
|
||||
|
||||
from .utils import discover_example_modules
|
||||
|
||||
|
||||
class ExamplesIndexPage(Component):
|
||||
"""Index page that lists all available examples"""
|
||||
|
||||
class Media:
|
||||
js = (
|
||||
mark_safe(
|
||||
'<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp,container-queries"></script>'
|
||||
),
|
||||
)
|
||||
|
||||
def get_template_data(self, args, kwargs, slots, context):
|
||||
# Get the list of discovered examples
|
||||
example_names = discover_example_modules()
|
||||
|
||||
# Convert example names to display format
|
||||
examples = []
|
||||
for name in sorted(example_names):
|
||||
# Convert snake_case to PascalCase (e.g. error_fallback -> ErrorFallback)
|
||||
display_name = "".join(word.capitalize() for word in name.split("_"))
|
||||
examples.append(
|
||||
{
|
||||
"name": name, # Original name for URLs
|
||||
"display_name": display_name, # PascalCase for display
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"examples": examples,
|
||||
}
|
||||
|
||||
class View:
|
||||
def get(self, request: HttpRequest):
|
||||
return ExamplesIndexPage.render_to_response(request=request)
|
||||
|
||||
template: types.django_html = """
|
||||
<html>
|
||||
<head>
|
||||
<title>Django Components Examples</title>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<div class="max-w-4xl mx-auto py-12 px-6">
|
||||
<div class="text-center mb-12">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">
|
||||
Django Components Examples
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600">
|
||||
Interactive examples showcasing django-components features
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if examples %}
|
||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for example in examples %}
|
||||
<div class="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 flex flex-col">
|
||||
<div class="p-6 flex flex-col flex-grow">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-2">
|
||||
{{ example.display_name }}
|
||||
</h2>
|
||||
<p class="text-gray-600 mb-4 flex-grow">
|
||||
{{ example.display_name }} component example
|
||||
</p>
|
||||
<a
|
||||
href="/examples/{{ example.name }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors duration-200 self-start"
|
||||
>
|
||||
View Example
|
||||
<svg class="ml-2 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<div class="text-gray-400 mb-4">
|
||||
<svg class="mx-auto w-16 h-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No examples found</h3>
|
||||
<p class="text-gray-600">
|
||||
No example components were discovered in the docs/examples/ directory.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-12 text-center">
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
||||
About these examples
|
||||
</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
These examples are dynamically discovered from the <code class="bg-gray-100 px-2 py-1 rounded text-sm">docs/examples/</code> directory.
|
||||
Each example includes a component definition, live demo page, and tests.
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/django-components/django-components"
|
||||
class="inline-flex items-center text-blue-600 hover:text-blue-700 font-medium"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View on GitHub
|
||||
<svg class="ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""" # noqa: E501
|
||||
Loading…
Add table
Add a link
Reference in a new issue