Upload
Drag and drop, thumbnails and type and size validation on top of the native <input type="file">,
which stays in the DOM: name, required and request.FILES keep working.
With a URL, each file uploads right away, with its own progress bar.
On this page the direct upload is simulated in the browser; in your project it goes to the data-url.
Examples
Everything below is the same <input type="file"> with different attributes.
Images only, at most 2
accept, data-max-files and data-max-size. The hint below the drop zone is built from these three.
A single file
Without multiple, choosing another file replaces the previous one instead of adding to it.
A failing server
A response outside 2xx becomes an error on the item, with "Tentar de novo" (retry) and "Remover" (remove).
Upload on demand
autoUpload: false holds the files until uploadAll().
With an error
aria-invalid="true" on the native input paints the drop zone — Django 5 already writes the attribute.
Upload the receipt.
How to use
Mark the <input type="file"> and it initializes on its own, including anything that arrives later via
HTMX. What decides the mode is whether data-url is present.
<!-- In the form: nothing changes in your view -->
<input type="file" name="attachments" multiple data-tuc-upload data-max-size="5mb">
<!-- Direct: uploads right away, the form posts the ids -->
<input type="file" name="photos" multiple accept="image/*"
data-tuc-upload data-url="{% url 'upload-temp' %}">In a Django form
In form mode the <form> needs enctype="multipart/form-data", like any
upload. For multiple files, Django requires a widget that declares it accepts 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">Submit</button>
</form>In JavaScript
For options that do not exist as attributes — extra fields, headers, texts, onError — or to read the list.
const up = new Tucano.Upload('#photos', {
url: '/upload/temp/',
maxSize: '5mb',
maxFiles: 10,
extraData: { folder: 'obras' }, // sent along in each file's FormData
onError: (error, file) => console.warn(file.name, error.message),
});
up.getValue(); // ids returned by the server; File[] in form mode
up.getFiles(); // [{ name, size, type, status, progress, id, url, file }]
up.uploadAll(); // uploads whatever is pending (autoUpload: false)
up.clear();
up.destroy();The two modes
The difference is not only technical: it changes where the upload lives, and what the server needs to have.
| In the form | Direct (data-url) | |
|---|---|---|
| When it uploads | On submit | When the file is chosen or dropped |
| The server receives | request.FILES, together with the rest of the form | One POST per file to the endpoint |
| The form posts | The files | Only the returned ids |
| Progress per file | No | Yes |
| Cancel and retry | No | Yes |
| Needs an endpoint | No | Yes |
Works without a <form> | No | Yes, as a standalone drop zone |
Why there is no progress in form mode
On a regular submit the browser sends everything in a single block and does not report progress — this is not a limitation of the library, it is how HTML works. Progress per file requires each one to upload right away, which is direct mode.
Dropped files are sent on submit too
In form mode, whatever was dropped on the zone is written back to the native input.files — the only way to do that is through DataTransfer. That way a dragged file arrives in request.FILES just like one chosen from the dialog.
Direct upload
Each file goes in its own POST to the data-url, and the endpoint returns JSON with an id. The
component keeps that id and, instead of the file, the form posts the ids.
@require_POST
def upload_temp(request):
uploaded = request.FILES["file"] # data-field-name changes "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),
]| Attribute | Default | What for |
|---|---|---|
data-url | — | Turns on direct mode: each file uploads here, via POST |
data-field-name | file | Name of the file field in the POST |
data-response-id | id | Key of the id in the JSON response |
data-response-url | url | Key of the url in the JSON response |
data-delete-url | — | Removing an already uploaded file calls DELETE on <data-delete-url><id>/ |
data-auto-upload | true | false waits for uploadAll() |
data-csrf | true | false does not send the token |
CSRF is handled for you
The token is read from the csrftoken cookie and sent in X-CSRFToken, on the POST and on the DELETE, only when the URL has the same origin as the page — without it Django responds 403, and the error is not obvious to diagnose. With CSRF_COOKIE_HTTPONLY = True the cookie is not readable: pass the token in headers: { 'X-CSRFToken': '{{ csrf_token }}' }, which takes precedence.
Remember the cleanup
If the person uploads files and closes the tab without saving, they stay on the server. A task that deletes temporary files older than 24 hours takes care of it.
Uploading uses XMLHttpRequest, not fetch: fetch still does not report upload progress
reliably across browsers. Cancelling aborts the request and removes the item from the list; a response outside
2xx or a network failure leaves the item in error, with the message in place of the size.
Type, size and count
accept and multiple are the input's native attributes, and the component respects
both. accept filters the system dialog and is checked again on drop, where the system does not filter.
<!-- by extension -->
<input type="file" accept=".pdf,.docx" data-tuc-upload>
<!-- by family -->
<input type="file" accept="image/*" data-tuc-upload>
<!-- by exact type -->
<input type="file" accept="image/png,image/jpeg" data-tuc-upload>
<!-- mixed, with limits -->
<input type="file" accept=".pdf,image/*" multiple data-tuc-upload
data-max-size="500kb" data-max-files="3">Prefer extensions over MIME types
The browser does not always know a file's type: a .csv often arrives with an empty type, and then accept="text/csv" rejects it while accept=".csv" accepts it. The same goes for .csv, .md, .log and less common formats. For images and PDF, the MIME type is reliable.
| Attribute | Accepts | Rejects with |
|---|---|---|
accept | Extension, family/* or exact type, separated by commas | "Tipo de arquivo não aceito" (file type not accepted) |
data-max-size | 5mb, 500kb, 1gb or bytes | "Arquivo maior que 5 MB" (file larger than 5 MB) |
data-max-files | Number of files accepted in the list | "No máximo 2 arquivos" (at most 2 files) |
The hint below the drop zone is built from these three — image/png, image/jpeg · até 2 MB · no máximo 2 arquivos —,
so it does not need to be written again. The component's default texts are in Portuguese: Tucano.setTexts({ upload }) replaces them for the whole page, and the texts option for a single field. A rejected file does not enter the list: a notice appears with the name and the
reason, which disappears after 5 seconds, and onError(error, file) is called. An accepted image gets a thumbnail.
Value and form
What reaches the server is what would reach it without the component, in each mode.
# In the form: the files, in the same POST as the rest
FILES attachments = relatorio.pdf # request.FILES.getlist("attachments")
FILES attachments = planta.png
# Direct: the endpoint received one POST per file, and the form posts the ids
POST photos = 3f2a9c... # request.POST.getlist("photos")
POST photos = 8b41e0...In direct mode the name leaves the file input and moves to one <input type="hidden"> per
finished file — so only the ids of the files that finished uploading go in the POST, and the file is never sent twice.
Every change to the list fires tucano:change on the input itself, and onChange receives the same data.
document.querySelector('#photos').addEventListener('tucano:change', (e) => {
e.detail.value; // ready ids in direct mode; File[] in form mode
e.detail.files; // each one with status: 'pending' | 'uploading' | 'ready' | 'error'
e.detail.instance; // the Upload
});Keyboard and accessibility
The drop zone is a focusable role="button", described by the type and size hint. The native input leaves
the view, but not the form.
| Key | Action |
|---|---|
Tab | Reaches the drop zone and, after it, each file's buttons |
Enter Space | On the drop zone, opens the file chooser dialog |
- Cancel, remove and retry are
<button>elements witharia-label. - The rejection comes out in a notice with
role="alert": it appears after the action and goes away on its own, so screen reader users need to hear it right away. aria-invalid="true"stays on the native input; the CSS reaches the drop zone from it.
API
Generated from the code on every build — if something is not here, it does not exist.
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:changeOptions
The notes in this table come from comments in the source code, which are written in Portuguese.
| Option | Default | What for |
|---|---|---|
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 |