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| Campo | O que chega | Por quê |
|---|---|---|
| Date picker | ISO: 2026-12-25, e 2026-12-25T09:30 com data-time — com data-seconds, os segundos também | O campo visível mostra o formato do locale e fica sem name; um <input type="hidden"> com o name original carrega o ISO |
| Período | 2026-03-01,2026-03-15 | Início e fim em ISO, separados por vírgula, num campo só |
| Select múltiplo | Um valor por opção marcada | É o <select multiple> nativo que posta: leia com getlist() |
| Color picker | #4f46e5; com opacidade abaixo de 1, #4f46e580 | data-format troca para rgb ou hsl; data-alpha="false" tira a opacidade |
| Máscara | O texto formatado, como está no campo | A 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-url | Os arquivos, em request.FILES | Eles ficam no <input type="file"> nativo e sobem no submit — o form precisa de enctype="multipart/form-data" |
Upload com data-url | Os ids que o endpoint devolveu, com o name do input | Cada arquivo já subiu na hora; o form posta só a referência |
| Editor | HTML 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.
| Atributo | Para quê |
|---|---|
data-type | Tipo: os níveis do Django, ou loading |
data-title | Título em negrito acima do texto |
data-text | Texto; sem ele vale o conteúdo do <div> |
data-duration | Tempo na tela, em ms; false não fecha sozinho |
data-position | top-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 em | A 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.