Django
Tucano se diseñó para el camino de Django: el campo sigue siendo el elemento nativo del formulario,
el valor llega a request.POST tal como el form lo espera, y todo se configura con atributos — en los
widgets del form, sin una línea de JavaScript.
Debajo de cada campo, lo que el formulario enviaría ahora. Cambia los valores para verlo.
Cargar
Copia la carpeta dist/ a los estáticos del proyecto y carga los dos archivos en la plantilla base.
El defer hace que el script se ejecute después del HTML, y el CSS ya dibuja el campo sin inicializar en su
tamaño final, así que nada salta cuando arranca.
{% load static %}
<link rel="stylesheet" href="{% static 'tucano/tucano.min.css' %}">
<script src="{% static 'tucano/tucano.min.js' %}" defer></script>Widgets
Cada componente es un atributo data-tuc-*. En Django entra por el attrs del widget,
y basta con el valor vacío: que el atributo exista es lo que lo activa.
class ContractForm(forms.ModelForm):
class Meta:
model = Contract
fields = ["event_date", "due_date", "state", "tags", "color", "document", "description", "attachment"]
widgets = {
"event_date": forms.TextInput(attrs={"data-tuc-datepicker": "", "data-time": "true"}),
"due_date": forms.TextInput(attrs={"data-tuc-datepicker": "", "data-min": "2026-01-01"}),
"state": forms.Select(attrs={"data-tuc-select": ""}),
"tags": forms.SelectMultiple(attrs={"data-tuc-select": "", "data-max-items": "3"}),
"color": forms.TextInput(attrs={"data-tuc-color": "", "data-alpha": "false"}),
"document": forms.TextInput(attrs={"data-tuc-mask": "cpf-cnpj", "data-validate": "true"}),
"description": forms.Textarea(attrs={"data-tuc-editor": ""}),
"attachment": forms.ClearableFileInput(attrs={"data-tuc-upload": "", "data-max-size": "5mb"}),
}Usa TextInput en el date picker, y no DateInput
DateInput genera type="date", y el navegador dibuja su propio calendario junto al nuestro. El campo de texto con data-tuc-datepicker es el que da el panel de la biblioteca; quien quiera el selector del sistema en el móvil lo pide con data-native.
Con {{ form }}, {{ form.as_div }} o campo por campo, los atributos salen en el HTML y cada campo
se inicializa solo al cargar la página y después de cada intercambio de HTMX.
Lo que llega en el POST
El elemento nativo sigue siendo el dueño del valor, así que el servidor recibe lo mismo que recibiría sin la biblioteca. La diferencia está donde ayuda al form: la fecha llega en ISO, y no en el formato de la pantalla.
event_date = 2026-12-25T09:30 # DateTimeField lo interpreta directamente
due_date = 2026-12-25 # DateField también
period = 2026-03-01,2026-03-15 # sepáralo por la coma en clean_
tags = django # request.POST.getlist("tags")
tags = htmx
color = #4f46e5
document = 123.456.789-09 # el texto del campo, con formato| Campo | Lo que llega | Por qué |
|---|---|---|
| Date picker | ISO: 2026-12-25, y 2026-12-25T09:30 con data-time — con data-seconds, también los segundos | El campo visible muestra el formato del locale y queda sin name; un <input type="hidden"> con el name original lleva el ISO |
| Período | 2026-03-01,2026-03-15 | Inicio y fin en ISO, separados por coma, en un solo campo |
| Select múltiple | Un valor por opción marcada | Quien envía es el <select multiple> nativo: léelo con getlist() |
| Color picker | #4f46e5; con opacidad menor que 1, #4f46e580 | data-format lo cambia a rgb o hsl; data-alpha="false" quita la opacidad |
| Máscara | El texto con formato, tal como está en el campo | La máscara es solo comportamiento sobre el <input>. Con data-tuc-reveal, un hidden con el name lleva el contenido sin formato |
Upload sin data-url | Los archivos, en request.FILES | Se quedan en el <input type="file"> nativo y suben con el submit — el form necesita enctype="multipart/form-data" |
Upload con data-url | Los ids que devolvió el endpoint, con el name del input | Cada archivo ya se subió en el momento; el form envía solo la referencia |
| Editor | HTML saneado, en el <textarea> | El textarea sigue siendo el campo; vuelve a sanearlo en el servidor antes de publicar |
Período en el form
Django no tiene campo de intervalo, así que el período llega como texto y se separa en clean_.
El período vuelve al campo en el formato de la pantalla
El date picker lee el valor inicial de un período en el formato que muestra, 01/03/2026 — 15/03/2026. Cuando el form vuelve con error, Django escribe en el campo exactamente lo que se envió — el ISO con coma. Un widget que devuelve el formato de visualización deja el campo bien en los dos casos, el valor enviado y el inicial.
from datetime import date
from django import forms
class PeriodInput(forms.TextInput):
def format_value(self, value):
if isinstance(value, str) and "," in value:
try:
value = [date.fromisoformat(p) for p in value.split(",", 1)]
except ValueError:
return value
if isinstance(value, (list, tuple)) and len(value) == 2:
return f"{value[0]:%d/%m/%Y} — {value[1]:%d/%m/%Y}"
return super().format_value(value)La fecha simple y la fecha con hora no lo necesitan: el valor que escribe Django — 2026-12-25 o
2026-12-25 09:30:00 — se lee directamente, como en el primer campo del ejemplo de arriba.
Errores de formulario
Un campo con error es aria-invalid="true", y no una clase. Es el atributo que anuncia el lector de
pantalla, y Django 5 lo escribe solo en todo campo que volvió con error — así que {{ field }} sale
pintado de rojo sin que nadie escriba nada.
{# Django 5 ya pone aria-invalid en el campo con error, y aria-describedby apuntando a la ayuda #}
<label class="tuc-label{% if field.field.required %} is-required{% endif %}" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}<p class="tuc-hint" id="{{ field.auto_id }}_helptext">{{ field.help_text }}</p>{% endif %}
{% for error in field.errors %}<p class="tuc-error">{{ error }}</p>{% endfor %}El atributo se queda en el elemento nativo, y el CSS alcanza el control que montó el script: el select, el campo de color, el editor y la zona de upload reciben el borde y el anillo de error a través del nativo que llevan dentro. Ningún componente copia el atributo, y por eso ninguno se desincroniza cuando HTMX reemplaza el campo.
CNPJ no válido.
Este campo es obligatorio.
En Django 4 o anterior, pon el atributo en el widget cuando el campo tenga error — en el full_clean() del form,
después de la validación:
class BaseForm(forms.Form):
def full_clean(self):
super().full_clean()
for name in self.errors:
if name in self.fields:
self.fields[name].widget.attrs["aria-invalid"] = "true"La validación del propio navegador también pinta, pero solo después de que la persona interactúe: un campo
required vacío se pone rojo por :user-invalid al salir de él o al intentar enviar, y no al cargar.
Mensajes como toast
El framework de messages se convierte en toast sin JavaScript. Pon el bucle en la plantilla base; cada
<div> se lee, se convierte en toast y sale del DOM.
{% for m in messages %}
<div data-tuc-toast data-type="{{ m.tags }}">{{ m }}</div>
{% endfor %}Los niveles debug, info, success, warning y error se
traducen solos — debug pasa a info. Las extra_tags no molestan: Django las
escribe antes del nivel ("highlight success"), y el componente usa la primera palabra que sea un tipo
conocido.
| Atributo | Para qué |
|---|---|
data-type | Tipo: los niveles de Django, o loading |
data-title | Título en negrita encima del texto |
data-text | Texto; sin él vale el contenido del <div> |
data-duration | Tiempo en pantalla, en ms; false no se cierra solo |
data-position | top-start, top-center, top-end, bottom-start, bottom-center o bottom-end (por defecto) |
from django.contrib import messages
def save(request, pk):
...
messages.success(request, "Contrato guardado.")
return redirect("contract", pk=pk)HTMX
HTMX ya está cubierto. La biblioteca vuelve a inicializar en cada htmx:afterSwap, dentro del
fragmento intercambiado, así que un campo que llega por swap funciona sin que hagas nada. Lo que ya está listo tiene
data-tuc-ready y se salta.
Toast lanzado por el servidor
En una respuesta de HTMX no hay redirect ni plantilla base para el bucle de messages. El servidor envía el
toast por la cabecera HX-Trigger, y la biblioteca escucha el evento tucano:toast en el
<body>:
import json
def save(request, pk):
...
return HttpResponse(status=204, headers={"HX-Trigger": json.dumps(
{"tucano:toast": {"type": "success", "text": "Contrato guardado"}})})El objeto acepta las mismas opciones que Tucano.toast(): type, title,
text, duration, position. Un texto suelto en lugar del objeto se convierte en un
toast de información. Si el fragmento intercambiado trae <div data-tuc-toast>, también se convierten en toast.
Campo que lanza una petición
Cada componente dispara el change nativo, así que hx-trigger="change" funciona sin ajustes. En el
date picker, sin embargo, quien tiene el name es el hidden con el ISO, y no el campo visible: pon el
hx-get en el formulario, que incluye todos los campos, en lugar de en el input.
<form hx-get="{% url 'agenda' %}" hx-trigger="change" hx-target="#results">
<input type="text" name="day" data-tuc-datepicker>
<select name="room" data-tuc-select>...</select>
</form>Cambiar las opciones de un select
Un select que ya existe y recibe <option> nuevas por hx-swap="innerHTML" no se
vuelve a inicializar — ya está listo. Pídele que vuelva a leer las opciones:
<select name="state" data-tuc-select hx-get="{% url 'cities' %}" hx-target="#city">...</select>
<select name="city" id="city" data-tuc-select></select>document.addEventListener('htmx:afterSwap', (e) => {
if (e.target.matches('select[data-tuc-select]')) e.target._tucano?.refresh();
});HTML que no llegó por HTMX
Lo automático cubre la carga y el htmx:afterSwap. Si el HTML entra por fetch e innerHTML, llama a Tucano.init(node) sobre el fragmento nuevo. Y por npm, importando de tucano, nada se inicializa solo: importa tucano/auto, que hace el arranque y escucha a HTMX como el script del CDN.
CSRF en el upload
En el upload directo cada archivo sube en su propia petición, fuera del submit del formulario — y el
{% csrf_token %} del form no va con él. Por eso el componente lee la cookie csrftoken y
envía el valor en la cabecera X-CSRFToken, que es lo que Django comprueba. La eliminación por
data-delete-url lleva la misma cabecera.
<form method="post" action="{% url 'gallery-save' %}">
{% csrf_token %}
<input type="file" name="photos" multiple accept="image/*"
data-tuc-upload data-url="{% url 'upload-temp' %}" data-max-size="5mb">
<button class="tuc-btn is-primary">Guardar</button>
</form>def upload_temp(request):
uploaded = request.FILES["file"] # data-field-name cambia el "file"
temp = TempUpload.objects.create(file=uploaded)
return JsonResponse({"id": str(temp.id), "url": temp.file.url})
def gallery_save(request):
ids = request.POST.getlist("photos") # los ids devueltos arriba
...La cookie solo se puede leer si JavaScript la ve. Con CSRF_COOKIE_HTTPONLY = True u otro
CSRF_COOKIE_NAME, pasa el token por tu cuenta — el {% csrf_token %} del formulario ya
dejó el valor en la página:
const token = document.querySelector('[name=csrfmiddlewaretoken]').value;
new Tucano.Upload('#photos', { url: '/upload/', headers: { 'X-CSRFToken': token } });Con HTMX es el mismo problema desde el otro lado: sus peticiones también necesitan la cabecera. Decláralo una
vez en el <body>:
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>No olvides la limpieza
En el upload directo el archivo llega al servidor antes de que se guarde el formulario. Si la persona lo sube y cierra la pestaña, se queda allí: una tarea que borre los temporales con más de 24 horas lo resuelve.
Tabla y paginación con Paginator
La tabla ordena en el servidor y la paginación está hecha para Paginator. Los dos son enlaces
de verdad que conservan el resto de la query string, así que orden, filtro y página conviven en la misma URL.
from django.core.paginator import Paginator
ORDERS = {"customer": "name", "amount": "amount", "due_date": "due_date"}
def contracts(request):
qs = Contract.objects.all()
if q := request.GET.get("q"):
qs = qs.filter(name__icontains=q)
field = ORDERS.get(request.GET.get("sort"), "name")
if request.GET.get("dir") == "desc":
field = f"-{field}"
qs = qs.order_by(field, "pk") # ordenar ANTES de paginar
page_obj = Paginator(qs, 20).get_page(request.GET.get("page"))
return render(request, "contracts/list.html", {"page_obj": page_obj})<form method="get">
<input class="tuc-input" type="search" name="q" value="{{ request.GET.q }}">
{# mantiene el orden al filtrar #}
<input type="hidden" name="sort" value="{{ request.GET.sort }}">
<input type="hidden" name="dir" value="{{ request.GET.dir }}">
</form>
<table data-tuc-table>
<thead><tr>
<th data-sort="text" data-field="customer">Cliente</th>
<th data-sort="date" data-field="due_date">Vencimiento</th>
<th data-sort="number" data-field="amount" class="is-number">Importe</th>
</tr></thead>
<tbody>
{% for obj in page_obj %}
<tr data-id="{{ obj.pk }}">
<td>{{ obj.name }}</td>
<td>{{ obj.due_date|date:"d/m/Y" }}</td>
<td class="is-number" data-tuc-format="brl">{{ obj.amount }}</td>
</tr>
{% empty %}
<tr><td colspan="3" class="tuc-table__empty">No se encontraron contratos.</td></tr>
{% endfor %}
</tbody>
</table>
<div data-tuc-pagination
data-page="{{ page_obj.number }}"
data-pages="{{ page_obj.paginator.num_pages }}"></div>| Clic en | La URL pasa a ser |
|---|---|
| Cabecera "Importe" | ?q=vale&sort=amount&dir=asc — el page desaparece, porque cambiar el orden vuelve a la página 1 |
| Otra vez en la misma cabecera | ?q=vale&sort=amount&dir=desc |
| Página 3 | ?q=vale&sort=amount&dir=desc&page=3 |
Traduce el ?sort con una lista cerrada, como ORDERS arriba: pasar el parámetro directamente a
order_by() dejaría que cualquiera ordenara por cualquier campo, y un nombre inexistente tumba la vista. El
"pk" al final desempata — sin un orden total, la misma fila puede aparecer en dos páginas. Con una sola
página, la paginación no se renderiza. Los detalles están en las páginas Tabla y
Paginación.