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
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
Dark Mode requiere el prefijo dark: en las clases
Props se heredan automáticamente al primer elemento
twMerge resuelve conflictos de clases
Componentes base reducen la duplicación
¡Los botones son ahora más flexibles, reutilizables y fáciles de mantener!
Comentarios
Publicar un comentario