Appearance
FileInput Component Code ​
Dependencies
This component requires:
- Vue 3 with Composition API
- BasicInput component for the input wrapper
Full Component Code ​
Due to the length of this component, only key sections are shown here. For the complete code, please refer to the source file.
vue
<script setup>
import { ref, useAttrs, computed, watch } from "vue";
import BasicInput from "./BasicInput.vue";
const emit = defineEmits([
"prepend:click",
"prependInner:click",
"clear:click",
"append:click",
"appendInner:click",
"update:modelValue",
"validate",
"focus",
"blur",
"input",
"change",
"keydown",
"keyup",
"keypress",
"click",
"dblclick",
"mousedown",
"mouseup",
"mouseenter",
"mouseleave",
"copy",
"cut",
"paste",
"compositionstart",
"compositionupdate",
"compositionend",
"dragenter",
"dragover",
"dragleave",
"drop",
]);
const props = defineProps({
label: {
type: String,
required: false,
default: (props) =>
props.multiple ? "Upload your files" : "Upload your file",
},
maxFiles: { type: Number, required: false },
maxSize: { type: Number, required: false },
multiple: { type: Boolean, required: false, default: false },
modelValue: { type: [Object, Array], required: false },
value: [Object, Array],
chip: { type: Boolean, required: false, default: false },
persistentDetails: { type: Boolean, required: false, default: true },
accept: { type: String, required: false },
});
// Use attributes passed to the component
const attrs = useAttrs();
// Internal state
const fileInputRef = ref(null);
const fileInputComponent = ref(null);
const isDragging = ref(false);
// Computed properties
const internalValue = computed({
get: () => {
if (props.modelValue !== undefined) return props.modelValue;
if (props.value !== undefined) return props.value;
return props.multiple ? [] : null;
},
set: (val) => {
emit("update:modelValue", val);
},
});
// Validation rules
const validationRules = computed(() => {
const rules = [];
// Add file count validation if maxFiles is set
if (props.maxFiles && props.multiple) {
rules.push({
rule: "custom",
validator: (value) => {
if (!value) return true;
return Array.isArray(value) && value.length <= props.maxFiles;
},
message: `Maximum ${props.maxFiles} files allowed`,
});
}
// Add file size validation if maxSize is set
if (props.maxSize) {
rules.push({
rule: "custom",
validator: (value) => {
if (!value) return true;
if (props.multiple) {
return (
Array.isArray(value) &&
value.every((file) => file.size <= props.maxSize)
);
}
return value.size <= props.maxSize;
},
message: `Maximum file size is ${formatFileSize(props.maxSize)}`,
});
}
return rules;
});
// Methods
const triggerFileInput = () => {
if (fileInputRef.value) {
fileInputRef.value.click();
}
};
const handleFileChange = (event) => {
const files = event.target.files;
if (!files || files.length === 0) return;
if (props.multiple) {
internalValue.value = Array.from(files);
} else {
internalValue.value = files[0];
}
// Reset the input to allow selecting the same file again
if (fileInputRef.value) {
fileInputRef.value.value = "";
}
emit("change", event);
};
const handleRemoveFile = (file) => {
if (props.multiple) {
internalValue.value = internalValue.value.filter((f) => f !== file);
} else {
internalValue.value = null;
}
};
const handleClearClick = () => {
internalValue.value = props.multiple ? [] : null;
emit("clear:click");
};
// Drag and drop handlers
const handleDragEnter = (event) => {
event.preventDefault();
isDragging.value = true;
};
const handleDragOver = (event) => {
event.preventDefault();
isDragging.value = true;
};
const handleDragLeave = (event) => {
event.preventDefault();
isDragging.value = false;
};
const handleDrop = (event) => {
event.preventDefault();
isDragging.value = false;
const files = event.dataTransfer.files;
if (!files || files.length === 0) return;
if (props.multiple) {
internalValue.value = Array.from(files);
} else {
internalValue.value = files[0];
}
emit("drop", event);
};
// Helper functions
const formatFileSize = (bytes) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// Event delegation
const triggerEvent = (eventName, event) => {
emit(eventName, event);
};
watch(
() => props.modelValue ?? props.value ?? '',
(newVal) => {
internalValue.value = newVal;
}
);
</script>
<template>
<OInput
ref="fileInputComponent"
class="file-input-wrapper"
v-bind="attrs"
:label="label"
:value="internalValue"
:rules="validationRules"
:persistentDetails="persistentDetails"
:class="{ 'is-dragging': isDragging }"
@prepend:click="triggerFileInput"
@prependInner:click="triggerFileInput"
@clear:click="handleClearClick"
>
<template
#input-field="{
props: { readonly, disabled, attrs, triggerEvent, validate },
}"
>
<input
ref="fileInput"
type="file"
v-bind="attrs"
:readonly="readonly"
:disabled="disabled"
:multiple="multiple"
:accept="accept"
@input="handleInput"
@change="triggerEvent('change', $event)"
@focus="triggerEvent('focus', $event)"
@blur="triggerEvent('blur', $event)"
@keydown="triggerEvent('keydown', $event)"
@keyup="triggerEvent('keyup', $event)"
@keypress="triggerEvent('keypress', $event)"
@click="triggerEvent('click', $event)"
@dblclick="triggerEvent('dblclick', $event)"
@mousedown="triggerEvent('mousedown', $event)"
@mouseup="triggerEvent('mouseup', $event)"
@mouseenter="triggerEvent('mouseenter', $event)"
@mouseleave="triggerEvent('mouseleave', $event)"
@compositionstart="triggerEvent('compositionstart', $event)"
@compositionupdate="triggerEvent('compositionupdate', $event)"
@compositionend="triggerEvent('compositionend', $event)"
@dragenter="triggerEvent('dragenter', $event)"
@dragover="triggerEvent('dragover', $event)"
@dragleave="triggerEvent('dragleave', $event)"
@drop="triggerEvent('drop', $event)"
/>
<div
class="file-input-placeholder"
v-if="!(multiple ? internalValue?.length > 0 : internalValue)"
@click="triggerFileInput"
></div>
<div v-else class="file-input-selected-files" @click="triggerFileInput">
<template v-if="chip">
<template v-if="multiple">
<div
class="file-chip"
v-for="file in internalValue"
:key="file.name"
@click.stop
>
<span class="file-name">{{ file.name }}</span>
<button class="remove-btn" @click.stop="handleRemoveFile(file)">
×
</button>
</div>
</template>
<div v-else class="file-chip" @click.stop>
<span class="file-name">{{ internalValue?.name }}</span>
<button
class="remove-btn"
@click.stop="handleRemoveFile(internalValue)"
>
×
</button>
</div>
</template>
<template v-else>
<template v-if="multiple">
{{ internalValue?.map((file) => file.name)?.join(", ") }}
</template>
<template v-else> {{ internalValue?.name }} </template>
</template>
</div>
</template>
<template #prepend="{ props }">
<slot name="prepend" :props="props">
<svg
@click="props?.click?.()"
class="prepend-svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
role="img"
aria-hidden="true"
>
<path
d="M16.5,6V17.5A4,4 0 0,1 12.5,21.5A4,4 0 0,1 8.5,17.5V5A2.5,2.5 0 0,1 11,2.5A2.5,2.5 0 0,1 13.5,5V15.5A1,1 0 0,1 12.5,16.5A1,1 0 0,1 11.5,15.5V6H10V15.5A2.5,2.5 0 0,0 12.5,18A2.5,2.5 0 0,0 15,15.5V5A4,4 0 0,0 11,1A4,4 0 0,0 7,5V17.5A5.5,5.5 0 0,0 12.5,23A5.5,5.5 0 0,0 18,17.5V6H16.5Z"
></path>
</svg>
</slot>
</template>
<template #prepend-inner="{ props }">
<slot name="prepend-inner" :props="{ props }">
<svg
@click="props?.click?.()"
class="prepend-inner-svg"
width="64"
height="64"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
fill="#000000"
>
<path
d="M20,8 C18.896,8 18,7.104 18,6 L18,2 L24,8 L20,8 Z M18,0 L18,0.028 C17.872,0.028 4,0 4,0 C1.791,0 0,1.791 0,4 L0,28 C0,30.209 1.791,32 4,32 L22,32 C24.209,32 26,30.209 26,28 L26,10 L26,8 L18,0 Z"
/>
</svg>
</slot>
</template>
<template #append-inner="{ props }">
<slot name="append-inner" :props="props">
<svg
class="append-inner-svg"
@click="props?.click?.()"
xmlns="http://www.w3.org/2000/svg"
fill="#000000"
width="64px"
height="64px"
viewBox="0 0 24 24"
>
<g id="SVGRepo_bgCarrier" stroke-width="0"></g>
<g
id="SVGRepo_tracerCarrier"
stroke-linecap="round"
stroke-linejoin="round"
></g>
<g id="SVGRepo_iconCarrier">
<path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"></path>
</g>
</svg>
</slot>
</template>
<template #append="{ props }">
<slot name="append" :props="props">
<svg
class="append-svg"
@click="props?.click?.()"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
role="img"
aria-hidden="true"
>
<path
d="M16.5,6V17.5A4,4 0 0,1 12.5,21.5A4,4 0 0,1 8.5,17.5V5A2.5,2.5 0 0,1 11,2.5A2.5,2.5 0 0,1 13.5,5V15.5A1,1 0 0,1 12.5,16.5A1,1 0 0,1 11.5,15.5V6H10V15.5A2.5,2.5 0 0,0 12.5,18A2.5,2.5 0 0,0 15,15.5V5A4,4 0 0,0 11,1A4,4 0 0,0 7,5V17.5A5.5,5.5 0 0,0 12.5,23A5.5,5.5 0 0,0 18,17.5V6H16.5Z"
></path>
</svg>
</slot>
</template>
<template
#details-right-content="{
props: { hint, error, errorMessage, persistentDetails, focused },
}"
>
<div
class="details-right-content"
:class="{ persistentDetails, focused }"
>
<span v-if="internalValue?.length > 0"
>{{ internalValue?.length }} files</span
>
<span v-if="totalFileSizes > 0"
>({{ formatFileSize(totalFileSizes) }} in total)
</span>
</div>
</template>
</OInput>
</template>
<style lang="scss" scoped>
.file-input-wrapper {
.prepend-svg {
width: 100%;
height: 100%;
cursor: pointer;
}
.prepend-inner-svg {
width: 100%;
height: 100%;
cursor: pointer;
}
.append-inner-svg {
width: 100%;
height: 100%;
cursor: pointer;
}
.append-svg {
width: 100%;
height: 100%;
cursor: pointer;
}
.file-picker-opener {
width: 100%;
height: 100%;
cursor: pointer;
}
.file-input-placeholder {
padding: 1rem 0;
cursor: pointer;
width: 100%;
height: 100%;
display: flex;
align-items: center;
position: relative;
min-height: 3.25rem;
}
.file-input-selected-files {
padding: 1rem 0;
cursor: pointer;
width: 100%;
height: 100%;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
.file-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
background-color: #f1f1f1;
border-radius: 9999px;
font-size: 0.875rem;
color: #333;
border: 1px solid #ccc;
.file-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
}
.remove-btn {
background: none;
border: none;
font-size: 1rem;
line-height: 1;
cursor: pointer;
color: #666;
transition: color 0.2s;
&:hover {
color: #333;
}
}
}
}
input {
position: relative; /* Relative positioning */
z-index: 1; /* Layering */
background: transparent; /* Transparent background */
padding: 0; /* No padding */
margin: 0; /* No margin */
border: none; /* No border */
outline: 0; /* No outline */
padding: 1rem 0; /* Padding */
width: 100%; /* Full width */
cursor: pointer;
width: 0;
height: 0;
opacity: 0;
position: absolute;
&:focus {
outline: none; /* No outline on focus */
}
}
.details-right-content {
display: flex;
// align-items: center;
gap: 0.5rem;
color: #666;
font-weight: 300;
font-size: 0.75rem;
flex-shrink: 0;
opacity: 0; /* Hidden by default */
visibility: hidden; /* Hidden by default */
min-height: 0.875rem; /* Minimum height */
min-width: 0.0625rem; /* Minimum width */
position: relative; /* Relative positioning */
transform: translateY(-100%); /* Move up */
transition: all 0.2s ease-in-out; /* Smooth transition */
span {
white-space: nowrap;
}
&.focused,
&.persistentDetails {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
}
}
</style>
<style lang="scss">
.file-input-wrapper {
&.is-dragging {
.input-wrapper {
border-color: #000000;
}
}
&.basic-input-wrapper {
&.readonly {
input {
pointer-events: none;
}
}
/* Input wrapper styles */
.input-wrapper {
grid-template-areas: "prepend-inner field clear append-inner"; /* Define grid areas */
/* Input field wrapper styles */
.input-field-wrapper {
/* Label styles */
&:has(.label.floating) {
.file-input-placeholder {
opacity: 0;
}
}
.label {
&.has-inner-prepend {
&.floating {
animation: float-diagonal 0.2s ease forwards; /* Animation for floating */
font-size: 0.625rem; /* Smaller font size */
height: max-content; /* Adjust height */
transform: translateY(-50%); /* Center vertically */
left: 0.625rem; /* Left position */
background: white; /* Background color */
padding: 0 0.25rem; /* Padding */
}
&.un-floating {
animation: reverse-float-diagonal 0.2s ease forwards; /* Animation for un-floating */
font-size: 0.875rem; /* Normal font size */
height: 100%; /* Full height */
left: calc(
0.625rem + 0.375rem + 1.5rem
); /* Adjust left position */
top: 50%; /* Center vertically */
transform: translateY(-50%); /* Center vertically */
padding: 0; /* No padding */
}
}
/* Keyframes for floating animation */
@keyframes float-diagonal {
0% {
left: calc(0.625rem + 0.375rem + 1.5rem); /* Start position */
top: 40%; /* Start vertical position */
}
100% {
left: 0.625rem; /* End position */
top: 0; /* End vertical position */
}
}
/* Keyframes for reverse floating animation */
@keyframes reverse-float-diagonal {
0% {
left: calc(0.625rem + 0.375rem); /* Start position */
top: 0%; /* Start vertical position */
}
100% {
left: calc(0.625rem + 0.375rem + 1.5rem); /* End position */
top: 50%; /* End vertical position */
}
}
/* Input field styles */
&:has(.label) {
.input-field {
&:not(:focus) {
&::placeholder {
width: 0; /* Hide placeholder when not focused */
}
}
}
}
/* Clear button styles */
&:has(.clear-wrapper) {
input:focus {
visibility: visible;
width: calc(100% - 2rem); /* Adjust width when focused */
}
}
/* Show clear button when focused */
&:focus-within {
&:has(.has-value) {
input {
width: calc(100% - 2rem); /* Adjust width when focused */
}
.clear-wrapper {
opacity: 1; /* Show clear button */
right: 2.5rem; /* Adjust right position */
visibility: visible; /* Make visible */
}
}
}
}
}
}
.details-wrapper {
align-items: initial;
}
}
}
</style>