Feat: Reemplazar sliders por selector de intensidad con iconos
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m6s

Crea nuevo componente SelectorIntensidad que reemplaza los sliders tradicionales por un selector tipo "rating" con iconos clicables:

Características del nuevo componente:
- Usa círculos (circle/circle-dot) para intensidad descriptiva
- Usa corazones (heart) para intensidad afectiva
- Los iconos se llenan hasta el valor seleccionado
- Efecto hover para preview
- Mismo rango: descriptiva (1-10), afectiva (1-15)
- Click en mismo valor lo deselecciona (vuelve a null)
- Soporte para colores personalizados
- Efectos de glow en modo oscuro
- Responsive (iconos más pequeños en móvil)

Cambios técnicos:
- Nuevo archivo: app/components/cata/SelectorIntensidad.vue
- Modificado: app/components/cata/FormularioMuestra.vue
  - Reemplazado CataSliderIntensidad por CataSelectorIntensidad (global)
- Mantiene la misma interfaz de props y eventos que SliderIntensidad
This commit is contained in:
2025-10-18 15:03:52 -06:00
parent 45d4b1d663
commit 417b430863
2 changed files with 299 additions and 32 deletions

View File

@@ -90,13 +90,13 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="descriptiva"
:model-value="muestra.intensidades.fragancia.descriptiva"
:color="getCategoryColor('fragancia')"
@update:model-value="(v) => actualizarIntensidad('fragancia', 'descriptiva', v)"
/>
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="afectiva"
:model-value="muestra.intensidades.fragancia.afectiva"
:color="getCategoryColor('fragancia')"
@@ -119,13 +119,13 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="descriptiva"
:model-value="muestra.intensidades.aroma.descriptiva"
:color="getCategoryColor('aroma')"
@update:model-value="(v) => actualizarIntensidad('aroma', 'descriptiva', v)"
/>
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="afectiva"
:model-value="muestra.intensidades.aroma.afectiva"
:color="getCategoryColor('aroma')"
@@ -148,13 +148,13 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="descriptiva"
:model-value="muestra.intensidades.sabor.descriptiva"
:color="getCategoryColor('sabor')"
@update:model-value="(v) => actualizarIntensidad('sabor', 'descriptiva', v)"
/>
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="afectiva"
:model-value="muestra.intensidades.sabor.afectiva"
:color="getCategoryColor('sabor')"
@@ -177,13 +177,13 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="descriptiva"
:model-value="muestra.intensidades.saborResidual.descriptiva"
:color="getCategoryColor('saborResidual')"
@update:model-value="(v) => actualizarIntensidad('saborResidual', 'descriptiva', v)"
/>
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="afectiva"
:model-value="muestra.intensidades.saborResidual.afectiva"
:color="getCategoryColor('saborResidual')"
@@ -206,13 +206,13 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="descriptiva"
:model-value="muestra.intensidades.acidez.descriptiva"
:color="getCategoryColor('acidez')"
@update:model-value="(v) => actualizarIntensidad('acidez', 'descriptiva', v)"
/>
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="afectiva"
:model-value="muestra.intensidades.acidez.afectiva"
:color="getCategoryColor('acidez')"
@@ -235,13 +235,13 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="descriptiva"
:model-value="muestra.intensidades.dulzor.descriptiva"
:color="getCategoryColor('dulzor')"
@update:model-value="(v) => actualizarIntensidad('dulzor', 'descriptiva', v)"
/>
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="afectiva"
:model-value="muestra.intensidades.dulzor.afectiva"
:color="getCategoryColor('dulzor')"
@@ -264,13 +264,13 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="descriptiva"
:model-value="muestra.intensidades.sensacionBoca.descriptiva"
:color="getCategoryColor('sensacionBoca')"
@update:model-value="(v) => actualizarIntensidad('sensacionBoca', 'descriptiva', v)"
/>
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="afectiva"
:model-value="muestra.intensidades.sensacionBoca.afectiva"
:color="getCategoryColor('sensacionBoca')"
@@ -293,13 +293,13 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="descriptiva"
:model-value="muestra.intensidades.impresionGlobal.descriptiva"
:color="getCategoryColor('impresionGlobal')"
@update:model-value="(v) => actualizarIntensidad('impresionGlobal', 'descriptiva', v)"
/>
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="afectiva"
:model-value="muestra.intensidades.impresionGlobal.afectiva"
:color="getCategoryColor('impresionGlobal')"
@@ -406,13 +406,13 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="descriptiva"
:model-value="muestra.intensidades.fragancia.descriptiva"
:color="getCategoryColor('fragancia')"
@update:model-value="(v) => actualizarIntensidad('fragancia', 'descriptiva', v)"
/>
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="afectiva"
:model-value="muestra.intensidades.fragancia.afectiva"
:color="getCategoryColor('fragancia')"
@@ -435,13 +435,13 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="descriptiva"
:model-value="muestra.intensidades.aroma.descriptiva"
:color="getCategoryColor('aroma')"
@update:model-value="(v) => actualizarIntensidad('aroma', 'descriptiva', v)"
/>
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="afectiva"
:model-value="muestra.intensidades.aroma.afectiva"
:color="getCategoryColor('aroma')"
@@ -464,13 +464,13 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="descriptiva"
:model-value="muestra.intensidades.sabor.descriptiva"
:color="getCategoryColor('sabor')"
@update:model-value="(v) => actualizarIntensidad('sabor', 'descriptiva', v)"
/>
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="afectiva"
:model-value="muestra.intensidades.sabor.afectiva"
:color="getCategoryColor('sabor')"
@@ -493,13 +493,13 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="descriptiva"
:model-value="muestra.intensidades.saborResidual.descriptiva"
:color="getCategoryColor('saborResidual')"
@update:model-value="(v) => actualizarIntensidad('saborResidual', 'descriptiva', v)"
/>
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="afectiva"
:model-value="muestra.intensidades.saborResidual.afectiva"
:color="getCategoryColor('saborResidual')"
@@ -522,13 +522,13 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="descriptiva"
:model-value="muestra.intensidades.acidez.descriptiva"
:color="getCategoryColor('acidez')"
@update:model-value="(v) => actualizarIntensidad('acidez', 'descriptiva', v)"
/>
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="afectiva"
:model-value="muestra.intensidades.acidez.afectiva"
:color="getCategoryColor('acidez')"
@@ -551,13 +551,13 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="descriptiva"
:model-value="muestra.intensidades.dulzor.descriptiva"
:color="getCategoryColor('dulzor')"
@update:model-value="(v) => actualizarIntensidad('dulzor', 'descriptiva', v)"
/>
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="afectiva"
:model-value="muestra.intensidades.dulzor.afectiva"
:color="getCategoryColor('dulzor')"
@@ -580,13 +580,13 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="descriptiva"
:model-value="muestra.intensidades.sensacionBoca.descriptiva"
:color="getCategoryColor('sensacionBoca')"
@update:model-value="(v) => actualizarIntensidad('sensacionBoca', 'descriptiva', v)"
/>
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="afectiva"
:model-value="muestra.intensidades.sensacionBoca.afectiva"
:color="getCategoryColor('sensacionBoca')"
@@ -609,13 +609,13 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="descriptiva"
:model-value="muestra.intensidades.impresionGlobal.descriptiva"
:color="getCategoryColor('impresionGlobal')"
@update:model-value="(v) => actualizarIntensidad('impresionGlobal', 'descriptiva', v)"
/>
<CataSliderIntensidad
<CataSelectorIntensidad
tipo="afectiva"
:model-value="muestra.intensidades.impresionGlobal.afectiva"
:color="getCategoryColor('impresionGlobal')"

View File

@@ -0,0 +1,267 @@
<template>
<div class="selector-intensidad cata-fade-in" :style="customColorStyle">
<!-- Contenedor de iconos -->
<div class="iconos-container">
<button
v-for="valor in iconos"
:key="valor"
type="button"
class="icono-item"
:class="{
'icono-activo': modelValue !== null && valor <= modelValue,
'icono-hover': valorHover !== null && valor <= valorHover,
'icono-descriptiva': tipo === 'descriptiva',
'icono-afectiva': tipo === 'afectiva',
}"
:disabled="disabled"
:title="`Valor: ${valor}`"
@click="handleClick(valor)"
@mouseenter="valorHover = valor"
@mouseleave="valorHover = null"
>
<!-- Icono para descriptiva: círculo -->
<UIcon
v-if="tipo === 'descriptiva'"
:name="(modelValue !== null && valor <= modelValue) || (valorHover !== null && valor <= valorHover)
? 'i-lucide-circle-dot'
: 'i-lucide-circle'"
class="icono"
/>
<!-- Icono para afectiva: corazón -->
<UIcon
v-else
name="i-lucide-heart"
class="icono"
:class="{
'icono-filled': (modelValue !== null && valor <= modelValue) || (valorHover !== null && valor <= valorHover)
}"
/>
</button>
</div>
<!-- Indicadores de valor -->
<div class="flex justify-between mt-2 text-xs cata-text opacity-60">
<span>{{ min }}</span>
<span v-if="modelValue !== null" class="font-semibold opacity-100">
{{ modelValue }}
</span>
<span>{{ max }}</span>
</div>
<!-- Descripción del tipo -->
<p v-if="showDescription" class="text-xs mt-1 cata-text opacity-75">
{{ tipoDescription }}
</p>
</div>
</template>
<script setup lang="ts">
interface SelectorIntensidadProps {
/** Tipo de intensidad: descriptiva (1-10) o afectiva (1-15) */
tipo: 'descriptiva' | 'afectiva'
/** Valor actual del selector */
modelValue: number | null
/** Etiqueta del selector */
label?: string
/** ID para el input (auto-generado si no se provee) */
id?: string
/** Deshabilitar el selector */
disabled?: boolean
/** Marcar como requerido */
required?: boolean
/** Mostrar descripción del tipo */
showDescription?: boolean
/** Color personalizado para el selector */
color?: string
}
const props = withDefaults(defineProps<SelectorIntensidadProps>(), {
disabled: false,
required: false,
showDescription: false,
})
const emit = defineEmits<{
'update:modelValue': [value: number | null]
}>()
// Estado para el hover
const valorHover = ref<number | null>(null)
// Configuración según el tipo
const min = computed(() => 1)
const max = computed(() => props.tipo === 'descriptiva' ? 10 : 15)
// Array de valores para los iconos
const iconos = computed(() => {
return Array.from({ length: max.value }, (_, i) => i + 1)
})
// Descripción del tipo de intensidad
const tipoDescription = computed(() => {
return props.tipo === 'descriptiva'
? 'Intensidad descriptiva: qué tan intensa es la característica (sin importar si es buena o mala)'
: 'Intensidad afectiva: qué tan buena o mala consideras esta característica'
})
// Manejar click en un icono
const handleClick = (valor: number) => {
if (props.disabled) return
// Si clickean el mismo valor, lo deseleccionan (vuelve a null)
if (props.modelValue === valor) {
emit('update:modelValue', null)
} else {
emit('update:modelValue', valor)
}
}
// Estilo dinámico para el color personalizado
const customColorStyle = computed(() => {
if (!props.color) return {}
return {
'--selector-custom-color': props.color,
}
})
</script>
<style scoped>
.selector-intensidad {
width: 100%;
}
.iconos-container {
display: flex;
gap: 0.25rem;
justify-content: center;
flex-wrap: wrap;
padding: 0.5rem 0;
}
.icono-item {
background: transparent;
border: none;
padding: 0.25rem;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.icono-item:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.icono-item:not(:disabled):hover {
transform: scale(1.15);
}
.icono-item:not(:disabled):active {
transform: scale(1.05);
}
/* Iconos */
.icono {
width: 1.5rem;
height: 1.5rem;
transition: all 0.2s ease;
}
/* Iconos descriptiva (círculos) */
.icono-descriptiva .icono {
color: color-mix(in srgb, var(--cata-primary) 40%, transparent);
}
.icono-descriptiva.icono-activo .icono,
.icono-descriptiva.icono-hover .icono {
color: var(--cata-primary);
}
/* Iconos afectiva (corazones) */
.icono-afectiva .icono {
color: color-mix(in srgb, var(--cata-primary) 40%, transparent);
stroke-width: 2;
}
.icono-afectiva.icono-activo .icono,
.icono-afectiva.icono-hover .icono {
color: var(--cata-primary);
}
/* Fill para corazones activos */
.icono-filled {
fill: currentColor;
}
/* Color personalizado */
.selector-intensidad[style*="--selector-custom-color"] .icono-descriptiva .icono {
color: color-mix(in srgb, var(--selector-custom-color) 40%, transparent);
}
.selector-intensidad[style*="--selector-custom-color"] .icono-descriptiva.icono-activo .icono,
.selector-intensidad[style*="--selector-custom-color"] .icono-descriptiva.icono-hover .icono {
color: var(--selector-custom-color);
}
.selector-intensidad[style*="--selector-custom-color"] .icono-afectiva .icono {
color: color-mix(in srgb, var(--selector-custom-color) 40%, transparent);
}
.selector-intensidad[style*="--selector-custom-color"] .icono-afectiva.icono-activo .icono,
.selector-intensidad[style*="--selector-custom-color"] .icono-afectiva.icono-hover .icono {
color: var(--selector-custom-color);
}
/* Modo oscuro */
.dark .icono-descriptiva .icono {
color: color-mix(in srgb, var(--cata-primary) 30%, transparent);
}
.dark .icono-descriptiva.icono-activo .icono,
.dark .icono-descriptiva.icono-hover .icono {
color: var(--cata-primary);
filter: drop-shadow(0 0 4px var(--cata-primary));
}
.dark .icono-afectiva .icono {
color: color-mix(in srgb, var(--cata-primary) 30%, transparent);
}
.dark .icono-afectiva.icono-activo .icono,
.dark .icono-afectiva.icono-hover .icono {
color: var(--cata-primary);
filter: drop-shadow(0 0 4px var(--cata-primary));
}
/* Color personalizado en modo oscuro */
.dark .selector-intensidad[style*="--selector-custom-color"] .icono-descriptiva.icono-activo .icono,
.dark .selector-intensidad[style*="--selector-custom-color"] .icono-descriptiva.icono-hover .icono {
filter: drop-shadow(0 0 4px var(--selector-custom-color));
}
.dark .selector-intensidad[style*="--selector-custom-color"] .icono-afectiva.icono-activo .icono,
.dark .selector-intensidad[style*="--selector-custom-color"] .icono-afectiva.icono-hover .icono {
filter: drop-shadow(0 0 4px var(--selector-custom-color));
}
/* Responsive */
@media (max-width: 640px) {
.icono {
width: 1.25rem;
height: 1.25rem;
}
.iconos-container {
gap: 0.15rem;
}
}
/* Animación de fade in */
.selector-intensidad.cata-fade-in {
animation: cata-fade-in 0.3s ease-out;
}
</style>