20. Componente Textarea
20: Componente Textarea
En este tutorial, aprenderemos a crear un componente Textarea reutilizable para nuestra aplicación, con funcionalidades como autofocus y auto-ajuste de altura. También mejoraremos la navegación para que el link de "Chatbots" esté activo en todas las rutas relacionadas.
1. Mejorar la Navegación: Activar el Link de Chatbots
Antes de crear el componente, vamos a mejorar la navegación para que el link de "Chatbots" permanezca activo cuando estemos en las páginas de creación, edición o visualización.
1.1. Modificar AppLayout
resources/js/Layouts/AppLayout.vue
<template> <div> <!-- Navegación principal --> <nav class="bg-white border-b border-gray-100"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="flex justify-between h-16"> <div class="flex"> <!-- Logo --> <div class="shrink-0 flex items-center"> <Link :href="route('dashboard')"> <ApplicationLogo class="block h-9 w-auto fill-current text-gray-800" /> </Link> </div> <!-- Links de navegación --> <div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex"> <NavLink :href="route('dashboard')" :active="route().current('dashboard')"> Dashboard </NavLink> <NavLink :href="route('chatbots.index')" :active="route().current('chatbots.*')" <!-- ← Cambio aquí --> > Chatbots </NavLink> </div> </div> <!-- Configuración del usuario --> <div class="hidden sm:flex sm:items-center sm:ms-6"> <div class="ms-3 relative"> <Dropdown align="right" width="48"> <template #trigger> <span class="inline-flex rounded-md"> <button type="button" class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150"> {{ $page.props.auth.user.name }} <svg class="ms-2 -me-0.5 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" /> </svg> </button> </span> </template> <template #content> <DropdownLink :href="route('profile.edit')">Perfil</DropdownLink> <DropdownLink :href="route('logout')" method="post" as="button"> Cerrar Sesión </DropdownLink> </template> </Dropdown> </div> </div> </div> </div> </nav> <!-- Navegación responsive --> <div class="sm:hidden"> <div class="pt-2 pb-3 space-y-1"> <ResponsiveNavLink :href="route('dashboard')" :active="route().current('dashboard')"> Dashboard </ResponsiveNavLink> <ResponsiveNavLink :href="route('chatbots.index')" :active="route().current('chatbots.*')" <!-- ← Cambio aquí --> > Chatbots </ResponsiveNavLink> </div> </div> <!-- Contenido principal --> <main> <slot /> </main> </div> </template> <script setup> import ApplicationLogo from '@/Components/ApplicationLogo.vue'; import Dropdown from '@/Components/Dropdown.vue'; import DropdownLink from '@/Components/DropdownLink.vue'; import NavLink from '@/Components/NavLink.vue'; import ResponsiveNavLink from '@/Components/ResponsiveNavLink.vue'; import { Link } from '@inertiajs/vue3'; </script>
Explicación:
route().current('chatbots.*')usa un patrón wildcard para activar el link en cualquier ruta que comience conchatbots.Esto incluye:
chatbots.index,chatbots.create,chatbots.edit,chatbots.show, etc.
2. Crear el Componente Textarea
Ahora vamos a crear un componente Textarea reutilizable basado en el componente TextInput existente, pero con funcionalidades adicionales.
2.1. Estructura del Componente
resources/js/Components/Textarea.vue
<template> <textarea :ref="textareaRef" :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" v-bind="$attrs" :class="[ 'border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm', props.class, ]" /> </template> <script setup> import { ref, onMounted, watch } from 'vue'; const props = defineProps({ modelValue: { type: String, default: '', }, class: { type: String, default: '', }, focus: { type: Boolean, default: false, }, autosize: { type: Boolean, default: true, }, }); const emit = defineEmits(['update:modelValue']); const textareaRef = ref(null); // Exponer el método focus al componente padre defineExpose({ focus: () => { if (textareaRef.value) { textareaRef.value.focus(); } }, }); // Auto-focus cuando el componente se monta onMounted(() => { if (props.focus && textareaRef.value) { textareaRef.value.focus(); } }); // Watch para auto-ajuste de altura watch( () => props.modelValue, () => { if (props.autosize && textareaRef.value) { autoResize(textareaRef.value); } }, { flush: 'post' } ); // Función para auto-ajustar la altura del textarea const autoResize = (element) => { element.style.height = 'auto'; element.style.height = element.scrollHeight + 'px'; }; </script>
2.2. Versión con autosize (Usando librería)
Si prefieres usar la librería autosize para un comportamiento más robusto:
Instalar la dependencia:
npm install autosize # o yarn add autosize
Actualizar el componente:
<template> <textarea :ref="setTextareaRef" :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" v-bind="$attrs" :class="[ 'border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm', props.class, ]" /> </template> <script setup> import { ref, onMounted, onBeforeUnmount, watch } from 'vue'; import autosize from 'autosize'; const props = defineProps({ modelValue: { type: String, default: '', }, class: { type: String, default: '', }, focus: { type: Boolean, default: false, }, autosize: { type: Boolean, default: true, }, }); const emit = defineEmits(['update:modelValue']); const textareaRef = ref(null); // Función para establecer la referencia y aplicar autosize const setTextareaRef = (el) => { textareaRef.value = el; if (el && props.autosize) { autosize(el); } }; // Exponer el método focus defineExpose({ focus: () => { if (textareaRef.value) { textareaRef.value.focus(); } }, }); // Auto-focus cuando el componente se monta onMounted(() => { if (props.focus && textareaRef.value) { textareaRef.value.focus(); } }); // Actualizar autosize cuando el valor cambie watch( () => props.modelValue, () => { if (props.autosize && textareaRef.value) { autosize.update(textareaRef.value); } } ); // Limpiar autosize al desmontar onBeforeUnmount(() => { if (props.autosize && textareaRef.value) { autosize.destroy(textareaRef.value); } }); </script>
3. Integrar el Componente Textarea en el Formulario
3.1. Actualizar el Formulario de Chatbot
resources/js/Components/ChatbotForm.vue
<template> <form @submit.prevent="handleSubmit"> <!-- Campo: Nombre --> <div class="mb-4"> <label for="name" class="block text-sm font-medium text-gray-700 mb-2"> Nombre del Chatbot </label> <TextInput id="name" v-model="form.name" type="text" class="w-full" placeholder="Ej: Asistente de Ventas" :error="form.errors.name" :focus="true" /> <p v-if="form.errors.name" class="mt-1 text-sm text-red-600"> {{ form.errors.name }} </p> </div> <!-- Campo: Descripción --> <div class="mb-4"> <label for="description" class="block text-sm font-medium text-gray-700 mb-2"> Descripción </label> <Textarea id="description" v-model="form.description" class="w-full" rows="3" placeholder="Describe el propósito de este chatbot..." :autosize="true" /> <p v-if="form.errors.description" class="mt-1 text-sm text-red-600"> {{ form.errors.description }} </p> </div> <!-- Nuevo campo: System Prompt --> <div class="mb-4"> <label for="system_prompt" class="block text-sm font-medium text-gray-700 mb-2"> System Prompt </label> <Textarea id="system_prompt" v-model="form.system_prompt" class="w-full font-mono" rows="5" placeholder="Define el comportamiento del chatbot..." :autosize="true" /> <p class="mt-1 text-xs text-gray-500"> Define cómo el chatbot debe comportarse y responder a los usuarios. </p> <p v-if="form.errors.system_prompt" class="mt-1 text-sm text-red-600"> {{ form.errors.system_prompt }} </p> </div> <!-- Botones --> <div class="flex items-center justify-end"> <Link :href="cancelRoute || route('chatbots.index')" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 mr-2"> Cancelar </Link> <button type="submit" :disabled="form.processing" class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition disabled:opacity-50"> {{ submitText || 'Guardar' }} </button> </div> </form> </template> <script setup> import { Link } from '@inertiajs/vue3'; import TextInput from '@/Components/TextInput.vue'; import Textarea from '@/Components/Textarea.vue'; const props = defineProps({ form: { type: Object, required: true }, submitText: { type: String, default: 'Guardar' }, cancelRoute: { type: String, default: null }, handleSubmit: { type: Function, required: true } }); </script>
3.2. Actualizar el Archivo de Formulario
resources/js/forms/chatbot.js
import { useForm } from '@inertiajs/vue3'; // Opciones por defecto const getDefaultOptions = (customOptions = {}) => { const defaultOptions = { preserveScroll: true, preserveState: true, onSuccess: () => {}, onError: () => {}, onFinish: () => {}, }; return { ...defaultOptions, ...customOptions }; }; // Crear el formulario con el nuevo campo system_prompt export const createForm = (chatbot = {}) => { return useForm({ name: chatbot.name || '', description: chatbot.description || '', system_prompt: chatbot.system_prompt || '', // Nuevo campo }); }; // Enviar para crear export const store = (form, options = {}) => { const finalOptions = getDefaultOptions(options); return form.post(route('chatbots.store'), finalOptions); }; // Enviar para actualizar export const update = (form, chatbotId, options = {}) => { const finalOptions = getDefaultOptions(options); return form.put(route('chatbots.update', chatbotId), finalOptions); };
3.3. Actualizar Controlador y Validación
app/Http/Requests/ChatbotRequest.php
namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class ChatbotRequest extends FormRequest { public function authorize() { return true; } public function rules() { return [ 'name' => 'required|string|max:255|unique:chatbots,name,' . $this->chatbot?.id, 'description' => 'nullable|string|max:1000', 'system_prompt' => 'nullable|string|max:5000', // Nuevo campo ]; } public function messages() { return [ 'name.required' => 'El nombre del chatbot es obligatorio', 'name.unique' => 'Ya existe un chatbot con este nombre', 'name.max' => 'El nombre no puede tener más de 255 caracteres', 'description.max' => 'La descripción no puede tener más de 1000 caracteres', 'system_prompt.max' => 'El system prompt no puede tener más de 5000 caracteres', ]; } }
Migración (si es necesario agregar el campo):
// database/migrations/xxxx_xx_xx_add_system_prompt_to_chatbots_table.php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up() { Schema::table('chatbots', function (Blueprint $table) { $table->text('system_prompt')->nullable()->after('description'); }); } public function down() { Schema::table('chatbots', function (Blueprint $table) { $table->dropColumn('system_prompt'); }); } };
4. Características del Componente Textarea
4.1. Funcionalidades Implementadas
✅ Auto-ajuste de altura (autosize)
✅ Auto-focus opcional
✅ Two-way binding con v-model
✅ Soporte para clases personalizadas
✅ Exposición del método focus para uso externo
✅ Limpieza automática en el ciclo de vida
4.2. Uso Básico
<template> <Textarea v-model="text" placeholder="Escribe algo..." /> </template> <script setup> import Textarea from '@/Components/Textarea.vue'; const text = ref(''); </script>
4.3. Uso con Auto-focus
<template> <Textarea v-model="text" :focus="true" placeholder="Escribe algo..." /> </template>
4.4. Uso con Auto-ajuste
<template> <Textarea v-model="text" :autosize="true" rows="3" /> </template>
4.5. Uso con Clases Personalizadas
<template> <Textarea v-model="text" class="bg-gray-100 p-4 text-lg font-mono" placeholder="Escribe algo..." /> </template>
4.6. Acceder al Método focus desde el Padre
<template> <div> <Textarea ref="textareaRef" v-model="text" /> <button @click="focusTextarea">Enfocar</button> </div> </template> <script setup> import { ref } from 'vue'; import Textarea from '@/Components/Textarea.vue'; const text = ref(''); const textareaRef = ref(null); const focusTextarea = () => { if (textareaRef.value) { textareaRef.value.focus(); } }; </script>
5. Estilos CSS para el Textarea
Si quieres agregar estilos adicionales al textarea:
/* resources/css/app.css */ .textarea-custom { @apply w-full rounded-md shadow-sm; @apply border-gray-300 focus:border-indigo-500 focus:ring-indigo-500; @apply dark:bg-gray-900 dark:border-gray-700 dark:text-gray-300; @apply dark:focus:border-indigo-600 dark:focus:ring-indigo-600; @apply transition duration-150 ease-in-out; } .textarea-custom::placeholder { @apply text-gray-400 dark:text-gray-500; } .textarea-custom:disabled { @apply opacity-50 cursor-not-allowed; } /* Estilo para cuando el textarea está en estado de error */ .textarea-custom.error { @apply border-red-500 focus:border-red-500 focus:ring-red-500; }
6. Mejora Adicional: Textarea con Contador de Caracteres
Si necesitas mostrar un contador de caracteres:
<template> <div> <Textarea v-model="localValue" :maxlength="maxLength" class="w-full" :class="{ 'border-red-500 focus:border-red-500 focus:ring-red-500': characterCount > maxLength }" /> <div class="flex justify-end mt-1"> <span class="text-xs" :class="{ 'text-red-600': characterCount > maxLength, 'text-gray-500': characterCount <= maxLength }" > {{ characterCount }} / {{ maxLength }} </span> </div> </div> </template> <script setup> import { computed } from 'vue'; import Textarea from '@/Components/Textarea.vue'; const props = defineProps({ modelValue: { type: String, default: '' }, maxLength: { type: Number, default: 5000 } }); const emit = defineEmits(['update:modelValue']); const localValue = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) }); const characterCount = computed(() => localValue.value?.length || 0); </script>
7. Resumen
Lo que hemos logrado:
✅ Mejoramos la navegación para que el link "Chatbots" se active en todas las rutas relacionadas
✅ Creamos un componente Textarea reutilizable con auto-ajuste de altura
✅ Integramos el nuevo componente en el formulario de chatbots
✅ Añadimos el campo system_prompt al modelo y al formulario
✅ Implementamos funcionalidades como auto-focus y exposición de métodos
Beneficios:
Reutilización: El componente Textarea puede usarse en toda la aplicación
Mantenibilidad: Los cambios en el comportamiento del textarea se hacen en un solo lugar
Mejor UX: El auto-ajuste de altura mejora la experiencia del usuario
Flexibilidad: Configurable a través de props y clases
Próximos pasos:
Añadir más componentes reutilizables (Select, Checkbox, Radio, etc.)
Mejorar los estilos del textarea con temas oscuros/claros
Implementar validación en tiempo real
Crear un componente WYSIWYG para contenido enriquecido
8. Código Final del Componente Textarea
Aquí tienes el código completo del componente para que puedas copiarlo y usarlo directamente:
resources/js/Components/Textarea.vue
<template> <textarea :ref="setTextareaRef" :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" v-bind="$attrs" :class="[ 'border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300', 'focus:border-indigo-500 dark:focus:border-indigo-600', 'focus:ring-indigo-500 dark:focus:ring-indigo-600', 'rounded-md shadow-sm', props.class, ]" /> </template> <script setup> import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'; import autosize from 'autosize'; const props = defineProps({ modelValue: { type: String, default: '', }, class: { type: String, default: '', }, focus: { type: Boolean, default: false, }, autosize: { type: Boolean, default: true, }, }); const emit = defineEmits(['update:modelValue']); const textareaRef = ref(null); const setTextareaRef = (el) => { textareaRef.value = el; if (el && props.autosize) { autosize(el); } }; defineExpose({ focus: () => { if (textareaRef.value) { textareaRef.value.focus(); } }, update: () => { if (props.autosize && textareaRef.value) { autosize.update(textareaRef.value); } }, }); onMounted(() => { if (props.focus && textareaRef.value) { nextTick(() => { textareaRef.value.focus(); }); } }); watch( () => props.modelValue, () => { if (props.autosize && textareaRef.value) { nextTick(() => { autosize.update(textareaRef.value); }); } } ); onBeforeUnmount(() => { if (props.autosize && textareaRef.value) { autosize.destroy(textareaRef.value); } }); </script>
¡Componente listo para usar
Comentarios
Publicar un comentario