braindrops.nicwands.com/shared/FileInput.vue
2026-08-11 10:24:27 -04:00

226 lines
5.3 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div
class="shared-file-input"
:class="{ 'has-file': files.length, 'is-dragging': dragging, 'is-disabled': disabled, 'is-error': error }"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
>
<label v-if="label" :for="id">{{ label }}</label>
<div class="drop-zone" @click="triggerInput">
<input
ref="inputRef"
:id="id"
type="file"
:multiple="multiple"
:accept="accept"
:disabled="disabled"
@change="onChange"
/>
<template v-if="files.length">
<ul class="file-list">
<li v-for="(file, i) in files" :key="i">
<span>{{ file.name }}</span>
<button type="button" @click.stop="removeFile(i)">×</button>
</li>
</ul>
</template>
<template v-else>
<span class="drop-prompt">
<span class="icon">↑</span>
<span>{{ placeholder }}</span>
</span>
</template>
</div>
<span v-if="error" class="error-msg">{{ error }}</span>
<span v-else-if="hint" class="hint">{{ hint }}</span>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
id: String,
label: String,
placeholder: {
type: String,
default: 'Drop files here or click to browse',
},
multiple: {
type: Boolean,
default: false,
},
accept: String,
disabled: {
type: Boolean,
default: false,
},
error: String,
hint: String,
})
const emit = defineEmits(['change'])
const inputRef = ref(null)
const files = ref([])
const dragging = ref(false)
function triggerInput() {
inputRef.value?.click()
}
function onChange(e) {
addFiles(Array.from(e.target.files))
e.target.value = ''
}
function onDrop(e) {
dragging.value = false
addFiles(Array.from(e.dataTransfer.files))
}
function addFiles(newFiles) {
if (!props.multiple) {
files.value = newFiles.slice(0, 1)
} else {
files.value = [...files.value, ...newFiles]
}
emit('change', files.value)
}
function removeFile(index) {
files.value.splice(index, 1)
emit('change', files.value)
}
</script>
<style lang="scss">
.shared-file-input {
--t-duration: 250ms;
--t-ease: var(--ease-out-quad);
display: flex;
flex-direction: column;
gap: rs(6px);
label {
@include label;
opacity: 0.45;
}
.drop-zone {
border: rs(1px) dashed var(--grey-400);
border-radius: rs(8px);
padding: rs(24px) rs(16px);
cursor: pointer;
transition:
border-color var(--t-duration) var(--t-ease),
background var(--t-duration) var(--t-ease);
input[type='file'] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
}
.drop-prompt {
display: flex;
flex-direction: column;
align-items: center;
gap: rs(8px);
text-align: center;
.icon {
@include h4;
font-style: normal;
font-family: var(--font-mono);
opacity: 0.35;
transition: transform var(--t-duration) var(--t-ease);
}
span:last-child {
@include label;
opacity: 0.35;
}
}
.file-list {
list-style: none;
display: flex;
flex-direction: column;
gap: rs(6px);
li {
@include p;
display: flex;
align-items: center;
justify-content: space-between;
gap: rs(8px);
padding: rs(4px) 0;
border-bottom: rs(1px) solid var(--grey-400);
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
button {
@include label;
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
color: var(--theme-fg);
opacity: 0.45;
transition: opacity var(--t-duration) var(--t-ease);
padding: 0 rs(4px);
&:hover {
opacity: 1;
}
}
}
}
&:hover .drop-zone,
&.is-dragging .drop-zone {
border-color: var(--theme-contrast);
border-style: solid;
.drop-prompt .icon {
transform: translateY(-rs(4px));
}
}
&.is-dragging .drop-zone {
background: color-mix(in srgb, var(--theme-contrast) 5%, transparent);
}
.error-msg,
.hint {
@include label;
font-size: rs(11px);
}
.hint {
opacity: 0.35;
}
&.is-error .drop-zone {
border-color: currentColor;
border-style: solid;
.error-msg {
opacity: 0.65;
}
}
&.is-disabled {
opacity: 0.3;
pointer-events: none;
}
}
</style>