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

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 con chatbots.

  • 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

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:

bash
npm install autosize
# o
yarn add autosize

Actualizar el componente:

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

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

javascript
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

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):

php
// 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

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

vue
<template>
    <Textarea v-model="text" :focus="true" placeholder="Escribe algo..." />
</template>

4.4. Uso con Auto-ajuste

vue
<template>
    <Textarea v-model="text" :autosize="true" rows="3" />
</template>

4.5. Uso con Clases Personalizadas

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

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

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

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

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

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