226 lines
5.3 KiB
Vue
226 lines
5.3 KiB
Vue
<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>
|