28. Diseñando el campo para seleccionar PDFs

 

28: Diseñando el Campo para Seleccionar PDFs

En este tutorial, aprenderemos a mejorar el diseño del campo de selección de archivos PDF. Personalizaremos el botón nativo del input file utilizando pseudo-elementos y clases CSS para lograr una apariencia más moderna y consistente con el resto del formulario.


1. El Problema con el Input File Nativo

El input de tipo file tiene un estilo nativo que varía según el navegador y el sistema operativo, lo que puede hacer que se vea inconsistente con el resto de la interfaz.

html
<!-- Input file nativo -->
<input type="file" accept=".pdf,application/pdf" />

El navegador genera automáticamente:

  • Un botón "Seleccionar archivo" (o similar)

  • El texto con el nombre del archivo seleccionado


2. Personalizar el Estilo del Input File

2.1. Modificar el Componente FileInput

resources/js/Components/FileInput.vue

vue
<template>
    <div>
        <input
            :ref="inputRef"
            type="file"
            :accept="accept"
            :multiple="multiple"
            :disabled="disabled"
            :class="[
                // Estilos base para el input file
                'block w-full text-sm text-gray-500 dark:text-gray-400',
                'file:mr-4',                    // Margen derecho al botón
                'file:cursor-pointer',          // Cursor pointer en el botón
                'file:border-none',             // Sin borde
                'file:rounded-md',              // Bordes redondeados
                'file:px-3',                    // Padding horizontal
                'file:py-1.5',                  // Padding vertical
                'file:text-sm',                 // Tamaño de texto
                'file:font-semibold',           // Fuente semibold
                'file:bg-gray-200',             // Fondo gris
                'file:text-gray-800',           // Texto gris oscuro
                'dark:file:bg-gray-700',        // Fondo oscuro
                'dark:file:text-white',         // Texto blanco en oscuro
                'hover:file:bg-gray-300',       // Hover en claro
                'dark:hover:file:bg-gray-600',  // Hover en oscuro
                'focus:outline-none',
                // Borde completo del input
                'border border-gray-300 dark:border-gray-600 rounded-md p-2',
                props.class,
            ]"
            @input="handleInput"
        />
        
        <!-- Mostrar archivo seleccionado -->
        <div v-if="modelValue && !multiple" class="mt-2">
            <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">
                    <FileIcon class="w-5 h-5 text-gray-500" />
                    <span class="text-sm text-gray-700 dark:text-gray-300 truncate max-w-xs">
                        {{ modelValue.name }}
                    </span>
                    <span class="text-xs text-gray-500 dark:text-gray-400">
                        ({{ formatFileSize(modelValue.size) }})
                    </span>
                </div>
                <button
                    type="button"
                    @click="removeFile"
                    class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 transition"
                >
                    <XMarkIcon class="w-5 h-5" />
                </button>
            </div>
        </div>

        <!-- Información de archivos 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">
                    <FileIcon class="w-5 h-5 text-gray-500" />
                    <span class="text-sm text-gray-700 dark:text-gray-300 truncate max-w-xs">
                        {{ file.name }}
                    </span>
                    <span class="text-xs text-gray-500 dark:text-gray-400">
                        ({{ 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 transition"
                >
                    <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 FileIcon from '@/Components/Icons/FileIcon.vue';
import XMarkIcon from '@/Components/Icons/XMarkIcon.vue';

const props = defineProps({
    modelValue: {
        type: [File, Array, null],
        default: null,
    },
    accept: {
        type: String,
        default: '',
    },
    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', 'change', 'error']);
const inputRef = ref(null);

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);
    }
    
    emit('change', props.multiple ? validFiles : (validFiles[0] || null));
};

const removeFile = () => {
    emit('update:modelValue', null);
    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);
    }
};

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>

3. Explicación de los Estilos

3.1. Pseudo-elemento file:

Tailwind CSS proporciona el modificador file: para apuntar al botón nativo del input file.

css
/* Estilos aplicados al botón del input file */
file:mr-4              /* Margen derecho: 1rem */
file:cursor-pointer    /* Cursor tipo mano */
file:border-none       /* Sin borde */
file:rounded-md        /* Bordes redondeados */
file:px-3              /* Padding horizontal: 0.75rem */
file:py-1.5            /* Padding vertical: 0.375rem */
file:text-sm           /* Tamaño de texto: 0.875rem */
file:font-semibold     /* Peso de fuente: 600 */
file:bg-gray-200       /* Fondo: gris 200 */
file:text-gray-800     /* Texto: gris 800 */

3.2. Estados del Botón

css
/* Estado hover en modo claro */
hover:file:bg-gray-300

/* Estado hover en modo oscuro */
dark:hover:file:bg-gray-600

/* Modo oscuro */
dark:file:bg-gray-700
dark:file:text-white

3.3. Contenedor del Input

css
/* Estilos del contenedor completo */
block w-full           /* Bloque y ancho completo */
text-sm                /* Tamaño de texto */
text-gray-500          /* Color de texto */
dark:text-gray-400     /* Color en modo oscuro */
border border-gray-300 /* Borde gris */
dark:border-gray-600   /* Borde en modo oscuro */
rounded-md             /* Bordes redondeados */
p-2                    /* Padding interno */
focus:outline-none     /* Quitar outline al focus */

4. Estilos Alternativos para el Input File

4.1. Estilo Primario (Azul)

vue
<input
    type="file"
    class="
        block w-full text-sm text-gray-500
        file:mr-4 file:py-2 file:px-4
        file:rounded-md file:border-0
        file:text-sm file:font-semibold
        file:bg-blue-500 file:text-white
        hover:file:bg-blue-600
        dark:file:bg-blue-600
        dark:hover:file:bg-blue-700
        border border-gray-300 rounded-md p-2
        focus:ring-2 focus:ring-blue-500 focus:border-blue-500
    "
/>

4.2. Estilo Outline

vue
<input
    type="file"
    class="
        block w-full text-sm text-gray-500
        file:mr-4 file:py-2 file:px-4
        file:rounded-md file:border-2 file:border-gray-300
        file:text-sm file:font-semibold
        file:bg-transparent file:text-gray-700
        hover:file:bg-gray-100
        dark:file:text-white
        dark:hover:file:bg-gray-800
        border border-gray-300 rounded-md p-2
    "
/>

4.3. Estilo con Icono

vue
<template>
    <div class="relative">
        <input
            type="file"
            class="
                block w-full text-sm text-gray-500
                file:mr-4 file:py-2 file:px-4
                file:rounded-md file:border-0
                file:text-sm file:font-semibold
                file:bg-indigo-50 file:text-indigo-700
                hover:file:bg-indigo-100
                dark:file:bg-indigo-900/20 dark:file:text-indigo-400
                dark:hover:file:bg-indigo-900/30
                border border-gray-300 rounded-md p-2
                pl-10
            "
        />
        <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
            <PaperClipIcon class="h-5 w-5 text-gray-400" />
        </div>
    </div>
</template>

5. Validación de Archivos con Estilos de Error

5.1. Input con Estado de Error

vue
<input
    type="file"
    :class="[
        'block w-full text-sm',
        'file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0',
        'file:text-sm file:font-semibold file:bg-gray-200 file:text-gray-800',
        'hover:file:bg-gray-300',
        'border rounded-md p-2',
        hasError 
            ? 'border-red-500 focus:ring-red-500' 
            : 'border-gray-300 focus:ring-indigo-500',
    ]"
/>

5.2. Mostrar Mensajes de Error

vue
<template>
    <div>
        <input
            type="file"
            :class="inputClasses"
            @input="handleInput"
        />
        
        <!-- Mensaje de error -->
        <Transition
            enter-active-class="transition ease-out duration-200"
            enter-from-class="opacity-0 translate-y-1"
            enter-to-class="opacity-100 translate-y-0"
            leave-active-class="transition ease-in duration-150"
            leave-from-class="opacity-100 translate-y-0"
            leave-to-class="opacity-0 translate-y-1"
        >
            <p v-if="error" class="mt-1 text-sm text-red-600 dark:text-red-400 flex items-center">
                <ExclamationCircleIcon class="w-4 h-4 mr-1" />
                {{ error }}
            </p>
        </Transition>
    </div>
</template>

6. Estilos Responsive

6.1. Ajustes para Móviles

css
/* Estilos responsive para el input file */
@media (max-width: 640px) {
    .file-input-mobile {
        @apply text-xs;
    }
    
    .file-input-mobile::file-selector-button {
        @apply text-xs py-1.5 px-3;
    }
}

6.2. Uso con Tailwind

vue
<input
    type="file"
    class="
        block w-full text-sm sm:text-base
        file:py-1.5 sm:file:py-2
        file:px-3 sm:file:px-4
        file:text-sm sm:file:text-base
        ...
    "
/>

7. Mejoras en la Experiencia de Usuario

7.1. Drag & Drop Visual

vue
<template>
    <div
        class="relative border-2 border-dashed rounded-lg p-6 text-center"
        :class="[
            isDragging 
                ? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20' 
                : 'border-gray-300 dark:border-gray-600'
        ]"
        @dragover.prevent="isDragging = true"
        @dragleave.prevent="isDragging = false"
        @drop.prevent="handleDrop"
    >
        <input
            type="file"
            class="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
            @input="handleInput"
        />
        
        <div class="space-y-2">
            <UploadIcon class="mx-auto h-12 w-12 text-gray-400" />
            <div class="text-sm text-gray-600 dark:text-gray-400">
                <span class="font-semibold text-indigo-600 dark:text-indigo-400">
                    Haz clic para seleccionar
                </span>
                o arrastra y suelta
            </div>
            <p class="text-xs text-gray-500 dark:text-gray-400">
                PDF hasta 10MB
            </p>
        </div>
    </div>
</template>

7.2. Previsualización del Archivo

vue
<template>
    <div>
        <input type="file" @input="handleInput" />
        
        <!-- Previsualización del PDF -->
        <div v-if="pdfUrl" class="mt-4">
            <iframe
                :src="pdfUrl"
                class="w-full h-64 rounded-md border border-gray-200"
                frameborder="0"
            />
            <button
                @click="removeFile"
                class="mt-2 text-sm text-red-600 hover:text-red-800"
            >
                Eliminar archivo
            </button>
        </div>
    </div>
</template>

<script setup>
import { ref } from 'vue';

const pdfUrl = ref(null);

const handleInput = (event) => {
    const file = event.target.files[0];
    if (file && file.type === 'application/pdf') {
        pdfUrl.value = URL.createObjectURL(file);
    }
};

const removeFile = () => {
    if (pdfUrl.value) {
        URL.revokeObjectURL(pdfUrl.value);
        pdfUrl.value = null;
    }
};
</script>

8. Resumen del Diseño Final

8.1. Características del Diseño

✅ Botón personalizado: Estilo consistente con el resto de la interfaz
✅ Feedback visual: Hover y estados activos
✅ Modo oscuro: Soporte completo para dark mode
✅ Responsive: Se adapta a diferentes tamaños de pantalla
✅ Accesibilidad: Cursor pointer y focus visible
✅ Manejo de errores: Indicadores visuales de error

8.2. Clases CSS Utilizadas

ClaseDescripción
file:mr-4Margen derecho al botón
file:cursor-pointerCursor de mano
file:border-noneSin borde en el botón
file:rounded-mdBordes redondeados
file:bg-gray-200Fondo gris claro
file:text-gray-800Texto gris oscuro
dark:file:bg-gray-700Fondo oscuro
hover:file:bg-gray-300Hover en modo claro
dark:hover:file:bg-gray-600Hover en modo oscuro
border-gray-300Borde del contenedor
p-2Padding interno

9. Código Completo del Componente Final

vue
<!-- resources/js/Components/FileInput.vue -->
<template>
    <div>
        <input
            :ref="inputRef"
            type="file"
            :accept="accept"
            :multiple="multiple"
            :disabled="disabled"
            :class="[
                'block 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"
        />
        
        <!-- Mostrar archivo seleccionado -->
        <div v-if="modelValue && !multiple" class="mt-2">
            <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="removeFile"
                    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 FileIcon from '@/Components/Icons/FileIcon.vue';
import XMarkIcon from '@/Components/Icons/XMarkIcon.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']);
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));
};

const removeFile = () => {
    emit('update:modelValue', null);
    if (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:

✅ Personalizar el input file con Tailwind CSS
✅ Usar el modificador file: para estilizar el botón nativo
✅ Mantener consistencia con el resto del formulario
✅ Agregar soporte para dark mode
✅ Mejorar la experiencia de usuario con feedback visual
✅ Mostrar información del archivo seleccionado
✅ Manejar estados de error de forma visual

¡Excelente trabajo! Ahora tienes un campo de selección de archivos con un diseño profesional y consistente. 

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