Skip to content

Instantly share code, notes, and snippets.

@blairdow
Last active February 22, 2025 19:11
Show Gist options
  • Select an option

  • Save blairdow/9dc3766803ed2c69834b4e1fe3f8220d to your computer and use it in GitHub Desktop.

Select an option

Save blairdow/9dc3766803ed2c69834b4e1fe3f8220d to your computer and use it in GitHub Desktop.
Vue 3 File Upload Input Component (with Button and ProgressBar child components)
<template>
<button
:disabled="disabled"
:type="type"
class="btn btn-form-base"
:class="{
'btn-outline': outline,
'btn-small': small,
submit: type == 'submit',
'btn-transparent': transparent,
}"
>
<span class="btn-child" :class="{ 'opacity-0': loading && type == 'submit' }">
<slot></slot>
</span>
<i v-if="loading && type == 'submit'" class="btn-child fa-solid fa-circle-notch fa-spin"></i>
</button>
</template>
<script setup>
defineOptions({
name: 'CustomButton',
})
defineProps({
disabled: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
outline: { type: Boolean, default: false },
small: { type: Boolean, default: false },
transparent: { type: Boolean, default: false },
type: { type: String, default: 'button' },
})
</script>
<style lang="scss" scoped>
.btn.btn-form-base {
background: var(--color-dark-gray);
color: var(--color-white);
display: inline-block;
padding: 0.5rem 2rem;
text-decoration: none;
font-weight: 100;
border-radius: 100px;
transition:
background-color 0.3s,
color 0.3s;
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 1px;
&:hover {
background: var(--color-black);
}
&.btn-small {
padding: 0.5rem;
font-size: 0.7rem;
}
&.btn-outline {
background: var(--color-white);
color: var(--color-dark-gray);
border: 1.5px solid var(--color-dark-gray);
font-weight: 400;
&:hover {
background: var(--color-dark-gray);
color: var(--color-white);
}
}
&.btn-transparent {
background-color: transparent;
padding: 0;
color: var(--color-black);
font-size: 1rem;
&:hover {
background-color: transparent;
}
}
&.submit {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
grid-column-gap: 0px;
grid-row-gap: 0px;
.btn-child {
grid-area: 1 / 1 / 1 / 1;
}
}
}
.fa-solid.fa-circle-notch.fa-spin {
position: relative;
top: -1px;
line-height: inherit;
}
</style>
<template>
<div>
<label :for="name">
<strong>{{ label }}</strong>
</label>
<div class="input-container mt-3 mb-4">
<span class="mb-5 z-1">Drop Files Here or</span>
<input
type="file"
:accept="accept"
:name="name"
:id="name"
:multiple="multiple"
class="custom-file-input"
@change="handleFileChange($event)"
/>
</div>
<div class="file-errors-container" v-if="fileErrors.length > 0 && filesArray.length > 0">
<p class="error text-danger" v-for="error in fileErrors" :key="error">
{{ error }}
</p>
</div>
<div class="progress-bar-container mb-2" v-for="file in filesArray" :key="file.name">
<div class="d-flex align-items-center">
<ProgressBar
class="w-100 me-2"
:progressBarWidth="file.progressBarWidth"
:fillColor="'#bacdff'"
></ProgressBar>
<div class="lh-1">{{ file.progressBarWidth }}%</div>
</div>
<div class="file-name">
{{ file.name }} ({{ fileSizeInKB(file.size) }} kb)
<CustomButton @click="removeFile(file)" :transparent="true" style="color: red">
<i class="fa-regular fa-circle-xmark"></i>
</CustomButton>
</div>
</div>
</div>
</template>
<script>
import CustomButton from '@/components/widgets/Button.vue'
import ProgressBar from '@/components/widgets/ProgressBar.vue'
export default {
name: 'FileUploader',
components: { CustomButton, ProgressBar },
props: {
accept: { type: String, default: 'application/pdf,pdf,image/png,png,image/jpeg,jpg,jpeg' },
name: { type: String, required: true },
label: { type: String, default: 'Upload File Here' },
multiple: { type: Boolean, default: false },
maxFiles: { type: Number, default: 3 },
maxSize: { type: Number, default: 5 },
},
emits: ['update:modelValue'],
data() {
return {
fileErrors: [],
filesArray: [],
progressBarWidth: 0,
}
},
computed: {
maxFilesComputed() {
return this.multiple ? this.maxFiles : 1
},
},
methods: {
fileSizeInMB(fileSize) {
return Math.round((fileSize / 1024 / 1024) * 100) / 100
},
fileSizeInKB(fileSize) {
if (fileSize > 0) {
return Math.round(fileSize / 1024)
} else return fileSize
},
handleFileChange(e) {
this.fileErrors = []
this.progressBarWidth = 0
if (!this.multiple) {
this.filesArray = []
}
if (e.target.files && e.target.files[0]) {
const files = e.target.files
this.isFilesNumberValid(files) //check against maxFilesComputed number
for (let index = 0; index < this.maxFilesComputed - this.filesArray.length; index++) {
//only process files up to maxFiles length, including already processed files
let file = files[index]
if (file && this.isFileValid(file)) {
// const file = e.target.files[0]
let reader = new FileReader()
reader.onloadstart = () => {
file.progressBarWidth = 0
file.loading = true
}
reader.onprogress = (event) => {
if (event.lengthComputable) {
file.progressBarWidth = Math.floor((event.loaded / event.total) * 100)
}
}
reader.onload = () => {
this.filesArray.push(file)
file.progressBarWidth = 100
file.loading = false
this.$nextTick(() => {
this.$emit('update:modelValue', this.filesArray) //tell parent to update v-model
})
}
reader.readAsDataURL(file)
}
}
}
},
removeFile(file) {
const index = this.filesArray.indexOf(file)
console.log(index)
if (index > -1) {
this.filesArray.splice(index, 1)
}
},
isFileSizeValid(fileSize, fileName) {
let sizeInMB = this.fileSizeInMB(fileSize)
if (sizeInMB > this.maxSize) {
if (
this.fileErrors.indexOf(`${fileName} is larger than 5MB, please upload a smaller file.`) <
0
) {
this.fileErrors.push(`${fileName} is larger than 5MB, please upload a smaller file.`)
}
}
return sizeInMB <= this.maxSize
},
isFileTypeValid(fileExtension, fileName) {
if (this.accept.split(',').includes(fileExtension) === false) {
if (
this.fileErrors.indexOf(
`${fileName} is not an accepted file type, please upload a different file.`,
) < 0
) {
this.fileErrors.push(
`${fileName} is not an accepted file type, please upload a different file.`,
)
}
}
return this.accept.split(',').includes(fileExtension)
},
isFileValid(file) {
return (
this.isFileSizeValid(file.size, file.name) &&
this.isFileTypeValid(file.name.split('.').pop(), file.name)
)
},
isFilesNumberValid(files) {
if (files.length > this.maxFilesComputed - this.filesArray.length) {
if (
this.fileErrors.indexOf(
`The maximum number of uploaded files is ${this.maxFilesComputed}, only the first ${this.maxFilesComputed} selected have been processed.`,
) < 0
) {
this.fileErrors.push(
`The maximum number of uploaded files is ${this.maxFilesComputed}, only the first ${this.maxFilesComputed} selected have been processed.`,
)
}
}
},
},
}
</script>
<style lang="scss" scoped>
.input-container {
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed var(--color-black);
background-color: var(--color-white);
position: relative;
}
.custom-file-input {
color: transparent;
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
cursor: pointer;
&:hover {
background: var(--color-gray);
}
}
.custom-file-input::-webkit-file-upload-button {
visibility: hidden;
}
.custom-file-input::before {
content: 'Select File';
color: black;
display: inline-block;
background: -webkit-linear-gradient(top, #f9f9f9, #e3e3e3);
border: 1px solid #999;
border-radius: 3px;
padding: 5px 8px;
outline: none;
white-space: nowrap;
user-select: none;
-webkit-user-select: none;
text-shadow: 1px 1px #fff;
font-weight: 700;
font-size: 10pt;
position: absolute;
top: 55%;
left: 50%;
translate: -50% -50%;
}
.custom-file-input:hover::before {
border-color: black;
}
.custom-file-input:active {
outline: 0;
}
.custom-file-input:active::before {
background: -webkit-linear-gradient(top, #e3e3e3, #f9f9f9);
}
</style>
<template>
<div class="progressbar">
<svg width="100%" :height="height">
<rect
x="0"
y="0"
:width="progressBarWidth + '%'"
:height="height"
:style="`fill: ${fillColor}`"
/>
<rect
x="0"
y="0"
width="100%"
:height="height"
:style="`stroke: ${strokeColor}; fill: none`"
/>
</svg>
</div>
</template>
<script setup>
defineOptions({
name: 'ProgressBar',
})
defineProps({
progressBarWidth: {
type: Number,
},
height: { type: String, default: '15px' },
fillColor: {
type: String,
default: '#F2FD7C',
},
strokeColor: {
type: String,
default: '#808080',
},
})
</script>
<style scoped></style>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment