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 formulario | Directo (data-url) | |
|---|---|---|
| Cuándo se sube | En el submit | Al elegir o soltar el archivo |
| El servidor recibe | request.FILES, junto con el resto del formulario | Un POST por archivo en el endpoint |
| El formulario envía | Los archivos | Solo los ids devueltos |
| Progreso por archivo | No | Sí |
| Cancelar y reintentar | No | Sí |
| Necesita endpoint | No | Sí |
Funciona sin <form> | No | Sí, 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),
]| Atributo | Por defecto | Para qué |
|---|---|---|
data-url | — | Activa el modo directo: cada archivo se sube aquí, por POST |
data-field-name | file | Nombre del campo del archivo en el POST |
data-response-id | id | Clave del id en la respuesta JSON |
data-response-url | url | Clave 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-upload | true | false espera a uploadAll() |
data-csrf | true | false 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.
| Atributo | Acepta | Rechaza con |
|---|---|---|
accept | Extensión, familia/* o tipo exacto, separados por comas | "Tipo de arquivo não aceito" (tipo de archivo no aceptado) |
data-max-size | 5mb, 500kb, 1gb o bytes | "Arquivo maior que 5 MB" (archivo mayor que 5 MB) |
data-max-files | Nú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.
| Tecla | Acción |
|---|---|
Tab | Llega a la zona y, después, a los botones de cada archivo |
Enter Espacio | En la zona, abre la ventana para elegir archivo |
- Cancelar, quitar y reintentar son
<button>conaria-label. - El rechazo sale en un aviso con
role="alert": aparece después de la acción y desaparece solo, así que quien usa lector de pantalla necesita oírlo en el momento. aria-invalid="true"queda en el input nativo; el CSS alcanza la zona a partir de él.
API
Generada a partir del código en cada build — si algo no está aquí, no 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:changeOpciones
Las notas de esta tabla salen de los comentarios del código fuente, que están escritos en portugués.
| Opción | Por defecto | 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 |