Upload
An upload screen is a target for files and a list of what is on its way. DropZone takes the files — a drag and the picker both end in one select event — and everything after that is yours: a row per file, a ProgressBar on each, and somewhere to say what was turned away.
import { Alert, DropZone, DropZoneTrigger, EmptyState, ProgressBar } from "ropav";Drop images or PDFs here, or browse
Up to 2 MB each
upload.vue
<script setup lang="ts">
import {
Alert,
AlertContent,
AlertDescription,
AlertIndicator,
AlertTitle,
CloseButton,
DropZone,
DropZoneTrigger,
EmptyState,
Label,
ProgressBar,
ProgressBarFill,
ProgressBarOutput,
ProgressBarTrack,
} from "ropav";
import { onBeforeUnmount, shallowRef, triggerRef } from "vue";
const MAX_BYTES = 2 * 1024 * 1024;
interface Upload {
id: number;
name: string;
progress: number;
}
const uploads = shallowRef<Upload[]>([]);
const refused = shallowRef<string[]>([]);
const timers = new Map<number, ReturnType<typeof setInterval>>();
const stop = (id: number) => {
clearInterval(timers.get(id));
timers.delete(id);
};
onBeforeUnmount(() => {
for (const id of [...timers.keys()]) stop(id);
});
/* Stands in for the request. Everything above it is the part worth copying. */
const send = (upload: Upload) => {
timers.set(
upload.id,
setInterval(() => {
upload.progress = Math.min(100, upload.progress + 20);
triggerRef(uploads);
if (upload.progress === 100) stop(upload.id);
}, 400),
);
};
const onSelect = (files: File[]) => {
/*
* `accept` has already dropped the wrong types without saying so. A size cap is not something it
* can express, which is why this half is the half that can explain itself.
*/
const tooBig = files.filter((file) => file.size > MAX_BYTES);
const taken = files.filter((file) => file.size <= MAX_BYTES);
refused.value = tooBig.map((file) => file.name);
const added = taken.map((file, index) => ({
id: Date.now() + index,
name: file.name,
progress: 0,
}));
uploads.value = [...uploads.value, ...added];
added.forEach(send);
};
const remove = (id: number) => {
stop(id);
uploads.value = uploads.value.filter((upload) => upload.id !== id);
};
</script>
<template>
<div class="upload">
<DropZone
accept="image/*,.pdf"
aria-label="Upload attachments"
class="zone"
multiple
@select="onSelect"
>
<template #default="{ status }">
<p v-if="status === 'reject'">Images and PDFs only</p>
<p v-else>Drop images or PDFs here, or <DropZoneTrigger>browse</DropZoneTrigger></p>
<p class="hint">Up to 2 MB each</p>
</template>
</DropZone>
<Alert v-if="refused.length > 0" status="warning">
<AlertIndicator />
<AlertContent>
<AlertTitle>Some files were too large</AlertTitle>
<AlertDescription>
{{ refused.join(", ") }} — each file has to be under 2 MB.
</AlertDescription>
</AlertContent>
<CloseButton aria-label="Dismiss" @click="refused = []" />
</Alert>
<EmptyState v-if="uploads.length === 0">Nothing uploaded yet.</EmptyState>
<ul v-else class="list">
<li v-for="upload in uploads" :key="upload.id">
<div class="row">
<ProgressBar class="bar" :value="upload.progress">
<Label>{{ upload.name }}</Label>
<ProgressBarOutput />
<ProgressBarTrack><ProgressBarFill /></ProgressBarTrack>
</ProgressBar>
<CloseButton :aria-label="`Remove ${upload.name}`" @click="remove(upload.id)" />
</div>
</li>
</ul>
</div>
</template>
<style scoped>
.upload {
display: flex;
max-width: var(--rp-container-md);
flex-direction: column;
gap: calc(var(--rp-spacing) * 4);
}
.hint {
color: var(--rp-muted);
font-size: var(--rp-text-sm);
line-height: var(--rp-text-sm--line-height);
}
.list {
display: flex;
flex-direction: column;
padding: 0;
margin: 0;
gap: calc(var(--rp-spacing) * 4);
list-style: none;
}
.row {
display: flex;
gap: calc(var(--rp-spacing) * 3);
align-items: end;
}
.bar {
flex: 1;
min-width: 0;
}
</style>The zone refuses quietly, so the screen explains
accept filters three things at once — the picker, the drag while it is still moving, and what a drop emits. The files it turns away never reach select, and no error comes with them: the component has no channel to show one in. Anything accept cannot express is therefore the half that can explain itself, which is why the size cap above is applied in the handler and reported in an Alert naming the files.
What select hands you
select is never emitted empty, a folder dropped on the zone is walked so you receive plain files, and multiple is honoured before you see them. So the handler takes File[] and does not have to re-check any of that — only the things it added itself.
One row, one label
A ProgressBar carries its own Label and ProgressBarOutput, so the file name and the percentage are parts of the bar rather than text sitting beside it. That is what makes a row announce as one thing instead of three.
Accessibility
- The focusable control is a visually hidden file input, so the zone is one ordinary tab stop and needs an
aria-label— without one there is nothing to announce. DropZoneTriggeris deliberately not focusable. The keyboard already reaches the zone through that input, and a second stop for the same action is only one to tab past.- Each remove button names its file. A row of identical "Remove" buttons tells a screen-reader user which row they are in only by accident.