Tucano v0.37.2

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árioDireto (data-url)
Quando sobeNo submitAo escolher ou soltar o arquivo
O servidor receberequest.FILES, junto do resto do formulárioUm POST por arquivo no endpoint
O formulário postaOs arquivosSó os ids devolvidos
Progresso por arquivoNãoSim
Cancelar e repetirNãoSim
Precisa de endpointNãoSim
Funciona sem <form>NãoSim, 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),
]
AtributoPadrãoPara quê
data-url—Liga o modo direto: cada arquivo sobe aqui, por POST
data-field-namefileNome do campo do arquivo no POST
data-response-ididChave do id na resposta JSON
data-response-urlurlChave da url na resposta JSON
data-delete-url—Remover um arquivo já enviado chama DELETE em <data-delete-url><id>/
data-auto-uploadtruefalse espera uploadAll()
data-csrftruefalse 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.

AtributoAceitaRecusa com
acceptExtensão, família/* ou tipo exato, separados por vírgula"Tipo de arquivo não aceito"
data-max-size5mb, 500kb, 1gb ou bytes"Arquivo maior que 5 MB"
data-max-filesNú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.

TeclaAção
TabChega na zona e, depois, nos botões de cada arquivo
Enter EspaçoNa zona, abre a janela de escolher arquivo

API

Gerada do código a cada build — se algo não está aqui, não existe.

Marcação
input[type=file][data-tuc-upload]
Em JS
new Tucano.Upload(alvo, opcoes)
Atributos
data-auto-upload data-csrf data-delete-url data-field-name data-max-files data-max-size data-response-id data-response-url data-url
Métodos
getFiles getValue uploadAll clear destroy
Eventos
tucano:change

Opções

OpçãoPadrãoPara quê
urlnullcom 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{}
csrftruemanda 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
deleteUrlnullse definido, remover chama DELETE aqui
maxSizenull'5mb' ou bytes
maxFilesnull
autoUploadtrueno modo direto, comeca ao soltar
localeundefined
texts{}por cima de Tucano.setTexts({ upload }), so nesta instancia
onChangenull
onErrornull