Upload
Arrastar e soltar, miniatura e validação de tipo e tamanho sobre o <input type="file">
nativo, que continua no DOM: name, required e request.FILES seguem valendo.
Com uma URL, cada arquivo sobe na hora, com barra própria.
Nesta página o envio direto é simulado no navegador; no seu projeto ele vai para o data-url.
Exemplos
Tudo abaixo é o mesmo <input type="file"> com atributos diferentes.
Só imagem, no máximo 2
accept, data-max-files e data-max-size. A dica embaixo da zona sai desses três.
Um arquivo só
Sem multiple, escolher outro troca o anterior em vez de somar.
Servidor que falha
Resposta fora de 2xx vira erro no item, com "Tentar de novo" e "Remover".
Enviar quando pedir
autoUpload: false segura os arquivos até uploadAll().
Com erro
aria-invalid="true" no input nativo pinta a zona — o Django 5 já escreve o atributo.
Envie o comprovante.
Como usar
Marque o <input type="file"> e ele inicializa sozinho, inclusive o que chegar depois por
HTMX. O que decide o modo é a presença de data-url.
<!-- No formulário: nada muda na sua view -->
<input type="file" name="attachments" multiple data-tuc-upload data-max-size="5mb">
<!-- Direto: sobe na hora, o formulário posta os ids -->
<input type="file" name="photos" multiple accept="image/*"
data-tuc-upload data-url="{% url 'upload-temp' %}">No formulário do Django
No modo formulário o <form> precisa de enctype="multipart/form-data", como qualquer
upload. Para vários arquivos, o Django exige um widget que declare aceitar multiple.
class MultipleFileInput(forms.FileInput):
allow_multiple_selected = True
class TicketForm(forms.Form):
contract = forms.FileField(
widget=forms.FileInput(attrs={"data-tuc-upload": "", "accept": ".pdf", "data-max-size": "10mb"}),
)
attachments = forms.FileField(
required=False,
widget=MultipleFileInput(attrs={"data-tuc-upload": "", "multiple": True, "data-max-files": "5"}),
)
def open_ticket(request):
form = TicketForm(request.POST, request.FILES)
if form.is_valid():
for uploaded in request.FILES.getlist("attachments"):
...<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form }}
<button type="submit" class="tuc-btn is-primary">Enviar</button>
</form>Em JavaScript
Para opções que não existem como atributo — campos extras, cabeçalhos, textos, onError — ou para ler a lista.
const up = new Tucano.Upload('#photos', {
url: '/upload/temp/',
maxSize: '5mb',
maxFiles: 10,
extraData: { folder: 'obras' }, // vai junto no FormData de cada arquivo
onError: (error, file) => console.warn(file.name, error.message),
});
up.getValue(); // ids devolvidos pelo servidor; File[] no modo formulário
up.getFiles(); // [{ name, size, type, status, progress, id, url, file }]
up.uploadAll(); // sobe o que estiver pendente (autoUpload: false)
up.clear();
up.destroy();Os dois modos
A diferença não é só técnica: muda onde o upload vive, e o que o servidor precisa ter.
| No formulário | Direto (data-url) | |
|---|---|---|
| Quando sobe | No submit | Ao escolher ou soltar o arquivo |
| O servidor recebe | request.FILES, junto do resto do formulário | Um POST por arquivo no endpoint |
| O formulário posta | Os arquivos | Só os ids devolvidos |
| Progresso por arquivo | Não | Sim |
| Cancelar e repetir | Não | Sim |
| Precisa de endpoint | Não | Sim |
Funciona sem <form> | Não | Sim, como zona de envio solta |
Por que não há progresso no modo formulário
Num submit comum o navegador envia tudo num bloco só e não reporta o andamento — não é uma limitação daqui, é como o HTML funciona. Progresso por arquivo exige que cada um suba na hora, que é o modo direto.
Arrastado também vai no submit
No modo formulário, o que foi solto na zona é escrito de volta no input.files nativo — a única forma de fazer isso é por DataTransfer. Assim o arquivo arrastado chega em request.FILES igual ao escolhido pela janela.
Upload direto
Cada arquivo vai num POST próprio para o data-url, e o endpoint devolve JSON com um id. O
componente guarda esse id e, no lugar do arquivo, o formulário posta os ids.
@require_POST
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 delete_temp(request, pk): # data-delete-url="/upload/temp/"
if request.method == "DELETE":
TempUpload.objects.filter(pk=pk).delete()
return HttpResponse(status=204)urlpatterns = [
path("upload/temp/", upload_temp, name="upload-temp"),
path("upload/temp/<uuid:pk>/", delete_temp),
]| Atributo | Padrão | Para quê |
|---|---|---|
data-url | — | Liga o modo direto: cada arquivo sobe aqui, por POST |
data-field-name | file | Nome do campo do arquivo no POST |
data-response-id | id | Chave do id na resposta JSON |
data-response-url | url | Chave da url na resposta JSON |
data-delete-url | — | Remover um arquivo já enviado chama DELETE em <data-delete-url><id>/ |
data-auto-upload | true | false espera uploadAll() |
data-csrf | true | false não envia o token |
O CSRF vai sozinho
O token é lido do cookie csrftoken e enviado em X-CSRFToken, no POST e no DELETE, só quando a URL é da mesma origem da página — sem ele o Django responde 403, e o erro não é óbvio de diagnosticar. Com CSRF_COOKIE_HTTPONLY = True o cookie não é legível: passe o token em headers: { 'X-CSRFToken': '{{ csrf_token }}' }, que tem precedência.
Lembre da limpeza
Se a pessoa sobe arquivos e fecha a aba sem salvar, eles ficaram no servidor. Uma tarefa que apaga temporários com mais de 24 horas resolve.
O envio usa XMLHttpRequest, e não fetch: fetch ainda não reporta progresso de upload
de forma confiável entre navegadores. Cancelar aborta a requisição e tira o item da lista; resposta fora de
2xx ou falha de rede deixam o item em erro, com a mensagem no lugar do tamanho.
Tipo, tamanho e quantidade
accept e multiple são os atributos nativos do input, e o componente respeita os
dois. O accept filtra a janela do sistema e é conferido de novo ao arrastar, onde o sistema não filtra.
<!-- por extensão -->
<input type="file" accept=".pdf,.docx" data-tuc-upload>
<!-- por família -->
<input type="file" accept="image/*" data-tuc-upload>
<!-- por tipo exato -->
<input type="file" accept="image/png,image/jpeg" data-tuc-upload>
<!-- misturando, com limites -->
<input type="file" accept=".pdf,image/*" multiple data-tuc-upload
data-max-size="500kb" data-max-files="3">Prefira extensão a tipo MIME
O navegador nem sempre sabe o tipo de um arquivo: um .csv costuma chegar com type vazio, e aí accept="text/csv" recusa enquanto accept=".csv" aceita. Vale para .csv, .md, .log e formatos menos comuns. Para imagem e PDF, o MIME é confiável.
| Atributo | Aceita | Recusa com |
|---|---|---|
accept | Extensão, família/* ou tipo exato, separados por vírgula | "Tipo de arquivo não aceito" |
data-max-size | 5mb, 500kb, 1gb ou bytes | "Arquivo maior que 5 MB" |
data-max-files | Número de arquivos aceitos na lista | "No máximo 2 arquivos" |
A dica embaixo da zona é montada desses três — image/png, image/jpeg · até 2 MB · no máximo 2 arquivos —,
então não precisa ser escrita de novo. Arquivo recusado não entra na lista: aparece um aviso com o nome e o
motivo, que some em 5 segundos, e onError(error, file) é chamado. Imagem aceita ganha miniatura.
Valor e formulário
O que chega no servidor é o que chegaria sem o componente, em cada modo.
# No formulário: os arquivos, no mesmo POST do resto
FILES attachments = relatorio.pdf # request.FILES.getlist("attachments")
FILES attachments = planta.png
# Direto: o endpoint recebeu um POST por arquivo, e o formulário posta os ids
POST photos = 3f2a9c... # request.POST.getlist("photos")
POST photos = 8b41e0...No modo direto o name sai do input de arquivo e vai para um <input type="hidden"> por
arquivo pronto — então só os ids dos que terminaram de subir entram no POST, e o arquivo nunca vai duas vezes.
Cada mudança na lista dispara tucano:change no próprio input, e o onChange recebe os mesmos dados.
document.querySelector('#photos').addEventListener('tucano:change', (e) => {
e.detail.value; // ids prontos no modo direto; File[] no formulário
e.detail.files; // cada um com status: 'pending' | 'uploading' | 'ready' | 'error'
e.detail.instance; // o Upload
});Teclado e acessibilidade
A zona é um role="button" focável, descrito pela dica de tipo e tamanho. O input nativo sai da
vista, mas não do formulário.
| Tecla | Ação |
|---|---|
Tab | Chega na zona e, depois, nos botões de cada arquivo |
Enter Espaço | Na zona, abre a janela de escolher arquivo |
- Cancelar, remover e tentar de novo são
<button>comaria-label. - A recusa sai num aviso com
role="alert": ela aparece depois da ação e some sozinha, então quem usa leitor de tela precisa ouvi-la na hora. aria-invalid="true"fica no input nativo; o CSS alcança a zona a partir dele.
API
Gerada do código a cada build — se algo não está aqui, não existe.
input[type=file][data-tuc-upload]new Tucano.Upload(alvo, opcoes)data-auto-upload data-csrf data-delete-url data-field-name data-max-files data-max-size data-response-id data-response-url data-urlgetFiles getValue uploadAll clear destroytucano:changeOpções
| Opção | Padrão | Para quê |
|---|---|---|
url | null | com url: upload direto. sem: os arquivos vao no submit |
method | 'POST' | |
fieldName | 'file' | nome do campo no FormData do upload direto |
extraData | {} | campos extras enviados junto |
headers | {} | |
csrf | true | manda X-CSRFToken lido do cookie (Django), so para a mesma origem |
responseId | 'id' | chave do id na resposta JSON |
responseUrl | 'url' | chave da url na resposta JSON |
deleteUrl | null | se definido, remover chama DELETE aqui |
maxSize | null | '5mb' ou bytes |
maxFiles | null | |
autoUpload | true | no modo direto, comeca ao soltar |
locale | undefined | |
texts | {} | por cima de Tucano.setTexts({ upload }), so nesta instancia |
onChange | null | |
onError | null |