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.
<!-- 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
<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.
/* 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
/* 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
/* 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)
<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
<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
<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
<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
<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
/* 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
<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
<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
<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
| Clase | Descripción |
|---|---|
file:mr-4 | Margen derecho al botón |
file:cursor-pointer | Cursor de mano |
file:border-none | Sin borde en el botón |
file:rounded-md | Bordes redondeados |
file:bg-gray-200 | Fondo gris claro |
file:text-gray-800 | Texto gris oscuro |
dark:file:bg-gray-700 | Fondo oscuro |
hover:file:bg-gray-300 | Hover en modo claro |
dark:hover:file:bg-gray-600 | Hover en modo oscuro |
border-gray-300 | Borde del contenedor |
p-2 | Padding interno |
9. Código Completo del Componente Final
<!-- 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
Publicar un comentario