FileUpload
Drop or choose files: kinds, sizes and thumbnails listed, limits checked per file, removable, with upload progress.
Demo
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.qwemain.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>}Related
AvatarUploadA round picture with Change and Remove, showing the picture picked before it is sent, on a real file input.6 views · 6 downloadsFileDropDrop files or click to choose them: a real file input that a form posts, listing names and sizes.5 views · 7 downloadsChecklistInputCheckboxes under a progress line that says how many are done and fills as they are ticked; each posts with a form.5 views · 7 downloadsFormWizardA long form in steps, with the steps shown, Back and Next that check each step, and a review with Edit before it is sent as one form.5 views · 6 downloadsProfileFormAvatar, name, username, bio and website in the usual layout: a plain form posting to your route, shown again with errors.5 views · 8 downloadsUploadProgressOne file's upload: name, bytes sent of its size, speed, time left, a bar, and Cancel; done, paused and error states.4 views · 7 downloads
Thread
Thread 0 posts
On FileUpload: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.