feat(ui): implementar selector de vista universal (tabla/grilla vs tarjetas/mosaico) con persistencia

This commit is contained in:
Carlos Tello
2026-09-05 01:54:55 -03:00
parent 69b8b7ccef
commit 7d342bf8cc
9 changed files with 637 additions and 141 deletions
+32
View File
@@ -174,4 +174,36 @@
background-color: #FFFFFF !important;
color: #000000 !important;
}
}
/* View Switcher Controls */
.view-switcher-group {
flex-direction: row !important;
display: inline-flex !important;
}
.view-switcher-group .btn {
margin-bottom: 0 !important;
font-weight: 600;
font-size: 0.82rem;
padding: 0.35rem 0.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease-in-out;
}
.view-switcher-group .btn.active {
box-shadow: 0 2px 6px rgba(var(--brand-primary-rgb), 0.25);
}
.reservation-card {
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
border-radius: 0.75rem;
}
.reservation-card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.08) !important;
border-color: var(--bs-primary) !important;
}
+139
View File
@@ -0,0 +1,139 @@
/**
* ViewSwitcher — Gestor Universal y Extensible de Modos de Visualización
*
* Permite alternar instantáneamente entre vista de Tabla (grilla densa)
* y vista de Tarjetas (mosaico responsive), preservando filtros, búsquedas
* y persistiendo la preferencia del usuario en localStorage.
*/
(function () {
'use strict';
class ViewSwitcherManager {
constructor() {
this.init();
}
init() {
document.addEventListener('DOMContentLoaded', () => {
this.setupViewSwitchers();
});
}
setupViewSwitchers() {
const switchers = document.querySelectorAll('.view-switcher-group');
if (!switchers.length) return;
switchers.forEach(group => {
const context = group.getAttribute('data-context') || 'default';
const defaultView = group.getAttribute('data-default-view') || 'cards';
// Determinar vista inicial:
// 1. URL search param (?display=...)
// 2. localStorage
// 3. defaultView del contenedor
const urlParams = new URLSearchParams(window.location.search);
const urlView = urlParams.get('display');
const storedView = localStorage.getItem(`view_preference_${context}`);
const activeView = urlView || storedView || defaultView;
this.applyView(context, activeView, false);
// Asignar listeners a los botones del selector
const buttons = group.querySelectorAll('.view-switcher-btn');
buttons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const targetView = btn.getAttribute('data-view');
if (targetView) {
this.applyView(context, targetView, true);
}
});
});
});
}
/**
* Aplica la vista seleccionada en el contexto dado.
* @param {string} context - Identificador del módulo (ej: 'classrooms', 'reservations')
* @param {string} viewName - Nombre de la vista ('table', 'cards', etc.)
* @param {boolean} persist - Si debe persistirse en localStorage y actualizar URL
*/
applyView(context, viewName, persist = true) {
// 1. Actualizar botones activos en el grupo selector
const group = document.querySelector(`.view-switcher-group[data-context="${context}"]`);
if (group) {
const buttons = group.querySelectorAll('.view-switcher-btn');
buttons.forEach(btn => {
const btnView = btn.getAttribute('data-view');
if (btnView === viewName) {
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-primary', 'active');
btn.setAttribute('aria-pressed', 'true');
} else {
btn.classList.remove('btn-primary', 'active');
btn.classList.add('btn-outline-secondary');
btn.setAttribute('aria-pressed', 'false');
}
});
}
// 2. Conmutar visibilidad de los contenedores de vista
const containers = document.querySelectorAll(`[data-view-context="${context}"]`);
let matched = false;
containers.forEach(container => {
const containerView = container.getAttribute('data-view-container');
if (containerView === viewName) {
container.classList.remove('d-none');
container.removeAttribute('aria-hidden');
matched = true;
} else {
container.classList.add('d-none');
container.setAttribute('aria-hidden', 'true');
}
});
// Fallback: si la vista solicitada no tiene contenedor, restaurar a la primera visible
if (!matched && containers.length > 0) {
containers[0].classList.remove('d-none');
viewName = containers[0].getAttribute('data-view-container') || 'cards';
}
// 3. Persistir preferencia y actualizar formularios y URL
if (persist) {
try {
localStorage.setItem(`view_preference_${context}`, viewName);
} catch (e) {
console.warn('localStorage no disponible para guardar preferencia de vista:', e);
}
// Actualizar parámetro display en la URL sin recargar
const url = new URL(window.location.href);
url.searchParams.set('display', viewName);
window.history.replaceState({}, '', url);
}
// 4. Sincronizar campo hidden 'display' en formularios de búsqueda/filtro
const forms = document.querySelectorAll('form');
forms.forEach(form => {
let hiddenInput = form.querySelector('input[name="display"]');
if (!hiddenInput) {
hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'display';
form.appendChild(hiddenInput);
}
hiddenInput.value = viewName;
});
// 5. Emitir evento personalizado para componentes dinámicos
window.dispatchEvent(new CustomEvent('viewModeChanged', {
detail: { context, view: viewName }
}));
}
}
// Instanciar singleton
window.ViewSwitcher = new ViewSwitcherManager();
})();