Files
cataRio/nuxt4/app/components/cata/SelectorFamilia.vue
josedario87 fe24b3e724
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m7s
Refactor: Simplificar SelectorFamilia y agregar iconos contextuales
- Eliminar títulos de secciones para interfaz más limpia
  - Quitar "Categorías (selección múltiple)"
  - Quitar "Subcategorías (selección múltiple)"
  - Quitar "Nota Específica"

- Implementar iconos contextuales para marcar selecciones
  - Fragancia/Aroma: i-lucide-wind (viento/aroma)
  - Sabor: i-lucide-ice-cream-cone (comida/sabor)
  - Aplicar tanto en categorías como subcategorías
  - Reemplazar check genérico por iconos específicos según tipo

- Interfaz más intuitiva con iconos semánticos
2025-10-19 01:40:06 -06:00

445 lines
11 KiB
Vue

<template>
<div class="selector-familia cata-fade-in">
<!-- Label principal -->
<label v-if="label" class="block text-sm font-medium mb-3 cata-text">
{{ label }}
<span v-if="required" class="text-error">*</span>
</label>
<!-- Nivel 1: Categorías principales (selección múltiple) -->
<div class="nivel-container">
<div class="categorias-grid">
<button
v-for="categoria in categoriasDisponibles"
:key="categoria"
type="button"
:class="[
'categoria-item',
'cata-checkbox',
{
'cata-checkbox-checked': modelValue.categorias.includes(categoria),
'disabled': disabled,
},
]"
:disabled="disabled"
@click="toggleCategoria(categoria)"
>
<div class="categoria-content">
<UIcon
:name="categoriaIconos[categoria]"
class="categoria-icon"
:style="{ color: categoriaColores[categoria] }"
/>
<span class="categoria-text cata-text">{{ categoria }}</span>
</div>
<UIcon
v-if="modelValue.categorias.includes(categoria)"
:name="tipo === 'fragancia-aroma' ? 'i-lucide-wind' : 'i-lucide-ice-cream-cone'"
class="categoria-check"
/>
</button>
</div>
</div>
<!-- Nivel 2: Subcategorías (unión de todas las categorías seleccionadas, selección múltiple) -->
<div v-if="subcategoriasDisponibles.length > 0" class="nivel-container mt-4">
<div class="subcategorias-grid">
<button
v-for="subcategoria in subcategoriasDisponibles"
:key="subcategoria"
type="button"
:class="[
'subcategoria-item',
'cata-checkbox',
{
'cata-checkbox-checked': modelValue.subcategorias.includes(subcategoria),
'disabled': disabled,
},
]"
:disabled="disabled"
@click="toggleSubcategoria(subcategoria)"
>
<span class="subcategoria-text cata-text">{{ subcategoria }}</span>
<UIcon
v-if="modelValue.subcategorias.includes(subcategoria)"
:name="tipo === 'fragancia-aroma' ? 'i-lucide-wind' : 'i-lucide-ice-cream-cone'"
class="subcategoria-check"
/>
</button>
</div>
</div>
<!-- Nivel 3: Nota específica (input libre) -->
<div v-if="modelValue.categorias.length > 0" class="nivel-container mt-4">
<input
v-model="notaEspecificaLocal"
type="text"
:placeholder="notaPlaceholder"
:disabled="disabled"
class="cata-input w-full"
@blur="actualizarNotaEspecifica"
>
</div>
</div>
</template>
<script setup lang="ts">
import type { NotaSeleccionada, CategoriaNotaPrincipal } from '~/types/catacion'
import { FAMILIAS_NOTAS_ESTRUCTURA } from '~/types/catacion'
interface SelectorFamiliaProps {
/** Tipo de selector: fragancia-aroma o sabor */
tipo: 'fragancia-aroma' | 'sabor'
/** Nota seleccionada */
modelValue: NotaSeleccionada
/** Etiqueta del selector */
label?: string
/** Deshabilitar el selector */
disabled?: boolean
/** Marcar como requerido */
required?: boolean
}
const props = withDefaults(defineProps<SelectorFamiliaProps>(), {
disabled: false,
required: false,
})
const emit = defineEmits<{
'update:modelValue': [value: NotaSeleccionada]
}>()
// Estado local para la nota específica
const notaEspecificaLocal = ref(props.modelValue.notaEspecifica || '')
// Placeholder para el input de nota específica
const notaPlaceholder = computed(() => {
return props.tipo === 'fragancia-aroma'
? 'Ej: Naranja, Jazmín, Caramelo...'
: 'Ej: Fresa, Chocolate, Canela...'
})
// Mapeo de iconos para cada categoría
const categoriaIconos: Record<CategoriaNotaPrincipal, string> = {
Floral: 'i-lucide-flower-2',
Afrutado: 'i-lucide-apple',
'Ácido/Fermentado': 'i-lucide-flask-conical',
'Verde Vegetal': 'i-lucide-leaf',
Otro: 'i-lucide-circle-help',
Tostado: 'i-lucide-flame',
'Nueces/Cacao': 'i-lucide-shell',
Especias: 'i-lucide-sparkles',
Dulce: 'i-lucide-candy',
}
// Mapeo de colores para cada categoría
const categoriaColores: Record<CategoriaNotaPrincipal, string> = {
Floral: '#E91E63', // Rosa/Magenta
Afrutado: '#FF5722', // Rojo-Naranja
'Ácido/Fermentado': '#CDDC39', // Lima/Amarillo verdoso
'Verde Vegetal': '#4CAF50', // Verde
Otro: '#9E9E9E', // Gris
Tostado: '#FF6F00', // Naranja oscuro/Ámbar
'Nueces/Cacao': '#795548', // Marrón
Especias: '#FFC107', // Ámbar/Dorado
Dulce: '#EC407A', // Rosa/Fucsia
}
// Categorías principales disponibles
const categoriasDisponibles = computed<CategoriaNotaPrincipal[]>(() => {
return Object.keys(FAMILIAS_NOTAS_ESTRUCTURA) as CategoriaNotaPrincipal[]
})
// Subcategorías disponibles (unión de todas las categorías seleccionadas)
const subcategoriasDisponibles = computed<string[]>(() => {
if (props.modelValue.categorias.length === 0) return []
const subcategoriasSet = new Set<string>()
// Iterar sobre cada categoría seleccionada y agregar sus subcategorías
props.modelValue.categorias.forEach((categoria) => {
const familia = FAMILIAS_NOTAS_ESTRUCTURA[categoria as CategoriaNotaPrincipal]
if (familia && typeof familia === 'object') {
Object.keys(familia).forEach((subcategoria) => {
subcategoriasSet.add(subcategoria)
})
}
})
return Array.from(subcategoriasSet).sort()
})
// Toggle categoría (agregar o quitar)
const toggleCategoria = (categoria: CategoriaNotaPrincipal) => {
if (props.disabled) return
const categorias = [...props.modelValue.categorias]
const index = categorias.indexOf(categoria)
if (index > -1) {
// Quitar categoría
categorias.splice(index, 1)
// Si ya no hay categorías, limpiar subcategorías también
if (categorias.length === 0) {
emit('update:modelValue', {
categorias: [],
subcategorias: [],
notaEspecifica: null,
})
notaEspecificaLocal.value = ''
return
}
} else {
// Agregar categoría
categorias.push(categoria)
}
// Filtrar subcategorías: mantener solo las que siguen siendo válidas
const subcategoriasValidas = new Set<string>()
categorias.forEach((cat) => {
const familia = FAMILIAS_NOTAS_ESTRUCTURA[cat as CategoriaNotaPrincipal]
if (familia && typeof familia === 'object') {
Object.keys(familia).forEach((sub) => subcategoriasValidas.add(sub))
}
})
const subcategoriasFiltradas = props.modelValue.subcategorias.filter((sub) =>
subcategoriasValidas.has(sub)
)
emit('update:modelValue', {
...props.modelValue,
categorias,
subcategorias: subcategoriasFiltradas,
})
}
// Toggle subcategoría (agregar o quitar)
const toggleSubcategoria = (subcategoria: string) => {
if (props.disabled) return
const subcategorias = [...props.modelValue.subcategorias]
const index = subcategorias.indexOf(subcategoria)
if (index > -1) {
// Quitar subcategoría
subcategorias.splice(index, 1)
} else {
// Agregar subcategoría
subcategorias.push(subcategoria)
}
emit('update:modelValue', {
...props.modelValue,
subcategorias,
})
}
// Actualizar nota específica
const actualizarNotaEspecifica = () => {
const nota = notaEspecificaLocal.value.trim()
emit('update:modelValue', {
...props.modelValue,
notaEspecifica: nota || null,
})
}
// Sincronizar nota específica cuando cambia el modelo
watch(() => props.modelValue.notaEspecifica, (newVal) => {
if (newVal !== notaEspecificaLocal.value) {
notaEspecificaLocal.value = newVal || ''
}
})
</script>
<style scoped>
.selector-familia {
width: 100%;
display: flex;
flex-direction: column;
gap: 1rem;
}
.nivel-container {
width: 100%;
}
.nivel-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
opacity: 0.75;
}
/* Grid de categorías */
.categorias-grid {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.categoria-item {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
min-height: 36px;
padding: 0.5rem 0.625rem;
}
.categoria-item:focus {
outline: 2px solid transparent;
outline-offset: 2px;
}
.categoria-item:focus-visible {
box-shadow: 0 0 0 2px var(--cata-primary);
}
.categoria-content {
display: flex;
align-items: center;
gap: 0.375rem;
flex: 1 1 0%;
}
.categoria-icon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.categoria-item.cata-checkbox-checked .categoria-icon {
opacity: 1;
filter: brightness(1.1) saturate(1.2);
}
.categoria-text {
font-size: 0.75rem;
font-weight: 500;
text-align: left;
}
.categoria-check {
width: 1rem;
height: 1rem;
color: var(--cata-primary);
flex-shrink: 0;
margin-left: 0.375rem;
}
/* Grid de subcategorías */
.subcategorias-grid {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.subcategoria-item {
position: relative;
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
min-height: 32px;
}
.subcategoria-item:focus {
outline: 2px solid transparent;
outline-offset: 2px;
}
.subcategoria-item:focus-visible {
box-shadow: 0 0 0 2px var(--cata-primary);
}
.subcategoria-text {
font-size: 0.75rem;
}
.subcategoria-check {
width: 0.875rem;
height: 0.875rem;
color: var(--cata-primary);
}
/* Animaciones */
.categoria-item.cata-checkbox-checked,
.subcategoria-item.cata-checkbox-checked {
transform: scale(1.02);
}
.categoria-item:not(.disabled):hover,
.subcategoria-item:not(.disabled):hover {
transform: scale(1.02);
}
.categoria-item:not(.disabled):active,
.subcategoria-item:not(.disabled):active {
transform: scale(0.98);
}
/* Responsive */
@media (max-width: 640px) {
.categoria-item {
min-height: 32px;
padding: 0.375rem 0.5rem;
}
.categoria-content {
gap: 0.25rem;
}
.categoria-icon {
width: 0.875rem;
height: 0.875rem;
}
.categoria-text {
font-size: 0.6875rem;
}
.categoria-check {
width: 0.875rem;
height: 0.875rem;
margin-left: 0.25rem;
}
.subcategoria-item {
min-height: 28px;
padding: 0.25rem 0.375rem;
}
.subcategoria-text {
font-size: 0.6875rem;
}
.subcategoria-check {
width: 0.75rem;
height: 0.75rem;
}
}
@media (min-width: 768px) {
.categoria-item {
min-height: 40px;
}
}
/* Touch-friendly */
@media (hover: none) and (pointer: coarse) {
.categoria-item {
min-height: 40px;
}
.subcategoria-item {
min-height: 36px;
}
}
</style>