Files
2026-10-06 03:00:22 +02:00

226 lines
4.2 KiB
Vue

<script setup lang="ts">
type InputType = HTMLInputElement["type"]
type AutocompleteType = HTMLInputElement["autocomplete"]
interface InputProps {
placeholder?: string
full?: boolean
iconName?: string
type?: InputType
name?: string
required?: boolean
autocomplete?: AutocompleteType
label?: string
textarea?: boolean
rows?: number
}
const value = defineModel<string | File | File[] | null>("value")
const emit = defineEmits<{
(e: "input", value: string): void
(e: "file", value: File | File[] | null | undefined): void
(e: "focus"): void
(e: "blur"): void
}>()
const props = defineProps<InputProps>()
const onFileChange = (e: Event) => {
const input = e.target as HTMLInputElement
const files = input.files
if (!files || files.length == 0) {
value.value = null
emit("file", null)
return
}
const result = files.length == 1 ? files[0] : Array.from(files)
value.value = result
emit("file", result)
}
const onTextInput = (e: Event) => {
emit("input", (e.currentTarget as HTMLInputElement | HTMLTextAreaElement).value)
}
const clearSearch = () => {
value.value = ""
emit("input", "")
}
</script>
<template>
<div class="input-wrapper">
<label v-if="$slots.label || props.label" :for="props.name">
<template v-if="!props.label">
<slot name="label" />
</template>
<template v-else>
{{ props.label }}
</template>
<span v-if="props.required" class="required">*</span>
</label>
<div
class="input"
:class="{ full: props.full, icon: $slots.icon || props.iconName }"
>
<span class="icon-wrapper">
<slot name="icon" v-if="!props.iconName" />
<Icon :name="props.iconName" v-else />
</span>
<input
v-if="props.type != 'file' && props.type != 'textarea'"
:placeholder="props.placeholder"
v-model="value"
@input="onTextInput"
@focus="emit('focus')"
@blur="emit('blur')"
:type="props.type"
:name="props.name"
:id="props.name"
:required="props.required"
:autocomplete="props.autocomplete"
/>
<textarea
v-else-if="props.textarea"
:placeholder="props.placeholder"
v-model="value"
@input="onTextInput"
@focus="emit('focus')"
@blur="emit('blur')"
:name="props.name"
:id="props.name"
:required="props.required"
:autocomplete="props.autocomplete"
:rows="props.rows"
/>
<input
v-else
@focus="emit('focus')"
@blur="emit('blur')"
@change="onFileChange"
:type="props.type"
:name="props.name"
:id="props.name"
:required="props.required"
/>
<button
v-if="props.type == 'search' && typeof value == 'string' && value"
type="button"
class="clear-search"
aria-label="Clear search"
@mousedown.prevent
@click="clearSearch"
>
<Icon name="lucide:x" />
</button>
</div>
</div>
</template>
<style lang="scss" scoped>
@use "@/styles/variables" as *;
@use "@/styles/util" as *;
.input-wrapper {
& > label {
display: block;
@include fontSize(14px);
margin-bottom: 0.4rem;
.required {
margin-left: 0.3em;
color: $color-attention;
}
}
}
.input {
border-radius: $border-radius-standard;
border: 1px solid $color-border;
padding: 0.5rem 1rem;
transition: outline 0.2s ease;
outline: 2px solid transparent;
&:focus-within {
outline: 2px solid $color-accent;
}
& input,
& textarea {
width: 100%;
background: none;
&:-webkit-autofill,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus {
box-shadow: 0 0 0px 1000px $color-background inset !important;
-webkit-text-fill-color: $color-text !important;
}
}
& textarea:focus {
outline: none;
}
& input:focus {
outline: none;
}
& input::placeholder {
color: $color-placeholder-text;
}
& input[type="search"] {
color-scheme: dark;
&::-webkit-search-cancel-button {
display: none;
}
}
.clear-search {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: $border-radius-standard;
line-height: 1;
color: $color-placeholder-text;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: $color-text;
}
}
}
.input,
.icon,
.icon-wrapper {
display: inline-flex;
align-items: center;
}
.icon {
gap: 0.7rem;
.icon-wrapper {
font-size: 24px;
}
}
.full {
width: 100%;
}
</style>