29. Botón para quitar archivo
29: Botón para Quitar Archivo
En este tutorial, aprenderemos a agregar un botón para eliminar el archivo seleccionado en el componente FileInput. Mejoraremos la experiencia de usuario permitiendo limpiar la selección y seleccionar un nuevo archivo sin necesidad de recargar la página.
1. Agregar el Icono de Eliminación
1.1. Importar el Icono XMark
Primero, importamos el icono X (cruz) de Heroicons que usaremos para el botón de eliminar.
resources/js/Components/FileInput.vue
<script setup> import { ref } from 'vue'; import XMarkIcon from '@/Components/Icons/XMarkIcon.vue'; // ← Nuevo import import FileIcon from '@/Components/Icons/FileIcon.vue'; // ... resto del código </script>
2. Estructura del Componente con Botón de Eliminación
2.1. Layout con Flex
Para colocar el input y el botón uno al lado del otro, usamos un contenedor flex.
resources/js/Components/FileInput.vue
<template> <div class="relative"> <!-- Contenedor flex --> <div class="flex items-center justify-between gap-2"> <!-- Input file --> <input :ref="inputRef" type="file" :accept="accept" :multiple="multiple" :disabled="disabled" v-bind="$attrs" :class="[ 'flex-1 w-full text-sm text-gray-500 dark:text-gray-400', 'file:mr-4 file:cursor-pointer file:border-none', 'file:rounded-md file:px-3 file:py-1.5', 'file:text-sm file:font-semibold', 'file:bg-gray-200 file:text-gray-800', 'hover:file:bg-gray-300', 'dark:file:bg-gray-700 dark:file:text-white', 'dark:hover:file:bg-gray-600', 'border border-gray-300 dark:border-gray-600 rounded-md p-2', 'focus:outline-none focus:ring-2 focus:ring-indigo-500', props.class, ]" @input="handleInput" /> <!-- Botón para eliminar archivo --> <button v-if="modelValue && !multiple" type="button" @click="clearFile" class=" flex-shrink-0 p-1.5 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 rounded-full hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors duration-200 " title="Eliminar archivo" > <XMarkIcon class="w-5 h-5" /> </button> </div> <!-- Mostrar archivo seleccionado (para múltiples) --> <div v-if="multiple && modelValue && modelValue.length > 0" class="mt-2 space-y-1"> <div v-for="(file, index) in modelValue" :key="index" class="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-700" > <div class="flex items-center space-x-2 min-w-0"> <FileIcon class="w-5 h-5 text-gray-500 flex-shrink-0" /> <span class="text-sm text-gray-700 dark:text-gray-300 truncate"> {{ file.name }} </span> <span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0"> ({{ formatFileSize(file.size) }}) </span> </div> <button type="button" @click="removeFileAtIndex(index)" class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 flex-shrink-0 ml-2" > <XMarkIcon class="w-5 h-5" /> </button> </div> </div> <!-- Mensaje de error --> <p v-if="error" class="mt-1 text-sm text-red-600 dark:text-red-400"> {{ error }} </p> </div> </template> <script setup> import { ref } from 'vue'; import XMarkIcon from '@/Components/Icons/XMarkIcon.vue'; import FileIcon from '@/Components/Icons/FileIcon.vue'; // ... resto del script </script>
3. Implementar la Función clearFile
3.1. Función para Limpiar el Archivo
La función clearFile debe:
Emitir
update:modelValueconnullpara limpiar el valorResetear el input file para que pueda seleccionar el mismo archivo nuevamente
resources/js/Components/FileInput.vue
<script setup> import { ref } from 'vue'; import XMarkIcon from '@/Components/Icons/XMarkIcon.vue'; import FileIcon from '@/Components/Icons/FileIcon.vue'; const props = defineProps({ modelValue: { type: [File, Array, null], default: null, }, accept: { type: String, default: '.pdf,application/pdf', }, multiple: { type: Boolean, default: false, }, disabled: { type: Boolean, default: false, }, class: { type: String, default: '', }, error: { type: String, default: '', }, maxSize: { type: Number, default: 10240, // 10MB en KB }, }); const emit = defineEmits(['update:modelValue', 'error', 'clear']); const inputRef = ref(null); // Manejar el input del archivo const handleInput = (event) => { const files = event.target.files; if (!files || files.length === 0) { emit('update:modelValue', props.multiple ? [] : null); return; } // Validar tamaño const invalidFiles = []; const validFiles = []; for (const file of files) { if (file.size > props.maxSize * 1024) { invalidFiles.push(file.name); } else { validFiles.push(file); } } if (invalidFiles.length > 0) { const message = `Los siguientes archivos exceden el límite de ${props.maxSize}KB: ${invalidFiles.join(', ')}`; emit('error', message); if (inputRef.value) { inputRef.value.value = ''; } return; } if (props.multiple) { emit('update:modelValue', validFiles); } else { emit('update:modelValue', validFiles[0] || null); } }; // ✅ Nueva función para limpiar el archivo const clearFile = () => { // Emitir el evento para actualizar el modelo emit('update:modelValue', null); // Emitir evento de limpieza emit('clear'); // Resetear el input file if (inputRef.value) { inputRef.value.value = ''; } }; // Eliminar archivo por índice (para múltiples) const removeFileAtIndex = (index) => { if (props.multiple && Array.isArray(props.modelValue)) { const newFiles = [...props.modelValue]; newFiles.splice(index, 1); emit('update:modelValue', newFiles); // Si no quedan archivos, resetear el input if (newFiles.length === 0 && inputRef.value) { inputRef.value.value = ''; } } }; // Formatear tamaño de archivo const formatFileSize = (bytes) => { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; }; // Exponer el método reset defineExpose({ reset: () => { if (inputRef.value) { inputRef.value.value = ''; } emit('update:modelValue', props.multiple ? [] : null); }, }); </script>
4. Mejoras Visuales del Botón
4.1. Estilos del Botón
/* Estilos base del botón */ .file-clear-button { @apply flex-shrink-0 p-1.5; @apply text-red-500 hover:text-red-700; @apply dark:text-red-400 dark:hover:text-red-300; @apply rounded-full hover:bg-red-50; @apply dark:hover:bg-red-900/20; @apply transition-colors duration-200; @apply focus:outline-none focus:ring-2 focus:ring-red-500; } /* Tamaño del icono */ .file-clear-button .icon { @apply w-5 h-5; }
4.2. Tooltip al Pasar el Mouse
<button v-if="modelValue && !multiple" type="button" @click="clearFile" class="file-clear-button" title="Eliminar archivo seleccionado" aria-label="Eliminar archivo" > <XMarkIcon class="w-5 h-5" /> </button>
4.3. Animación de Entrada/Salida
<template> <!-- Botón con transición --> <Transition enter-active-class="transition ease-out duration-200" enter-from-class="opacity-0 scale-75" enter-to-class="opacity-100 scale-100" leave-active-class="transition ease-in duration-150" leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-75" > <button v-if="modelValue && !multiple" type="button" @click="clearFile" class="file-clear-button" > <XMarkIcon class="w-5 h-5" /> </button> </Transition> </template>
5. Validación de Archivos con Botón de Limpieza
5.1. Manejo de Errores y Limpieza
const handleInput = (event) => { const files = event.target.files; if (!files || files.length === 0) { emit('update:modelValue', props.multiple ? [] : null); return; } // Validar tipo de archivo const file = files[0]; if (!file.type.match(/application\/pdf/)) { emit('error', 'Solo se permiten archivos PDF'); clearFile(); // Limpiar automáticamente return; } // Validar tamaño if (file.size > props.maxSize * 1024) { emit('error', `El archivo no puede ser mayor a ${props.maxSize}KB`); clearFile(); // Limpiar automáticamente return; } // Si todo está bien, emitir el archivo emit('update:modelValue', file); emit('error', ''); // Limpiar errores };
6. Uso en el Formulario de Fuentes de Conocimiento
6.1. Integración con KnowledgeSourceForm
resources/js/Components/KnowledgeSources/KnowledgeSourceForm.vue
<template> <div class="space-y-4"> <!-- ... otros campos ... --> <!-- Campo PDF con FileInput --> <div v-if="form.type === 'pdf'" class="col-span-6 sm:col-span-4"> <InputLabel for="pdf_file" value="Archivo PDF" /> <FileInput id="pdf_file" v-model="form.pdf_file" accept=".pdf,application/pdf" class="mt-1 block w-full" :error="form.errors.pdf_file" :max-size="10240" @error="handleFileError" @clear="handleFileClear" /> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> Tamaño máximo: 10MB. Formatos permitidos: PDF </p> <InputError :message="form.errors.pdf_file" class="mt-2" /> </div> <!-- ... otros campos ... --> </div> </template> <script setup> import InputLabel from '@/Components/InputLabel.vue'; import TextInput from '@/Components/TextInput.vue'; import SelectInput from '@/Components/SelectInput.vue'; import FileInput from '@/Components/FileInput.vue'; import InputError from '@/Components/InputError.vue'; const props = defineProps({ form: { type: Object, required: true, }, }); const sourceTypes = [ { value: 'pdf', label: '📄 PDF' }, { value: 'website', label: '🌐 Website' }, ]; // Manejar errores del archivo const handleFileError = (message) => { props.form.errors.pdf_file = message; }; // Manejar limpieza del archivo const handleFileClear = () => { // Limpiar errores relacionados props.form.errors.pdf_file = null; }; </script>
7. Estilos Alternativos para el Botón
7.1. Botón con Fondo
<button v-if="modelValue && !multiple" type="button" @click="clearFile" class=" flex-shrink-0 p-1.5 bg-red-100 text-red-600 hover:bg-red-200 hover:text-red-700 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50 dark:hover:text-red-300 rounded-md transition-colors duration-200 " > <XMarkIcon class="w-5 h-5" /> </button>
7.2. Botón con Texto
<button v-if="modelValue && !multiple" type="button" @click="clearFile" class=" flex-shrink-0 px-3 py-1.5 text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 border border-red-300 dark:border-red-700 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors duration-200 " > <span class="flex items-center gap-1"> <XMarkIcon class="w-4 h-4" /> Quitar </span> </button>
7.3. Botón en el Lugar del Nombre del Archivo
<div class="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-700"> <div class="flex items-center space-x-2 min-w-0"> <FileIcon class="w-5 h-5 text-gray-500 flex-shrink-0" /> <span class="text-sm text-gray-700 dark:text-gray-300 truncate"> {{ modelValue.name }} </span> <span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0"> ({{ formatFileSize(modelValue.size) }}) </span> </div> <button type="button" @click="clearFile" class=" flex-shrink-0 ml-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors duration-200 " title="Eliminar archivo" > <XMarkIcon class="w-5 h-5" /> </button> </div>
8. Accesibilidad del Botón
8.1. Atributos ARIA
<button v-if="modelValue && !multiple" type="button" @click="clearFile" class="file-clear-button" aria-label="Eliminar archivo seleccionado" :aria-describedby="`file-${props.id}-description`" > <XMarkIcon class="w-5 h-5" aria-hidden="true" /> </button> <!-- Descripción para lectores de pantalla --> <span :id="`file-${props.id}-description`" class="sr-only"> Haz clic para eliminar el archivo seleccionado </span>
8.2. Teclado
<button v-if="modelValue && !multiple" type="button" @click="clearFile" @keydown.enter="clearFile" @keydown.space.prevent="clearFile" class="file-clear-button" > <XMarkIcon class="w-5 h-5" /> </button>
9. Resumen del Componente Final
9.1. Características Implementadas
✅ Botón de eliminación: Icono X para quitar el archivo seleccionado
✅ Visualización condicional: Solo aparece cuando hay un archivo seleccionado
✅ Flex layout: Input y botón alineados horizontalmente
✅ Estilos consistentes: Coincide con el diseño general
✅ Soporte dark mode: Colores adaptados para tema oscuro
✅ Accesibilidad: Atributos ARIA y navegación por teclado
✅ Animaciones: Transiciones suaves al mostrar/ocultar
✅ Manejo de errores: Limpieza automática en caso de error
9.2. Código Completo
<!-- resources/js/Components/FileInput.vue --> <template> <div class="relative"> <div class="flex items-center gap-2"> <input :ref="inputRef" type="file" :accept="accept" :multiple="multiple" :disabled="disabled" v-bind="$attrs" :class="[ 'flex-1 w-full text-sm text-gray-500 dark:text-gray-400', 'file:mr-4 file:cursor-pointer file:border-none', 'file:rounded-md file:px-3 file:py-1.5', 'file:text-sm file:font-semibold', 'file:bg-gray-200 file:text-gray-800', 'hover:file:bg-gray-300', 'dark:file:bg-gray-700 dark:file:text-white', 'dark:hover:file:bg-gray-600', 'border border-gray-300 dark:border-gray-600 rounded-md p-2', 'focus:outline-none focus:ring-2 focus:ring-indigo-500', props.class, ]" @input="handleInput" /> <Transition enter-active-class="transition ease-out duration-200" enter-from-class="opacity-0 scale-75" enter-to-class="opacity-100 scale-100" leave-active-class="transition ease-in duration-150" leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-75" > <button v-if="modelValue && !multiple" type="button" @click="clearFile" class=" flex-shrink-0 p-1.5 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 rounded-full hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-red-500 " title="Eliminar archivo" aria-label="Eliminar archivo seleccionado" > <XMarkIcon class="w-5 h-5" aria-hidden="true" /> </button> </Transition> </div> <!-- Mostrar archivo seleccionado (múltiple) --> <div v-if="multiple && modelValue && modelValue.length > 0" class="mt-2 space-y-1"> <div v-for="(file, index) in modelValue" :key="index" class="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-700" > <div class="flex items-center space-x-2 min-w-0"> <FileIcon class="w-5 h-5 text-gray-500 flex-shrink-0" /> <span class="text-sm text-gray-700 dark:text-gray-300 truncate"> {{ file.name }} </span> <span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0"> ({{ formatFileSize(file.size) }}) </span> </div> <button type="button" @click="removeFileAtIndex(index)" class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 flex-shrink-0 ml-2" > <XMarkIcon class="w-5 h-5" /> </button> </div> </div> <!-- Mensaje de error --> <p v-if="error" class="mt-1 text-sm text-red-600 dark:text-red-400"> {{ error }} </p> </div> </template> <script setup> import { ref } from 'vue'; import XMarkIcon from '@/Components/Icons/XMarkIcon.vue'; import FileIcon from '@/Components/Icons/FileIcon.vue'; const props = defineProps({ modelValue: { type: [File, Array, null], default: null, }, accept: { type: String, default: '.pdf,application/pdf', }, multiple: { type: Boolean, default: false, }, disabled: { type: Boolean, default: false, }, class: { type: String, default: '', }, error: { type: String, default: '', }, maxSize: { type: Number, default: 10240, }, }); const emit = defineEmits(['update:modelValue', 'error', 'clear']); const inputRef = ref(null); const handleInput = (event) => { const files = event.target.files; if (!files || files.length === 0) { emit('update:modelValue', props.multiple ? [] : null); return; } const validFiles = []; const invalidFiles = []; for (const file of files) { if (file.size > props.maxSize * 1024) { invalidFiles.push(file.name); } else { validFiles.push(file); } } if (invalidFiles.length > 0) { emit('error', `Los siguientes archivos exceden el límite de ${props.maxSize}KB: ${invalidFiles.join(', ')}`); if (inputRef.value) { inputRef.value.value = ''; } return; } emit('update:modelValue', props.multiple ? validFiles : (validFiles[0] || null)); emit('error', ''); }; const clearFile = () => { emit('update:modelValue', null); emit('clear'); if (inputRef.value) { inputRef.value.value = ''; } }; const removeFileAtIndex = (index) => { if (props.multiple && Array.isArray(props.modelValue)) { const newFiles = [...props.modelValue]; newFiles.splice(index, 1); emit('update:modelValue', newFiles); if (newFiles.length === 0 && inputRef.value) { inputRef.value.value = ''; } } }; const formatFileSize = (bytes) => { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; }; defineExpose({ reset: () => { if (inputRef.value) { inputRef.value.value = ''; } emit('update:modelValue', props.multiple ? [] : null); }, }); </script>
10. Resumen
En este tutorial hemos aprendido a:
✅ Agregar un botón de eliminación para archivos seleccionados
✅ Usar el icono XMark de Heroicons
✅ Implementar la función clearFile para resetear el input
✅ Mostrar el botón condicionalmente solo cuando hay archivo
✅ Manejar la herencia de atributos con v-bind="$attrs"
✅ Agregar animaciones para mejorar la UX
✅ Mantener la accesibilidad con atributos ARIA
✅ Soporte para archivos múltiples
¡Excelente trabajo! Ahora los usuarios pueden limpiar fácilmente la selección de archivos en el formulario
Comentarios
Publicar un comentario