Forms

FileUpload

Drop or choose files: kinds, sizes and thumbnails listed, limits checked per file, removable, with upload progress.

#forms#upload#file#progress#interactive

Demo

Preview
Drop files here orbrowseImages, PDF · up to 5 MB each · 4 files at most

Photos or scans of each receipt.

Drop files here orbrowsePDF, DOCX, TXT, CSV · up to 2 MB each

Nothing chosen yet.
Drop a photo orchoose oneImages

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/file_upload.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/file_upload.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/file_upload.qwe
component FileUploadDemoLive() {    state chosen = []    state progress = null    let uploading = progress != null && progress < 100    if uploading { every(80, () => { progress = min([100, progress + 7]) }) }    <div.demo-stack>        <FileUpload name="fu-docs" label="Documents" accept=".pdf,.docx,.txt,.csv" max_size={2097152}                    progress={progress} on_change={(fs) => {                        chosen = fs                        progress = null                    }}/>        <div.demo-row>            <Button size="sm" disabled={len(chosen) == 0 || uploading} on_click={() => { progress = 0 }}>Upload</Button>            <span.demo-note>{len(chosen) == 0 ? "Nothing chosen yet." : `${len(chosen)} ready, ${file_upload_size(sum(chosen | map((f) => f.size)))} in all.`}</span>        </div>    </div>}view FileUploadDemo() =    <div.demo-stack.demo-narrow>        <FileUpload name="fu-receipts" label="Receipts" accept="image/*,.pdf" max_size={5242880} max_files={4}                    hint="Photos or scans of each receipt." required={true}/>        <FileUploadDemoLive/>        <FileUpload name="fu-avatar" label="Profile photo" accept="image/*" multiple={false}                    text="Drop a photo or" button="choose one"/>    </div>

Source

ui/file_upload.qwe
// FileUpload: files dropped or chosen, listed with their kind, size and (for images)// a thumbnail, each checked against the size, kinds and number allowed, and each// removable. Files picked again are added to those already there. It is a real// <input type="file"> holding the files that passed, so a <form method="post"// enctype="multipart/form-data"> sends them under `name` (see Uploads in the guide);// those that did not stay in the list with what is wrong, and are not sent.////   <form method="post" action="/claims" enctype="multipart/form-data">//       <FileUpload name="receipts" label="Receipts" accept="image/*,.pdf" max_size={5000000} max_files={4}/>//       <Button type="submit">Send</Button>//   </form>//// name ("files"), label, hint, error, required, disabled, field (the input's id,// "file-upload-" + name by default) as for every field.// accept: the kinds taken (".pdf,.docx", "image/*"), checked for dropped files too.// multiple (true): more than one file. max_size: the most bytes a file may have.// max_files: how many files at most. previews (true): thumbnails of images.// text: what the zone says ("Drop files here or"); button: its button's text ("browse").// progress: inside one of your components, how far an upload is, 0 to 100, shown as a// bar under the files (null for none).// on_change(files): inside one of your components, called with the files that passed,// as maps of { name, size, type }, whenever they change.import ui/theme.qwe use ui_clslet file_upload_css = css`.ui-fu { display: grid; gap: 8px; min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-fu-label { font-size: 13px; font-weight: 500; }.ui-fu-req { margin-left: 2px; color: var(--ui-danger); }.ui-fu-hint { margin: 0; font-size: 13px; color: var(--ui-muted); }.ui-fu-error { margin: 0; font-size: 13px; color: var(--ui-danger); }.ui-fu-zone {  position: relative; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 4px 12px;  padding: 22px 16px; border: 1.5px dashed var(--ui-strong); border-radius: var(--ui-radius); background: var(--ui-subtle);  color: var(--ui-muted); text-align: center; transition: border-color .15s, background .15s, box-shadow .15s;}.ui-fu-zone input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }.ui-fu-zone:hover { border-color: var(--ui-accent); }.ui-fu-zone:has(input:focus-visible) { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-fu-over { border-style: solid; border-color: var(--ui-accent); background: var(--ui-accent-soft); }.ui-fu-invalid { border-color: var(--ui-danger); }.ui-fu-zone > svg { width: 30px; height: 30px; color: var(--ui-accent); }.ui-fu-zone-text { display: grid; gap: 2px; }.ui-fu-zone-text b { color: var(--ui-fg); font-weight: 500; }.ui-fu-browse {  display: inline-block; margin-left: 4px; padding: 3px 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm);  background: var(--ui-surface); color: var(--ui-accent); font-weight: 500;}.ui-fu-zone:hover .ui-fu-browse { border-color: var(--ui-accent); }.ui-fu-rules { font-size: 13px; }.ui-fu:has(input:disabled) .ui-fu-zone { opacity: .55; }.ui-fu-zone:has(input:disabled), .ui-fu-zone input:disabled { cursor: not-allowed; border-color: var(--ui-strong); }.ui-fu-list { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }.ui-fu-file {  display: flex; align-items: center; gap: 12px; min-width: 0; padding: 8px 8px 8px 10px; border: 1px solid var(--ui-border);  border-radius: var(--ui-radius-sm); background: var(--ui-surface);}.ui-fu-bad { border-color: color-mix(in srgb, var(--ui-danger) 45%, var(--ui-border)); background: var(--ui-danger-soft); }.ui-fu-thumb { flex: none; width: 40px; height: 40px; border-radius: 6px; object-fit: cover; background: var(--ui-subtle); }.ui-fu-icon {  flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 6px;  background: color-mix(in srgb, var(--k) 14%, transparent); color: var(--k); font: 700 10px/1 var(--ui-font); letter-spacing: .02em;  --k: var(--ui-muted);}.ui-fu-k-image { --k: var(--ui-success); } .ui-fu-k-pdf { --k: var(--ui-danger); } .ui-fu-k-doc { --k: var(--ui-info); }.ui-fu-k-sheet { --k: var(--ui-success); } .ui-fu-k-archive { --k: var(--ui-warning); } .ui-fu-k-audio, .ui-fu-k-video { --k: var(--ui-accent); }.ui-fu-meta { display: grid; flex: 1; min-width: 0; }.ui-fu-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }.ui-fu-size { color: var(--ui-muted); font-size: 12px; font-variant-numeric: tabular-nums; }.ui-fu-why { color: var(--ui-danger); font-size: 12px; }.ui-fu-x {  flex: none; display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: var(--ui-radius-sm);  background: transparent; color: var(--ui-muted); cursor: pointer;}.ui-fu-x:hover { background: var(--ui-subtle); color: var(--ui-fg); }.ui-fu-x:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-fu-x svg { width: 14px; height: 14px; }.ui-fu-progress { display: grid; gap: 4px; font-size: 13px; color: var(--ui-muted); }.ui-fu-progress > span { display: flex; justify-content: space-between; font-variant-numeric: tabular-nums; }.ui-fu-bar { height: 6px; overflow: hidden; border-radius: 999px; background: var(--ui-subtle); }.ui-fu-bar div { height: 100%; border-radius: inherit; background: var(--ui-accent); transition: width .2s; }.ui-fu-done .ui-fu-bar div { background: var(--ui-success); }@media (prefers-reduced-motion: reduce) { .ui-fu-zone, .ui-fu-bar div { transition: none; } }`// A size in bytes as people read it: 812 B, 14.2 KB, 3 MB. Worked out in whole// numbers, so the server and the browser write the same text.fn file_upload_size(n: num) -> str {    if n < 1024 { return `${n} B` }    let unit = n < 1024 * 1024 ? 1024 : (n < 1024 * 1024 * 1024 ? 1024 * 1024 : 1024 * 1024 * 1024)    let tenths = round(n * 10 / unit)    let whole = floor(tenths / 10)    `${whole}${tenths % 10 == 0 ? "" : `.${tenths % 10}`} ${unit == 1024 ? "KB" : (unit == 1024 * 1024 ? "MB" : "GB")}`}// A file's extension, in lower case ("" when it has none).fn file_upload_ext(name: str) -> str {    let parts = split(name, ".")    len(parts) > 1 ? lower(last(parts)) : ""}// What kind of file it is, from its type or extension: image, pdf, doc, sheet,// archive, audio, video or file.fn file_upload_kind(name: str, mime: str = "") -> str {    let ext = file_upload_ext(name)    if starts_with(mime, "image/") || contains(["png", "jpg", "jpeg", "gif", "webp", "svg", "avif", "heic"], ext) { return "image" }    if mime == "application/pdf" || ext == "pdf" { return "pdf" }    if contains(["doc", "docx", "odt", "rtf", "txt", "md", "pages"], ext) { return "doc" }    if contains(["xls", "xlsx", "ods", "csv", "tsv", "numbers"], ext) { return "sheet" }    if contains(["zip", "rar", "7z", "gz", "tar", "tgz"], ext) { return "archive" }    if starts_with(mime, "audio/") || contains(["mp3", "wav", "ogg", "flac", "m4a"], ext) { return "audio" }    if starts_with(mime, "video/") || contains(["mp4", "mov", "webm", "mkv", "avi"], ext) { return "video" }    "file"}// Whether a file is of a kind `accept` takes (".pdf", "image/*", "text/csv", ...).fn file_upload_accepts(accept: str?, name: str, mime: str) -> bool {    if accept == null || trim(accept) == "" { return true }    let kinds = split(lower(accept), ",") | map((a) => trim(a)) | filter((a) => a != "")    any(kinds, (a) => starts_with(a, ".") ? ends_with(lower(name), a)        : (ends_with(a, "/*") ? starts_with(lower(mime), slice(a, 0, len(a) - 1)) : lower(mime) == a))}// The kinds `accept` names, as people say them: "image/*,.pdf" is "Images, PDF".fn file_upload_kinds(accept: str) -> str =    split(accept, ",") | map((a) => trim(a)) | filter((a) => a != "") | map((a) => match lower(a) {        "image/*" => "Images"        "audio/*" => "Audio"        "video/*" => "Video"        "text/*" => "Text"        _ => starts_with(a, ".") ? upper(slice(a, 1)) : a    }) | join(", ")// What the zone says it takes: "Images, PDF · up to 5 MB each · 4 files at most".fn file_upload_rules(accept: str?, max_size: int?, max_files: int?) -> str =    join([accept != null ? file_upload_kinds(accept) : null,          max_size != null ? `up to ${file_upload_size(max_size)} each` : null,          max_files != null ? `${max_files} ${max_files == 1 ? "file" : "files"} at most` : null]         | filter((s) => s != null), " · ")component FileUpload(name: str = "files", label: str? = null, hint: str? = null, error: str? = null,                     required: bool = false, disabled: bool = false, field: str? = null, accept: str? = null,                     multiple: bool = true, max_size: int? = null, max_files: int? = null, previews: bool = true,                     text: str = "Drop files here or", button: str = "browse", progress: int? = null,                     on_change = null, class: str? = null) {    style global file_upload_css    state files = []    state over = false    state seq = 0    let fid = field ?? `file-upload-${name}`    let described = ui_cls([hint != null && `${fid}-hint`, error != null && `${fid}-error`, `${fid}-rules`])    let rules = file_upload_rules(accept, max_size, max_files)    let good = files | filter((f) => f.why == null)    let bad = len(files) - len(good)    // Puts the files that passed in the input, so the form sends them, and says so.    let keep = (input, next: list) => {        let dt = js_new("DataTransfer")        for f in next | filter((f) => f.why == null) => dt.items.add(f.file)        input.files = dt.files        files = next        on_change && on_change(next | filter((f) => f.why == null) | map((f) => { name: f.name, size: f.size, type: f.type }))    }    let entry = (file, kept: list, n: int) => {        let mime = file.type ?? ""        let ok = kept | filter((f) => f.why == null)        let why = !file_upload_accepts(accept, file.name, mime) ? "Not a kind of file taken here"            : max_size != null && file.size > max_size ? `Larger than ${file_upload_size(max_size)}`            : any(ok, (f) => f.name == file.name && f.size == file.size) ? "Already chosen"            : max_files != null && len(ok) >= max_files ? `Only ${max_files} ${max_files == 1 ? "file" : "files"} can be sent`            : null        let url = previews && why == null && starts_with(mime, "image/") ? js("URL.createObjectURL", file) : null        { key: n, name: file.name, size: file.size, type: mime, kind: file_upload_kind(file.name, mime), why, url, file }    }    let remove = (e, f) => {        if f.url != null { js("URL.revokeObjectURL", f.url) }        let input = e.target.closest(".ui-fu").querySelector("input[type=file]")        keep(input, files | filter((x) => x.key != f.key))        input.focus()    }    <div class={ui_cls(["ui-fu", class])}>        {label != null && <label.ui-fu-label for={fid}>            {label}{required && <span.ui-fu-req aria-hidden="true">*</span>}        </label>}        <div class={ui_cls(["ui-fu-zone", over && "ui-fu-over", error != null && "ui-fu-invalid"])}>            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"                 stroke-linejoin="round" aria-hidden="true"><path d="M7 18a4.5 4.5 0 0 1-.6-8.96A6 6 0 0 1 18 8a4 4 0 0 1 0 8h-1"/><path d="M12 12v8M9 15l3-3 3 3"/></svg>            <span.ui-fu-zone-text>                <b>{text}<span.ui-fu-browse>{button}</span></b>                {rules != "" && <span.ui-fu-rules id={`${fid}-rules`}>{rules}</span>}            </span>            <input type="file" id={fid} name={name} accept={accept} multiple={multiple} required={required}                   disabled={disabled} aria-label={label == null ? `${text} ${button}` : null}                   aria-invalid={error != null ? "true" : null} aria-describedby={described}                   @dragenter={() => { over = true }} @dragleave={() => { over = false }} @drop={() => { over = false }}                   @change={(e) => {                       let fl = e.target.files                       let picked = for i in range(0, fl.length) => fl[i]                       if !multiple {                           for f in files => { if f.url != null { js("URL.revokeObjectURL", f.url) } }                       }                       let start = multiple ? files : []                       // A loop and not `reduce`: `entry` makes a preview with `js`, which a function                       // given to a builtin may not do.                       let next = start                       for p in enumerate(picked) { next = next | push(entry(p[1], next, seq + p[0])) }                       seq = seq + len(picked)                       keep(e.target, next)                   }}/>        </div>        {len(files) > 0 ? <ul.ui-fu-list aria-label="Files">            {for f in files => <li key={f.key} class={ui_cls(["ui-fu-file", f.why != null && "ui-fu-bad"])}>                {f.url != null                    ? <img.ui-fu-thumb src={f.url} alt="" width="40" height="40"/>                    : <span class={`ui-fu-icon ui-fu-k-${f.kind}`} aria-hidden="true">{upper(slice(file_upload_ext(f.name), 0, 4))}</span>}                <span.ui-fu-meta>                    <span.ui-fu-name title={f.name}>{f.name}</span>                    {f.why != null                        ? <span.ui-fu-why>{f.why}: not sent</span>                        : <span.ui-fu-size>{file_upload_size(f.size)}</span>}                </span>                <button.ui-fu-x type="button" aria-label={`Remove ${f.name}`} disabled={disabled} @click={(e) => { remove(e, f) }}>                    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"                         aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>                </button>            </li>}        </ul> : null}        <p.ui-sr role="status" aria-live="polite">            {len(files) == 0 ? "" : `${len(good)} ${len(good) == 1 ? "file" : "files"} ready${bad > 0 ? `, ${bad} not taken` : ""}`}        </p>        {progress != null ? <div class={ui_cls(["ui-fu-progress", progress >= 100 && "ui-fu-done"])}>            <span><span>{progress >= 100 ? "Uploaded" : "Uploading…"}</span><span>{progress}%</span></span>            <div.ui-fu-bar role="progressbar" aria-label="Upload" aria-valuemin="0" aria-valuemax="100" aria-valuenow={progress}>                <div style={`width: ${max([0, min([progress, 100])])}%`}></div>            </div>        </div> : null}        {hint != null && <p.ui-fu-hint id={`${fid}-hint`}>{hint}</p>}        {error != null && <p.ui-fu-error id={`${fid}-error`} role="alert">{error}</p>}    </div>}
Thread
56Useful? Like it, or add to its thread below.

Thread 0 posts

On FileUpload: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.