Tucano v0.37.2

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 formDirect (data-url)
When it uploadsOn submitWhen the file is chosen or dropped
The server receivesrequest.FILES, together with the rest of the formOne POST per file to the endpoint
The form postsThe filesOnly the returned ids
Progress per fileNoYes
Cancel and retryNoYes
Needs an endpointNoYes
Works without a <form>NoYes, 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),
]
AttributeDefaultWhat for
data-url—Turns on direct mode: each file uploads here, via POST
data-field-namefileName of the file field in the POST
data-response-ididKey of the id in the JSON response
data-response-urlurlKey of the url in the JSON response
data-delete-url—Removing an already uploaded file calls DELETE on <data-delete-url><id>/
data-auto-uploadtruefalse waits for uploadAll()
data-csrftruefalse 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.

AttributeAcceptsRejects with
acceptExtension, family/* or exact type, separated by commas"Tipo de arquivo não aceito" (file type not accepted)
data-max-size5mb, 500kb, 1gb or bytes"Arquivo maior que 5 MB" (file larger than 5 MB)
data-max-filesNumber 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.

KeyAction
TabReaches the drop zone and, after it, each file's buttons
Enter SpaceOn the drop zone, opens the file chooser dialog

API

Generated from the code on every build — if something is not here, it does not exist.

Markup
input[type=file][data-tuc-upload]
In JS
new Tucano.Upload(alvo, opcoes)
Attributes
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
Methods
getFiles getValue uploadAll clear destroy
Events
tucano:change

Options

The notes in this table come from comments in the source code, which are written in Portuguese.

OptionDefaultWhat for
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