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

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

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:

  1. Emitir update:modelValue con null para limpiar el valor

  2. Resetear el input file para que pueda seleccionar el mismo archivo nuevamente

resources/js/Components/FileInput.vue

javascript
<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

css
/* 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

vue
<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

vue
<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

javascript
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

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

vue
<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

vue
<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

vue
<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

vue
<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

vue
<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

vue
<!-- 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

Entradas más populares de este blog

1. Introducción al curso de Chatbots personalizados con Laravel & OpenAI

2. Prerequisitos para el Curso de Chatbots con Laravel y OpenAI

4. Herramientas de Calidad