11. Restructurando los botones de Jetstream Vue

 

 Reestructurando los Botones de Jetstream Vue

1. Problema: Dark Mode y Estilos

1.1. Comportamiento de Dark Mode

Cuando trabajamos con Tailwind CSS y Dark Mode:

vue

<!-- ❌ Solo visible en modo claro -->

<button class="bg-blue-600 hover:bg-blue-700 text-white">

    Botón

</button>


<!-- ✅ Visible en ambos modos -->

<button class="bg-blue-600 hover:bg-blue-700 text-white 

               dark:bg-blue-700 dark:hover:bg-blue-800">

    Botón

</button>

Importante: Las clases sin el prefijo dark: solo se aplican en modo claro.

1.2. Verificación Rápida

vue

<script setup>

import { ref } from 'vue'


const isDarkMode = ref(false) // Simulación

</script>


<template>

    <div :class="isDarkMode ? 'dark' : ''">

        <!-- Modo oscuro activo -->

        <button class="bg-blue-600 dark:bg-blue-800">

            <!-- En modo oscuro: bg-blue-800 -->

            <!-- En modo claro: bg-blue-600 -->

        </button>

    </div>

</template>


2. Creación del Componente BaseButton

2.1. Estructura del BaseButton

Crear resources/js/Components/BaseButton.vue:

vue

<script setup>

import { computed } from 'vue'

import { Link } from '@inertiajs/vue3'

import { twMerge } from 'tailwind-merge'


const props = defineProps({

    // Propiedades obligatorias

    ariaLabel: {

        type: String,

        required: true

    },

    // Clases base del botón

    defaultClasses: {

        type: String,

        default: ''

    },

    // Clases adicionales

    class: {

        type: String,

        default: ''

    },

    // Para links de Inertia

    href: {

        type: String,

        default: ''

    }

})


// Combinar clases

const mergedClasses = computed(() => {

    return twMerge(props.defaultClasses, props.class)

})

</script>


<template>

    <!-- Si hay href, renderizar Link -->

    <Link

        v-if="href"

        :href="href"

        :class="mergedClasses"

        :aria-label="ariaLabel"

    >

        <slot />

    </Link>

    

    <!-- Si no hay href, renderizar botón -->

    <button

        v-else

        :class="mergedClasses"

        :aria-label="ariaLabel"

    >

        <slot />

    </button>

</template>

2.2. Clases Base por Tipo

javascript

// utils/buttonClasses.js

export const buttonBaseClasses = `

    inline-flex

    items-center

    px-4

    py-2

    rounded-md

    font-semibold

    text-xs

    uppercase

    tracking-widest

    transition

    ease-in-out

    duration-150

    focus:outline-none

    focus:ring-2

    focus:ring-offset-2

    dark:focus:ring-offset-gray-800

`


// Variantes específicas

export const primaryClasses = `

    ${buttonBaseClasses}

    bg-blue-600

    border

    border-transparent

    text-white

    hover:bg-blue-700

    active:bg-blue-900

    focus:bg-blue-700

    focus:ring-blue-500

    dark:bg-blue-700

    dark:hover:bg-blue-800

    dark:active:bg-blue-950

    dark:focus:bg-blue-800

    dark:focus:ring-blue-400

`


export const secondaryClasses = `

    ${buttonBaseClasses}

    bg-gray-200

    border

    border-gray-300

    text-gray-700

    hover:bg-gray-300

    active:bg-gray-400

    focus:bg-gray-300

    focus:ring-gray-500

    dark:bg-gray-700

    dark:border-gray-600

    dark:text-gray-200

    dark:hover:bg-gray-600

    dark:active:bg-gray-500

    dark:focus:bg-gray-600

    dark:focus:ring-gray-400

`


export const dangerClasses = `

    ${buttonBaseClasses}

    bg-red-600

    border

    border-transparent

    text-white

    hover:bg-red-700

    active:bg-red-900

    focus:bg-red-700

    focus:ring-red-500

    dark:bg-red-700

    dark:hover:bg-red-800

    dark:active:bg-red-950

    dark:focus:bg-red-800

    dark:focus:ring-red-400

`


export const successClasses = `

    ${buttonBaseClasses}

    bg-green-600

    border

    border-transparent

    text-white

    hover:bg-green-700

    active:bg-green-900

    focus:bg-green-700

    focus:ring-green-500

    dark:bg-green-700

    dark:hover:bg-green-800

    dark:active:bg-green-950

    dark:focus:bg-green-800

    dark:focus:ring-green-400

`


3. Componentes de Botones Refactorizados

3.1. PrimaryButton Refactorizado

resources/js/Components/PrimaryButton.vue:

vue

<script setup>

import BaseButton from './BaseButton.vue'

import { primaryClasses } from '@/utils/buttonClasses'


// Solo necesitamos ariaLabel para el template

// Todas las demás props se heredan automáticamente

defineProps({

    ariaLabel: {

        type: String,

        required: true

    }

})

</script>


<template>

    <BaseButton 

        :default-classes="primaryClasses"

        :aria-label="ariaLabel"

    >

        <slot />

    </BaseButton>

</template>

3.2. SecondaryButton Refactorizado

resources/js/Components/SecondaryButton.vue:

vue

<script setup>

import BaseButton from './BaseButton.vue'

import { secondaryClasses } from '@/utils/buttonClasses'


defineProps({

    ariaLabel: {

        type: String,

        required: true

    }

})

</script>


<template>

    <BaseButton 

        :default-classes="secondaryClasses"

        :aria-label="ariaLabel"

    >

        <slot />

    </BaseButton>

</template>

3.3. DangerButton Refactorizado

resources/js/Components/DangerButton.vue:

vue

<script setup>

import BaseButton from './BaseButton.vue'

import { dangerClasses } from '@/utils/buttonClasses'


defineProps({

    ariaLabel: {

        type: String,

        required: true

    }

})

</script>


<template>

    <BaseButton 

        :default-classes="dangerClasses"

        :aria-label="ariaLabel"

    >

        <slot />

    </BaseButton>

</template>

3.4. SuccessButton (Nuevo)

resources/js/Components/SuccessButton.vue:

vue

<script setup>

import BaseButton from './BaseButton.vue'

import { successClasses } from '@/utils/buttonClasses'


defineProps({

    ariaLabel: {

        type: String,

        required: true

    }

})

</script>


<template>

    <BaseButton 

        :default-classes="successClasses"

        :aria-label="ariaLabel"

    >

        <slot />

    </BaseButton>

</template>


4. Actualización del ChatbotListItem

4.1. Componente Actualizado

resources/js/Components/Chatbots/ChatbotListItem.vue:

vue

<script setup>

import dayjs, { timeAgo } from '@/utils/dayjs'

import { PrimaryButton, SecondaryButton, DangerButton } from '@/Components/Buttons'


const emit = defineEmits(['delete'])


defineProps({

    chatbot: {

        type: Object,

        required: true

    }

})

</script>


<template>

    <div class="flex flex-col md:flex-row md:items-center md:justify-between py-4">

        <div class="flex-1">

            <div class="text-gray-800 dark:text-gray-200 font-medium">

                {{ chatbot.name }}

            </div>

            <div class="text-sm text-gray-500 dark:text-gray-400">

                Creado: {{ timeAgo(chatbot.created_at) }}

            </div>

            <div class="flex gap-x-4 mt-1 text-xs text-gray-400 dark:text-gray-500">

                <span>Modelo: {{ chatbot.model }}</span>

                <span>Temperatura: {{ chatbot.temperature }}</span>

            </div>

        </div>


        <div class="flex space-x-2 mt-4 md:mt-0">

            <!-- Ver - PrimaryButton como link -->

            <PrimaryButton 

                :href="route('chatbots.show', chatbot.id)"

                aria-label="Ver detalles del chatbot"

            >

                Ver

            </PrimaryButton>

            

            <!-- Editar - SecondaryButton como link -->

            <SecondaryButton 

                :href="route('chatbots.edit', chatbot.id)"

                aria-label="Editar chatbot"

            >

                Editar

            </SecondaryButton>

            

            <!-- Eliminar - DangerButton como botón -->

            <DangerButton 

                aria-label="Eliminar chatbot"

                @click="$emit('delete', chatbot.id)"

            >

                Eliminar

            </DangerButton>

        </div>

    </div>

</template>


5. Creación de Botones Personalizados

5.1. Botón con Icono

resources/js/Components/IconButton.vue:

vue

<script setup>

import BaseButton from './BaseButton.vue'

import { primaryClasses } from '@/utils/buttonClasses'

import { twMerge } from 'tailwind-merge'


const props = defineProps({

    ariaLabel: {

        type: String,

        required: true

    },

    variant: {

        type: String,

        default: 'primary'

    },

    size: {

        type: String,

        default: 'md'

    }

})


const variants = {

    primary: primaryClasses,

    secondary: secondaryClasses,

    danger: dangerClasses,

    success: successClasses

}


const sizeClasses = {

    sm: 'p-1.5',

    md: 'p-2',

    lg: 'p-3'

}


const defaultClasses = twMerge(

    variants[props.variant],

    'rounded-full',

    sizeClasses[props.size]

)

</script>


<template>

    <BaseButton 

        :default-classes="defaultClasses"

        :aria-label="ariaLabel"

        v-bind="$attrs"

    >

        <slot />

    </BaseButton>

</template>


<!-- Uso -->

<IconButton aria-label="Ajustes">

    <svg class="w-5 h-5">...</svg>

</IconButton>

5.2. Botón de Carga (Loading)

resources/js/Components/LoadingButton.vue:

vue

<script setup>

import { computed } from 'vue'

import PrimaryButton from './PrimaryButton.vue'


const props = defineProps({

    loading: {

        type: Boolean,

        default: false

    },

    ariaLabel: {

        type: String,

        required: true

    }

})


const buttonClasses = computed(() => ({

    'opacity-70 cursor-not-allowed': props.loading

}))

</script>


<template>

    <PrimaryButton 

        :aria-label="ariaLabel"

        :class="buttonClasses"

        :disabled="loading"

        v-bind="$attrs"

    >

        <span v-if="loading" class="inline-flex items-center gap-2">

            <svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">

                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" />

                <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />

            </svg>

            Cargando...

        </span>

        <slot v-else />

    </PrimaryButton>

</template>


<!-- Uso -->

<LoadingButton 

    :loading="isLoading" 

    aria-label="Guardar cambios"

    @click="saveData"

>

    Guardar

</LoadingButton>


6. Beneficios de la Refactorización

6.1. Antes vs Después

Antes (código duplicado):

vue

<!-- PrimaryButton.vue - 50+ líneas -->

<template>

    <button class="bg-blue-600...">

        <slot />

    </button>

</template>


<!-- SecondaryButton.vue - 50+ líneas -->

<template>

    <button class="bg-gray-200...">

        <slot />

    </button>

</template>

Después (código reutilizado):

vue

<!-- BaseButton.vue - 30 líneas (una vez) -->

<!-- PrimaryButton.vue - 12 líneas -->

<!-- SecondaryButton.vue - 12 líneas -->

<!-- DangerButton.vue - 12 líneas -->

6.2. Ventajas

  • ✅ Código más limpio y mantenible

  • ✅ Menos duplicación de estilos

  • ✅ Fácil añadir nuevos tipos de botones

  • ✅ Soporte consistente para Dark Mode

  • ✅ Soporte nativo para Inertia Links

  • ✅ Props heredadas automáticamente

6.3. Diagrama de Herencia

text

       ┌─────────────────┐

        │   BaseButton    │

        │  (Componente)   │

        └────────┬────────┘

                 │

    ┌────────────┼────────────┐

    │            │            │

    ▼            ▼            ▼

┌────────┐ ┌──────────┐ ┌──────────┐

│Primary │ │Secondary │ │ Danger   │

│Button  │ │Button    │ │ Button   │

└────────┘ └──────────┘ └──────────┘

    │            │            │

    └────────────┼────────────┘

                 ▼

         ┌─────────────┐

         │   Nuevos     │

         │   Botones    │

         └─────────────┘


7. Consideraciones de Accesibilidad

7.1. ARIA Labels

vue

<!-- ✅ Bueno: Label descriptivo -->

<PrimaryButton aria-label="Ver detalles del chatbot">

    Ver

</PrimaryButton>


<!-- ✅ Bueno: Contexto completo -->

<SecondaryButton aria-label="Editar chatbot: {{ chatbot.name }}">

    Editar

</SecondaryButton>


<!-- ❌ Malo: Label genérico -->

<DangerButton aria-label="Botón">

    Eliminar

</DangerButton>

7.2. Estados de Discapacidad

vue

<script setup>

const props = defineProps({

    disabled: {

        type: Boolean,

        default: false

    }

})


const buttonClasses = computed(() => ({

    'opacity-50 cursor-not-allowed': props.disabled

}))

</script>


<template>

    <button 

        :class="buttonClasses"

        :disabled="disabled"

        :aria-disabled="disabled"

    >

        <slot />

    </button>

</template>


8. Resumen

8.1. Archivos Creados/Modificados

Archivo

Acción

resources/js/Components/BaseButton.vue

✅ Creado

resources/js/Components/PrimaryButton.vue

✅ Refactorizado

resources/js/Components/SecondaryButton.vue

✅ Refactorizado

resources/js/Components/DangerButton.vue

✅ Refactorizado

resources/js/Components/SuccessButton.vue

✅ Creado (opcional)

resources/js/Components/Chatbots/ChatbotListItem.vue

✅ Actualizado

8.2. Comandos y Paquetes

bash

# Dependencias

npm install --save-dev tailwind-merge


# Uso en componentes

import { twMerge } from 'tailwind-merge'

8.3. Lecciones Aprendidas

  1. Dark Mode requiere el prefijo dark: en las clases

  2. Props se heredan automáticamente al primer elemento

  3. twMerge resuelve conflictos de clases

  4. Componentes base reducen la duplicación

¡Los botones son ahora más flexibles, reutilizables y fáciles de mantener! 


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