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