Tabs
Tab navigation component.
Preview
Content for tab 1.
Content for tab 2.
Content for tab 3.
Usage
views.py
python
from djust import LiveView
from djust.decorators import event_handler
class MyView(LiveView):
template_name = "my_template.html"
def mount(self, request, **kwargs):
self.tabs = [{'label': 'Tab 1', 'content': 'Content for tab 1.'}, {'label': 'Tab 2', 'content': 'Content for tab 2.'}, {'label': 'Tab 3', 'content': 'Content for tab 3.'}]
@event_handler()
def set_tab(self, value=None, **kwargs):
... # write to self.component; the re-render carries itmy_template.html
django
{% load theme_components %}
{% theme_tabs id='gallery-tabs' tabs=tabs active=0 %}Events this component sends to the view: set_tab.
Props
| Name | Type | Required | Default |
|---|---|---|---|
| id | str | required | — |
| tabs | list | required | — |
| active | int | 0 | |
| css_prefix | str | — | |
| attrs | dict | — |
Accessibility
| Requirement | Element | Attribute | Value |
|---|---|---|---|
| Tab list must have role=tablist | div | role | tablist |
Source & styles
| File | Path | Notes |
|---|---|---|
| template | djust_theming/components/tabs.html | Copy it to the same path in your project, or per theme under djust_theming/themes/<theme>/components/. |
| css | djust_theming/components.css | Defines 6 of this component's classes at line 557, 561, 567, 585, 590, 594. Override those rules, or the custom properties they read, in a stylesheet loaded after it. |
CSS variables
--border --foreground --muted --muted-foreground --primary --radius
Template source — tabs.html
django
{% comment %}
Server-driven tabs. Each tab button carries `dj-click="set_tab"` with its index
as `data-value`, so the active tab lives in the host LiveView's state and the
markup below is re-rendered from it.
The `data-theme-*` attributes below are the client fallback, not the primary
path. `components.js` stands down on any page carrying a djust mount root, so a
LiveView page is driven entirely by `dj-click`; a plain Django page — the theme
gallery, the theme editor — has no server to dispatch to and keeps using them,
which is why both sets of hooks coexist here. Removing the fallback outright
was tried and reverted: the theming gallery cannot be a LiveView (all 25
`theme_*` tags are registered with Django's engine only, not the Rust one), so
without these it would have had no working tab set at all.
The fallback is genuinely second-best for the reasons that motivated the
server-driven path: the server already computed `aria-selected`, `tabindex` and
the active class correctly for the initial paint, so the JS existed only to
re-derive what the server had already decided.
The list comparison is deliberately stringified: the DEP-002 `Tabs` descriptor
stores `active` as the string the client sends, while `{% theme_tabs %}` is
called with an int index. Comparing `forloop.counter0 == active` directly would
be False for `0 == "0"` — a live tab set that renders tab 1 active and never
moves.
{% endcomment %}
<div class="{{ css_prefix }}tabs {% if attrs.class %}{{ attrs.class }}{% endif %}"
data-theme-tabs="{{ id }}"
data-active-class="{{ css_prefix }}tab-active"
data-hidden-class="{{ css_prefix }}tab-panel-hidden"
{% if attrs.id %}id="{{ attrs.id }}"{% endif %}>
<div class="{{ css_prefix }}tab-list" role="tablist">
{% for tab in tabs %}
<button class="{{ css_prefix }}tab{% if forloop.counter0|stringformat:'s' == active|stringformat:'s' %} {{ css_prefix }}tab-active{% endif %}"
role="tab"
id="{{ id }}-tab-{{ forloop.counter0 }}"
aria-selected="{% if forloop.counter0|stringformat:'s' == active|stringformat:'s' %}true{% else %}false{% endif %}"
aria-controls="{{ id }}-panel-{{ forloop.counter0 }}"
dj-click="set_tab"
data-value="{{ forloop.counter0 }}"
{% if component_id %}data-component-id="{{ component_id }}"{% endif %}
{% if forloop.counter0|stringformat:'s' != active|stringformat:'s' %}tabindex="-1"{% endif %}>
{{ tab.label }}
</button>
{% endfor %}
</div>
{% for tab in tabs %}
<div class="{{ css_prefix }}tab-panel{% if forloop.counter0|stringformat:'s' != active|stringformat:'s' %} {{ css_prefix }}tab-panel-hidden{% endif %}"
role="tabpanel"
id="{{ id }}-panel-{{ forloop.counter0 }}"
aria-labelledby="{{ id }}-tab-{{ forloop.counter0 }}"
{% if forloop.counter0|stringformat:'s' != active|stringformat:'s' %}hidden{% endif %}>
{{ tab.content|safe }}
</div>
{% endfor %}
</div>