Tucano v0.37.2

Django

A Tucano foi desenhada para o caminho do Django: o campo continua sendo o elemento nativo do formulário, o valor chega no request.POST do jeito que o form espera, e tudo se configura por atributo — no widgets do form, sem uma linha de JavaScript.

Embaixo de cada campo, o que o formulário enviaria agora. Mude os valores para ver.

Carregar

Copie a pasta dist/ para os estáticos do projeto e carregue os dois arquivos no template base. O defer deixa o script rodar depois do HTML, e o CSS já desenha o campo cru no tamanho final, então nada pula quando ele inicializa.

{% load static %}
<link rel="stylesheet" href="{% static 'tucano/tucano.min.css' %}">
<script src="{% static 'tucano/tucano.min.js' %}" defer></script>

Widgets

Cada componente é um atributo data-tuc-*. No Django ele entra pelo attrs do widget, e o valor vazio basta: o atributo existir é o que liga.

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"}),
        }

Use TextInput no date picker, e não DateInput

O DateInput renderiza type="date", e o navegador desenha o próprio calendário ao lado do nosso. O campo de texto com data-tuc-datepicker é o que dá o painel da biblioteca; quem quiser o seletor do sistema no celular pede com data-native.

Com {{ form }}, {{ form.as_div }} ou campo a campo, os atributos saem no HTML e cada campo se inicializa sozinho no carregamento e depois de cada troca do HTMX.

O que chega no POST

O elemento nativo continua dono do valor, então o servidor recebe o que receberia sem a biblioteca. A diferença está onde ela ajuda o form: a data chega em ISO, e não no formato da tela.

event_date  = 2026-12-25T09:30          # DateTimeField faz o parse direto
due_date    = 2026-12-25                # DateField também
period      = 2026-03-01,2026-03-15     # separe na vírgula no clean_
tags        = django                    # request.POST.getlist("tags")
tags        = htmx
color       = #4f46e5
document    = 123.456.789-09            # o texto do campo, formatado
CampoO que chegaPor quê
Date pickerISO: 2026-12-25, e 2026-12-25T09:30 com data-time — com data-seconds, os segundos tambémO campo visível mostra o formato do locale e fica sem name; um <input type="hidden"> com o name original carrega o ISO
Período2026-03-01,2026-03-15Início e fim em ISO, separados por vírgula, num campo só
Select múltiploUm valor por opção marcadaÉ o <select multiple> nativo que posta: leia com getlist()
Color picker#4f46e5; com opacidade abaixo de 1, #4f46e580data-format troca para rgb ou hsl; data-alpha="false" tira a opacidade
MáscaraO texto formatado, como está no campoA máscara é comportamento puro sobre o <input>. Com data-tuc-reveal, um hidden com o name leva o conteúdo sem formatação
Upload sem data-urlOs arquivos, em request.FILESEles ficam no <input type="file"> nativo e sobem no submit — o form precisa de enctype="multipart/form-data"
Upload com data-urlOs ids que o endpoint devolveu, com o name do inputCada arquivo já subiu na hora; o form posta só a referência
EditorHTML sanitizado, no <textarea>O textarea continua sendo o campo; sanitize de novo no servidor antes de publicar

Período no form

O Django não tem campo de intervalo, então o período chega como texto e se separa no clean_.

O período volta ao campo no formato da tela

O date picker lê o valor inicial de um período no formato que ele exibe, 01/03/2026 — 15/03/2026. Quando o form volta com erro, o Django reescreve no campo exatamente o que foi postado — o ISO com vírgula. Um widget que devolve o formato de exibição deixa o campo certo nos dois casos, o valor postado e o 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)

Data simples e data com hora não precisam disso: o valor que o Django escreve — 2026-12-25 ou 2026-12-25 09:30:00 — é lido direto, como no primeiro campo do exemplo acima.

Erros de formulário

Campo com erro é aria-invalid="true", e não uma classe. É o atributo que o leitor de tela anuncia, e o Django 5 o escreve sozinho em todo campo que voltou com erro — então {{ field }} sai pintado de vermelho sem ninguém escrever nada.

{# o Django 5 já põe aria-invalid no campo com erro, e aria-describedby apontando para a ajuda #}
<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 %}

O atributo fica no elemento nativo, e o CSS alcança o controle que o script montou: o select, o campo de cor, o editor e a zona de upload ganham a borda e o anel de erro pelo nativo que está dentro deles. Nenhum componente copia o atributo, e por isso nenhum fica dessincronizado quando o HTMX troca o campo.

CNPJ inválido.

Este campo é obrigatório.

Para o Django 4 ou anterior, ponha o atributo no widget quando o campo tiver erro — no full_clean() do form, depois da validação:

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"

Validação do próprio navegador também pinta, mas só depois de a pessoa mexer: um campo required vazio fica vermelho por :user-invalid ao sair dele ou ao tentar enviar, e não no carregamento.

Mensagens como toast

O framework de messages vira toast sem JavaScript. Ponha o laço no template base; cada <div> é lido, vira toast e sai do DOM.

{% for m in messages %}
  <div data-tuc-toast data-type="{{ m.tags }}">{{ m }}</div>
{% endfor %}

Os níveis debug, info, success, warning e error são traduzidos sozinhos — debug vira info. As extra_tags não atrapalham: o Django as escreve antes do nível ("highlight success"), e o componente usa a primeira palavra que for um tipo conhecido.

AtributoPara quê
data-typeTipo: os níveis do Django, ou loading
data-titleTítulo em negrito acima do texto
data-textTexto; sem ele vale o conteúdo do <div>
data-durationTempo na tela, em ms; false não fecha sozinho
data-positiontop-start, top-center, top-end, bottom-start, bottom-center ou bottom-end (padrão)
from django.contrib import messages

def save(request, pk):
    ...
    messages.success(request, "Contrato salvo.")
    return redirect("contract", pk=pk)

HTMX

HTMX já está coberto. A biblioteca inicializa de novo a cada htmx:afterSwap, dentro do trecho trocado, então campo que chega por swap funciona sem você fazer nada. Quem já está pronto tem data-tuc-ready e é pulado.

Toast disparado pelo servidor

Numa resposta do HTMX não há redirect nem template base para o laço de messages. O servidor manda o toast pelo cabeçalho HX-Trigger, e a biblioteca escuta o evento tucano:toast no <body>:

import json

def save(request, pk):
    ...
    return HttpResponse(status=204, headers={"HX-Trigger": json.dumps(
        {"tucano:toast": {"type": "success", "text": "Contrato salvo"}})})

O objeto aceita as mesmas opções do Tucano.toast(): type, title, text, duration, position. Um texto solto no lugar do objeto vira toast de informação. Se o fragmento trocado trouxer <div data-tuc-toast>, eles também viram toast.

Campo que dispara requisição

Cada componente dispara o change nativo, então hx-trigger="change" funciona sem ajuste. No date picker, porém, quem tem o name é o hidden com o ISO, e não o campo visível: ponha o hx-get no formulário, que inclui todos os campos, em vez de no 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>

Trocar as opções de um select

Um select que já existe e recebe <option> novas por hx-swap="innerHTML" não é inicializado de novo — ele já está pronto. Peça para ele reler as opções:

<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 não veio pelo HTMX

O automático cobre o carregamento e o htmx:afterSwap. Se o HTML entrar por fetch e innerHTML, chame Tucano.init(node) no trecho novo. E pelo npm, importando de tucano, nada se inicializa sozinho: importe tucano/auto, que faz o boot e escuta o HTMX como o script do CDN.

CSRF no upload

No upload direto cada arquivo sobe numa requisição própria, fora do submit do formulário — e o {% csrf_token %} do form não vai junto. Por isso o componente lê o cookie csrftoken e manda o valor no cabeçalho X-CSRFToken, que é o que o Django confere. A remoção por data-delete-url leva o mesmo cabeçalho.

<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">Salvar</button>
</form>
def upload_temp(request):
    uploaded = request.FILES["file"]             # data-field-name muda o "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")         # os ids devolvidos acima
    ...

O cookie só pode ser lido se o JavaScript o enxergar. Com CSRF_COOKIE_HTTPONLY = True ou outro CSRF_COOKIE_NAME, passe o token por conta própria — o {% csrf_token %} do formulário já deixou o valor na página:

const token = document.querySelector('[name=csrfmiddlewaretoken]').value;
new Tucano.Upload('#photos', { url: '/upload/', headers: { 'X-CSRFToken': token } });

Com o HTMX é o mesmo problema pelo outro lado: as requisições dele também precisam do cabeçalho. Declare uma vez no <body>:

<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>

Lembre da limpeza

No upload direto o arquivo chega ao servidor antes de o formulário ser salvo. Se a pessoa sobe e fecha a aba, ele fica lá: uma tarefa apagando temporários com mais de 24 horas resolve.

Tabela e paginação com Paginator

A tabela ordena pelo servidor e a paginação é feita para o Paginator. Os dois são links de verdade que preservam o resto da query string, então ordem, filtro e página convivem na mesma 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 }}">
  {# mantém a ordem ao 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">Vencimento</th>
    <th data-sort="number" data-field="amount" class="is-number">Valor</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">Nenhum contrato encontrado.</td></tr>
    {% endfor %}
  </tbody>
</table>

<div data-tuc-pagination
     data-page="{{ page_obj.number }}"
     data-pages="{{ page_obj.paginator.num_pages }}"></div>
Clique emA URL vira
Cabeçalho "Valor"?q=vale&sort=amount&dir=asc — o page sai, porque trocar a ordem volta para a página 1
De novo no mesmo cabeçalho?q=vale&sort=amount&dir=desc
Página 3?q=vale&sort=amount&dir=desc&page=3

Traduza o ?sort por uma lista fechada, como o ORDERS acima: passar o parâmetro direto ao order_by() deixaria qualquer um ordenar por qualquer campo, e um nome inexistente derruba a view. O "pk" no fim desempata — sem ordem total, a mesma linha pode aparecer em duas páginas. Com uma página só, a paginação não é renderizada. Os detalhes estão nas páginas Tabela e Paginação.