Deployed 9877cf30 to dev with MkDocs 1.6.1 and mike 2.1.3
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
146
dev/examples/form/component.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple
|
||||||
|
|
||||||
|
from django_components import Component, Slot, register, types
|
||||||
|
|
||||||
|
|
||||||
|
@register("form")
|
||||||
|
class Form(Component):
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
template: types.django_html = """
|
||||||
|
<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>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
88
dev/examples/form/page.py
Normal 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)
|
||||||
96
dev/examples/form/test_example_form.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# Imported lazily, so we import components only once settings are set
|
||||||
|
def _create_form_components():
|
||||||
|
from docs.examples.form.component import Form, FormLabel # noqa: PLC0415
|
||||||
|
|
||||||
|
registry.register("form", Form)
|
||||||
|
registry.register("form_label", FormLabel)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@djc_test
|
||||||
|
class TestExampleForm:
|
||||||
|
def test_render_simple_form(self):
|
||||||
|
_create_form_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):
|
||||||
|
_create_form_components()
|
||||||
|
template_str: types.django_html = """
|
||||||
|
{% 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):
|
||||||
|
_create_form_components()
|
||||||
|
template_str: types.django_html = """
|
||||||
|
{% 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):
|
||||||
|
_create_form_components()
|
||||||
|
template_str: types.django_html = """
|
||||||
|
{% 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")
|
||||||
411
dev/examples/tabs/component.py
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
"""
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
template: t.django_html = """
|
||||||
|
{% 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>
|
||||||
|
"""
|
||||||
|
|
||||||
|
css: t.css = """
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@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()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 259 KiB |
39
dev/examples/tabs/page.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
|
||||||
|
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) -> HttpResponse:
|
||||||
|
return TabsPage.render_to_response(request=request)
|
||||||
144
dev/examples/tabs/test_example_tabs.py
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# Imported lazily, so we import it only once settings are set
|
||||||
|
def _create_tab_components() -> None:
|
||||||
|
from docs.examples.tabs.component import Tab, Tablist, _TablistImpl # noqa: PLC0415
|
||||||
|
|
||||||
|
registry.register("Tab", Tab)
|
||||||
|
registry.register("Tablist", Tablist)
|
||||||
|
registry.register("_tabset", _TablistImpl)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@djc_test
|
||||||
|
class TestExampleTabs:
|
||||||
|
def test_render_simple_tabs(self):
|
||||||
|
_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({}))
|
||||||
|
|
||||||
|
assertHTMLEqual(
|
||||||
|
rendered,
|
||||||
|
"""
|
||||||
|
<div x-data="{
|
||||||
|
selectedTab: 'my-tabs_tab-1_tab',
|
||||||
|
}"
|
||||||
|
id="my-tabs" data-djc-id-ca1bc4b>
|
||||||
|
<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):
|
||||||
|
_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" 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):
|
||||||
|
_create_tab_components()
|
||||||
|
template_str: types.django_html = """
|
||||||
|
{% 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):
|
||||||
|
_create_tab_components()
|
||||||
|
template_str: types.django_html = """
|
||||||
|
{% 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):
|
||||||
|
_create_tab_components()
|
||||||
|
template_str: types.django_html = """
|
||||||
|
{% 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({}))
|
||||||