Tucano v0.37.2

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
CampoLo que llegaPor qué
Date pickerISO: 2026-12-25, y 2026-12-25T09:30 con data-time — con data-seconds, también los segundosEl campo visible muestra el formato del locale y queda sin name; un <input type="hidden"> con el name original lleva el ISO
Período2026-03-01,2026-03-15Inicio y fin en ISO, separados por coma, en un solo campo
Select múltipleUn valor por opción marcadaQuien envía es el <select multiple> nativo: léelo con getlist()
Color picker#4f46e5; con opacidad menor que 1, #4f46e580data-format lo cambia a rgb o hsl; data-alpha="false" quita la opacidad
MáscaraEl texto con formato, tal como está en el campoLa 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-urlLos archivos, en request.FILESSe quedan en el <input type="file"> nativo y suben con el submit — el form necesita enctype="multipart/form-data"
Upload con data-urlLos ids que devolvió el endpoint, con el name del inputCada archivo ya se subió en el momento; el form envía solo la referencia
EditorHTML 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.

AtributoPara qué
data-typeTipo: los niveles de Django, o loading
data-titleTítulo en negrita encima del texto
data-textTexto; sin él vale el contenido del <div>
data-durationTiempo en pantalla, en ms; false no se cierra solo
data-positiontop-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 enLa 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.