Tucano v0.37.2

Upload

Arrastrar y soltar, miniatura y validación de tipo y tamaño sobre el <input type="file"> nativo, que sigue en el DOM: name, required y request.FILES siguen funcionando. Con una URL, cada archivo se sube al momento, con su propia barra.

En esta página el envío directo se simula en el navegador; en tu proyecto va al data-url.

Ejemplos

Todo lo de abajo es el mismo <input type="file"> con atributos distintos.

Solo imágenes, 2 como máximo

accept, data-max-files y data-max-size. La pista debajo de la zona sale de estos tres.

Un solo archivo

Sin multiple, elegir otro reemplaza el anterior en lugar de sumarlo.

Servidor que falla

Una respuesta fuera de 2xx se vuelve un error en el ítem, con "Tentar de novo" (reintentar) y "Remover" (quitar).

Enviar cuando se pida

autoUpload: false retiene los archivos hasta uploadAll().

Con error

aria-invalid="true" en el input nativo pinta la zona — Django 5 ya escribe el atributo.

Sube el comprobante.

Cómo usar

Marca el <input type="file"> y se inicializa solo, incluso lo que llegue después por HTMX. Lo que decide el modo es la presencia de data-url.

<!-- En el formulario: nada cambia en tu view -->
<input type="file" name="attachments" multiple data-tuc-upload data-max-size="5mb">

<!-- Directo: se sube al momento, el formulario envía los ids -->
<input type="file" name="photos" multiple accept="image/*"
       data-tuc-upload data-url="{% url 'upload-temp' %}">

En el formulario de Django

En el modo formulario el <form> necesita enctype="multipart/form-data", como cualquier upload. Para varios archivos, Django exige un widget que declare aceptar 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>

En JavaScript

Para opciones que no existen como atributo — campos extra, cabeceras, textos, onError — o para leer la lista.

const up = new Tucano.Upload('#photos', {
  url: '/upload/temp/',
  maxSize: '5mb',
  maxFiles: 10,
  extraData: { folder: 'obras' },        // va junto en el FormData de cada archivo
  onError: (error, file) => console.warn(file.name, error.message),
});

up.getValue();    // ids devueltos por el servidor; File[] en el modo formulario
up.getFiles();    // [{ name, size, type, status, progress, id, url, file }]
up.uploadAll();   // sube lo que esté pendiente (autoUpload: false)
up.clear();
up.destroy();

Los dos modos

La diferencia no es solo técnica: cambia dónde vive el upload, y qué necesita tener el servidor.

En el formularioDirecto (data-url)
Cuándo se subeEn el submitAl elegir o soltar el archivo
El servidor reciberequest.FILES, junto con el resto del formularioUn POST por archivo en el endpoint
El formulario envíaLos archivosSolo los ids devueltos
Progreso por archivoNoSí
Cancelar y reintentarNoSí
Necesita endpointNoSí
Funciona sin <form>NoSí, como zona de envío suelta

Por qué no hay progreso en el modo formulario

En un submit común el navegador envía todo en un solo bloque y no informa el avance — no es una limitación de la biblioteca, es cómo funciona HTML. El progreso por archivo exige que cada uno se suba al momento, que es el modo directo.

Lo arrastrado también va en el submit

En el modo formulario, lo que se soltó en la zona se escribe de vuelta en el input.files nativo — la única forma de hacerlo es con DataTransfer. Así el archivo arrastrado llega a request.FILES igual que el elegido desde la ventana.

Upload directo

Cada archivo va en un POST propio al data-url, y el endpoint devuelve JSON con un id. El componente guarda ese id y, en lugar del archivo, el formulario envía los ids.

@require_POST
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 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),
]
AtributoPor defectoPara qué
data-url—Activa el modo directo: cada archivo se sube aquí, por POST
data-field-namefileNombre del campo del archivo en el POST
data-response-ididClave del id en la respuesta JSON
data-response-urlurlClave de la url en la respuesta JSON
data-delete-url—Quitar un archivo ya enviado llama a DELETE en <data-delete-url><id>/
data-auto-uploadtruefalse espera a uploadAll()
data-csrftruefalse no envía el token

El CSRF va solo

El token se lee de la cookie csrftoken y se envía en X-CSRFToken, en el POST y en el DELETE, solo cuando la URL es del mismo origen que la página — sin él Django responde 403, y el error no es fácil de diagnosticar. Con CSRF_COOKIE_HTTPONLY = True la cookie no se puede leer: pasa el token en headers: { 'X-CSRFToken': '{{ csrf_token }}' }, que tiene prioridad.

No olvides la limpieza

Si la persona sube archivos y cierra la pestaña sin guardar, quedan en el servidor. Una tarea que borre los temporales con más de 24 horas lo resuelve.

El envío usa XMLHttpRequest, y no fetch: fetch todavía no informa el progreso de subida de forma confiable entre navegadores. Cancelar aborta la petición y quita el ítem de la lista; una respuesta fuera de 2xx o un fallo de red dejan el ítem en error, con el mensaje en lugar del tamaño.

Tipo, tamaño y cantidad

accept y multiple son los atributos nativos del input, y el componente respeta los dos. El accept filtra la ventana del sistema y se comprueba de nuevo al arrastrar, donde el sistema no filtra.

<!-- por extensión -->
<input type="file" accept=".pdf,.docx" data-tuc-upload>

<!-- por familia -->
<input type="file" accept="image/*" data-tuc-upload>

<!-- por tipo exacto -->
<input type="file" accept="image/png,image/jpeg" data-tuc-upload>

<!-- mezclando, con límites -->
<input type="file" accept=".pdf,image/*" multiple data-tuc-upload
       data-max-size="500kb" data-max-files="3">

Prefiere la extensión al tipo MIME

El navegador no siempre sabe el tipo de un archivo: un .csv suele llegar con type vacío, y entonces accept="text/csv" lo rechaza mientras accept=".csv" lo acepta. Vale para .csv, .md, .log y formatos menos comunes. Para imágenes y PDF, el MIME es confiable.

AtributoAceptaRechaza con
acceptExtensión, familia/* o tipo exacto, separados por comas"Tipo de arquivo não aceito" (tipo de archivo no aceptado)
data-max-size5mb, 500kb, 1gb o bytes"Arquivo maior que 5 MB" (archivo mayor que 5 MB)
data-max-filesNúmero de archivos aceptados en la lista"No máximo 2 arquivos" (2 archivos como máximo)

La pista debajo de la zona se arma con estos tres — image/png, image/jpeg · até 2 MB · no máximo 2 arquivos —, así que no hace falta escribirla de nuevo. Los textos por defecto del componente están en portugués: Tucano.setTexts({ upload }) los reemplaza en toda la página, y la opción texts en un solo campo. Un archivo rechazado no entra en la lista: aparece un aviso con el nombre y el motivo, que desaparece en 5 segundos, y se llama a onError(error, file). Una imagen aceptada recibe miniatura.

Valor y formulario

Lo que llega al servidor es lo que llegaría sin el componente, en cada modo.

# En el formulario: los archivos, en el mismo POST que el resto
FILES  attachments = relatorio.pdf   # request.FILES.getlist("attachments")
FILES  attachments = planta.png

# Directo: el endpoint recibió un POST por archivo, y el formulario envía los ids
POST   photos      = 3f2a9c...       # request.POST.getlist("photos")
POST   photos      = 8b41e0...

En el modo directo el name sale del input de archivo y pasa a un <input type="hidden"> por archivo listo — así solo los ids de los que terminaron de subir entran en el POST, y el archivo nunca va dos veces.

Cada cambio en la lista dispara tucano:change en el propio input, y onChange recibe los mismos datos.

document.querySelector('#photos').addEventListener('tucano:change', (e) => {
  e.detail.value;      // ids listos en el modo directo; File[] en el formulario
  e.detail.files;      // cada uno con status: 'pending' | 'uploading' | 'ready' | 'error'
  e.detail.instance;   // el Upload
});

Teclado y accesibilidad

La zona es un role="button" enfocable, descrito por la pista de tipo y tamaño. El input nativo sale de la vista, pero no del formulario.

TeclaAcción
TabLlega a la zona y, después, a los botones de cada archivo
Enter EspacioEn la zona, abre la ventana para elegir archivo

API

Generada a partir del código en cada build — si algo no está aquí, no existe.

Marcado
input[type=file][data-tuc-upload]
En 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

Opciones

Las notas de esta tabla salen de los comentarios del código fuente, que están escritos en portugués.

OpciónPor defectoPara 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