diff --git a/docs/examples/.nav.yml b/docs/examples/.nav.yml index 1514751a..992552c6 100644 --- a/docs/examples/.nav.yml +++ b/docs/examples/.nav.yml @@ -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 \ No newline at end of file diff --git a/docs/examples/README.md b/docs/examples/README.md new file mode 100644 index 00000000..d2a1095e --- /dev/null +++ b/docs/examples/README.md @@ -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//.py`. +2. Define a view / page component in `sampleproject/examples/pages//.py`. +3. Add a new page here in the documentation named `.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_.py`. diff --git a/docs/examples/form.md b/docs/examples/form.md new file mode 100644 index 00000000..e058bab1 --- /dev/null +++ b/docs/examples/form.md @@ -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" %} + + {% endfill %} + + {% fill "field:option" %} + + {% endfill %} +{% endcomponent %} +``` + +This will render a `
` where fields are defined using `field:` 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:` 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" %} + + {% 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 `` element itself. + +To define the fields, you define a slot for each field. + +**Slots:** + +- **`field:`**: Use this slot to define a form field. The component will automatically generate a label for it based on ``. +- **`label:`**: 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:` 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 + +``` + +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" +``` diff --git a/docs/examples/images/form.png b/docs/examples/images/form.png new file mode 100644 index 00000000..cd47339b Binary files /dev/null and b/docs/examples/images/form.png differ diff --git a/docs/examples/images/form_structure.png b/docs/examples/images/form_structure.png new file mode 100644 index 00000000..a5963bb8 Binary files /dev/null and b/docs/examples/images/form_structure.png differ diff --git a/docs/examples/images/tabs.gif b/docs/examples/images/tabs.gif new file mode 100644 index 00000000..a0c30b27 Binary files /dev/null and b/docs/examples/images/tabs.gif differ diff --git a/docs/examples/overview.md b/docs/examples/index.md similarity index 51% rename from docs/examples/overview.md rename to docs/examples/index.md index a7ded4fa..de5f8344 100644 --- a/docs/examples/overview.md +++ b/docs/examples/index.md @@ -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/). diff --git a/docs/examples/tabs.md b/docs/examples/tabs.md new file mode 100644 index 00000000..805852fb --- /dev/null +++ b/docs/examples/tabs.md @@ -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" +``` diff --git a/sampleproject/examples/README.md b/sampleproject/examples/README.md new file mode 100644 index 00000000..5ee157e2 --- /dev/null +++ b/sampleproject/examples/README.md @@ -0,0 +1,3 @@ +# Examples + +This directory contains example components from the "Examples" documentation section. diff --git a/sampleproject/examples/__init__.py b/sampleproject/examples/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sampleproject/examples/apps.py b/sampleproject/examples/apps.py new file mode 100644 index 00000000..af8411d2 --- /dev/null +++ b/sampleproject/examples/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ExamplesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "examples" diff --git a/sampleproject/examples/components/form/form.html b/sampleproject/examples/components/form/form.html new file mode 100644 index 00000000..74a2733b --- /dev/null +++ b/sampleproject/examples/components/form/form.html @@ -0,0 +1,19 @@ + + {% slot "prepend" / %} + +
+ {# Generate a grid of fields and labels out of given slots #} +
+ {% for field_name, label in fields %} + {{ label }} + {% slot name=field_name / %} + {% endfor %} +
+
+ + {% slot "append" / %} + diff --git a/sampleproject/examples/components/form/form.py b/sampleproject/examples/components/form/form.py new file mode 100644 index 00000000..973e7b5e --- /dev/null +++ b/sampleproject/examples/components/form/form.py @@ -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" / %} +# + {% endfill %} + + {% fill "append" %} +
+ + +
+ {% endfill %} + {% endcomponent %} + + + + """ # noqa: E501 + + class View: + def get(self, request: HttpRequest): + return FormPage.render_to_response(request=request) diff --git a/sampleproject/examples/pages/tabs.py b/sampleproject/examples/pages/tabs.py new file mode 100644 index 00000000..e4630e92 --- /dev/null +++ b/sampleproject/examples/pages/tabs.py @@ -0,0 +1,39 @@ +from django.http import HttpRequest + +from django_components import Component, types + + +class TabsPage(Component): + template: types.django_html = """ + + + Tabs + + + {% 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!" %} +

{% lorem %}

+

{% lorem %}

+ {% endcomponent %} + {% component "Tab" header="I am a gorilla!" %} +

{% lorem %}

+

I wonder if anyone got the Monty Python reference. 🤔

+ {% endcomponent %} + {% endcomponent %} + + + """ + + class View: + def get(self, request: HttpRequest): + return TabsPage.render_to_response(request=request) diff --git a/sampleproject/examples/urls.py b/sampleproject/examples/urls.py new file mode 100644 index 00000000..48646393 --- /dev/null +++ b/sampleproject/examples/urls.py @@ -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"), +] diff --git a/sampleproject/sampleproject/settings.py b/sampleproject/sampleproject/settings.py index 5fbdbac2..8c995d9c 100644 --- a/sampleproject/sampleproject/settings.py +++ b/sampleproject/sampleproject/settings.py @@ -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" ) diff --git a/sampleproject/sampleproject/urls.py b/sampleproject/sampleproject/urls.py index 966f02c6..997233dc 100644 --- a/sampleproject/sampleproject/urls.py +++ b/sampleproject/sampleproject/urls.py @@ -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")), ] diff --git a/tests/test_example_form.py b/tests/test_example_form.py new file mode 100644 index 00000000..5a8661a4 --- /dev/null +++ b/tests/test_example_form.py @@ -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" %}{% endfill %} + {% fill "field:option" %}{% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + rendered = template.render(Context({})) + + assertHTMLEqual( + rendered, + """ +
+
+
+ + + + +
+
+
+ """, + ) + + def test_custom_label(self, components_settings): + _create_tab_components() + template_str = """ + {% load component_tags %} + {% component "form" %} + {% fill "label:project" %}Custom Project Label{% endfill %} + {% fill "field:project" %}{% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + rendered = template.render(Context({})) + + assert "Custom Project Label" in rendered + assert '