feat: sidebar con categorías colapsables (acordeón)
- Cada sección del menú se puede abrir/cerrar con clic - Estado persiste en localStorage por categoría - La sección con el link activo siempre se auto-expande - Chevron rota al colapsar con transición suave - CSS en styles.css actualizado para que sidebar-section sea clickeable Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
935cea24b6
commit
97a3dba249
+12
-12
@@ -163,27 +163,27 @@ body {
|
||||
/* ── Etiquetas de categoría ───────────────────────────── */
|
||||
.sidebar-menu li.sidebar-section {
|
||||
border-bottom: none;
|
||||
padding: 14px 18px 3px;
|
||||
padding: 10px 16px 6px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: rgba(255, 255, 255, 0.40);
|
||||
pointer-events: none;
|
||||
letter-spacing: 0.12em;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border-radius: 6px;
|
||||
margin: 4px 8px 0;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
|
||||
.sidebar-menu li.sidebar-section::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
margin-left: 4px;
|
||||
}
|
||||
.sidebar-menu li.sidebar-section:hover { color: rgba(255,255,255,.9); background: rgba(255,255,255,.07); }
|
||||
.sidebar-menu li.sidebar-section .sb-cat-label { display:flex; align-items:center; gap:4px; }
|
||||
.sidebar-menu li.sidebar-section::after { display: none; }
|
||||
|
||||
/* ── Badge dentro de un item ──────────────────────────── */
|
||||
.sidebar-menu .nav-link .sidebar-badge {
|
||||
|
||||
+147
-26
@@ -107,7 +107,7 @@ $_base = defined('APP_URL') ? rtrim(APP_URL, '/') : '';
|
||||
<?= htmlspecialchars($_sidebar_title, ENT_QUOTES, 'UTF-8') ?></h4>
|
||||
</div>
|
||||
|
||||
<ul class="sidebar-menu">
|
||||
<ul class="sidebar-menu" id="sb-menu">
|
||||
|
||||
<?php
|
||||
// ── Renderizado dinámico desde ModuleRegistry ───────────────────────────────
|
||||
@@ -116,57 +116,82 @@ if (class_exists('ModuleRegistry', false)) {
|
||||
|
||||
foreach ($sidebarGroups as $catKey => $catData):
|
||||
if (empty($catData['modules'])) continue;
|
||||
?>
|
||||
<!-- ══ <?= htmlspecialchars($catData['label'], ENT_QUOTES, 'UTF-8') ?> ══ -->
|
||||
<li class="sidebar-section">
|
||||
<i class="<?= htmlspecialchars($catData['icon'], ENT_QUOTES, 'UTF-8') ?> me-1"></i>
|
||||
<?= htmlspecialchars($catData['label'], ENT_QUOTES, 'UTF-8') ?>
|
||||
</li>
|
||||
|
||||
// Detectar si algún link de esta categoría está activo (para auto-expandir)
|
||||
$_catHasActive = false;
|
||||
foreach ($catData['modules'] as $__mod) {
|
||||
$__links = $__mod['links'] ?? [];
|
||||
if (!empty($__links)) {
|
||||
foreach ($__links as $__lnk) {
|
||||
if ($_isActive($__lnk['route']) !== '') { $_catHasActive = true; break 2; }
|
||||
}
|
||||
} else {
|
||||
$__route = $__mod['route'] ?? '';
|
||||
if ($__route && $_isActive($__route) !== '') { $_catHasActive = true; break; }
|
||||
}
|
||||
}
|
||||
$catId = 'sb-cat-' . htmlspecialchars($catKey, ENT_QUOTES, 'UTF-8');
|
||||
?>
|
||||
<li class="sidebar-section<?= $_catHasActive ? ' has-active' : '' ?>"
|
||||
data-cat="<?= $catId ?>"
|
||||
onclick="sbToggleCat('<?= $catId ?>', this)">
|
||||
<span class="sb-cat-label">
|
||||
<i class="<?= htmlspecialchars($catData['icon'], ENT_QUOTES, 'UTF-8') ?> me-1"></i>
|
||||
<?= htmlspecialchars($catData['label'], ENT_QUOTES, 'UTF-8') ?>
|
||||
</span>
|
||||
<i class="fas fa-chevron-down sb-chevron"></i>
|
||||
</li>
|
||||
<li class="sb-group" id="<?= $catId ?>">
|
||||
<ul>
|
||||
<?php foreach ($catData['modules'] as $slug => $mod):
|
||||
$links = $mod['links'] ?? [];
|
||||
// Si el módulo declara links propios, mostrarlos todos
|
||||
if (!empty($links)):
|
||||
foreach ($links as $lnk):
|
||||
$href = $_base . $lnk['route'];
|
||||
$active = $_isActive($lnk['route']);
|
||||
?>
|
||||
<li><a href="<?= htmlspecialchars($href, ENT_QUOTES, 'UTF-8') ?>"
|
||||
class="nav-link<?= $active ?>">
|
||||
<i class="<?= htmlspecialchars($lnk['icon'], ENT_QUOTES, 'UTF-8') ?>"></i>
|
||||
<?= htmlspecialchars($lnk['name'], ENT_QUOTES, 'UTF-8') ?></a></li>
|
||||
<li><a href="<?= htmlspecialchars($href, ENT_QUOTES, 'UTF-8') ?>"
|
||||
class="nav-link<?= $active ?>">
|
||||
<i class="<?= htmlspecialchars($lnk['icon'], ENT_QUOTES, 'UTF-8') ?>"></i>
|
||||
<?= htmlspecialchars($lnk['name'], ENT_QUOTES, 'UTF-8') ?></a></li>
|
||||
<?php endforeach;
|
||||
else:
|
||||
// Solo la ruta principal del módulo
|
||||
$route = $mod['route'] ?? '';
|
||||
$href = $route ? $_base . $route : '#';
|
||||
$active = $route ? $_isActive($route) : '';
|
||||
?>
|
||||
<li><a href="<?= htmlspecialchars($href, ENT_QUOTES, 'UTF-8') ?>"
|
||||
class="nav-link<?= $active ?>">
|
||||
<i class="<?= htmlspecialchars($mod['icon'], ENT_QUOTES, 'UTF-8') ?>"></i>
|
||||
<?= htmlspecialchars($mod['name'], ENT_QUOTES, 'UTF-8') ?></a></li>
|
||||
<li><a href="<?= htmlspecialchars($href, ENT_QUOTES, 'UTF-8') ?>"
|
||||
class="nav-link<?= $active ?>">
|
||||
<i class="<?= htmlspecialchars($mod['icon'], ENT_QUOTES, 'UTF-8') ?>"></i>
|
||||
<?= htmlspecialchars($mod['name'], ENT_QUOTES, 'UTF-8') ?></a></li>
|
||||
<?php endif;
|
||||
endforeach;
|
||||
endforeach;
|
||||
endforeach; ?>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<?php endforeach;
|
||||
|
||||
} else {
|
||||
// ── Fallback estático (cuando ModuleRegistry no está disponible) ──────────
|
||||
// Solo muestra los módulos básicos de laboratorio sin lógica dinámica.
|
||||
// Fallback estático
|
||||
?>
|
||||
<li class="sidebar-section"><i class="fas fa-house-medical me-1"></i> Domicilios</li>
|
||||
<li class="sidebar-section has-active" data-cat="sb-cat-lab" onclick="sbToggleCat('sb-cat-lab',this)">
|
||||
<span class="sb-cat-label"><i class="fas fa-house-medical me-1"></i> Laboratorio</span>
|
||||
<i class="fas fa-chevron-down sb-chevron"></i>
|
||||
</li>
|
||||
<li class="sb-group" id="sb-cat-lab"><ul>
|
||||
<?php if (function_exists('hasModule') && hasModule('lab_dashboard')): ?>
|
||||
<li><a href="lab_dashboard.php" class="nav-link"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
|
||||
<li><a href="lab_dashboard.php" class="nav-link"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
|
||||
<?php endif; ?>
|
||||
<?php if (function_exists('hasModule') && hasModule('lab_domicilios')): ?>
|
||||
<li><a href="lab_domicilios.php" class="nav-link"><i class="fas fa-house-medical"></i> Domicilios</a></li>
|
||||
<li><a href="lab_domicilios.php" class="nav-link"><i class="fas fa-house-medical"></i> Domicilios</a></li>
|
||||
<?php endif; ?>
|
||||
<?php if (function_exists('hasModule') && hasModule('lab_pacientes')): ?>
|
||||
<li><a href="lab_pacientes.php" class="nav-link"><i class="fas fa-users"></i> Pacientes</a></li>
|
||||
<li><a href="lab_pacientes.php" class="nav-link"><i class="fas fa-users"></i> Pacientes</a></li>
|
||||
<?php endif; ?>
|
||||
</ul></li>
|
||||
<?php } ?>
|
||||
|
||||
<!-- ══ Pie del sidebar ══════════════════════════════════ -->
|
||||
<!-- Pie -->
|
||||
<li class="sidebar-divider"></li>
|
||||
<li><a href="<?= $_base ?>/logout.php" class="nav-link logout-link"
|
||||
onclick="return confirm('¿Cerrar sesión?')">
|
||||
@@ -184,3 +209,99 @@ if (class_exists('ModuleRegistry', false)) {
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
/* ── Acordeón de categorías ─────────────────────────── */
|
||||
.sidebar-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 8px 16px 6px;
|
||||
margin-top: 6px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
transition: background .15s;
|
||||
}
|
||||
.sidebar-section:hover { background: rgba(255,255,255,.08); }
|
||||
.sidebar-section .sb-cat-label { display:flex; align-items:center; gap:4px; }
|
||||
.sb-chevron {
|
||||
font-size: .65rem;
|
||||
opacity: .7;
|
||||
transition: transform .25s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-section.collapsed .sb-chevron { transform: rotate(-90deg); }
|
||||
|
||||
/* Grupo colapsable */
|
||||
.sb-group { overflow: hidden; transition: max-height .28s ease, opacity .2s ease; }
|
||||
.sb-group > ul { list-style: none; padding: 0 0 4px; margin: 0; }
|
||||
.sb-group.collapsed { max-height: 0 !important; opacity: 0; pointer-events: none; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var STORE_KEY = 'sb_collapsed_cats';
|
||||
|
||||
function getCollapsed() {
|
||||
try { return JSON.parse(localStorage.getItem(STORE_KEY) || '[]'); } catch(_) { return []; }
|
||||
}
|
||||
function setCollapsed(arr) {
|
||||
try { localStorage.setItem(STORE_KEY, JSON.stringify(arr)); } catch(_) {}
|
||||
}
|
||||
|
||||
function applyState(groupEl, sectionEl, collapsed, animate) {
|
||||
if (collapsed) {
|
||||
if (!animate) { groupEl.style.maxHeight = '0'; groupEl.style.opacity = '0'; }
|
||||
groupEl.classList.add('collapsed');
|
||||
sectionEl.classList.add('collapsed');
|
||||
} else {
|
||||
groupEl.classList.remove('collapsed');
|
||||
sectionEl.classList.remove('collapsed');
|
||||
groupEl.style.maxHeight = groupEl.scrollHeight + 'px';
|
||||
groupEl.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
|
||||
window.sbToggleCat = function(catId, sectionEl) {
|
||||
var group = document.getElementById(catId);
|
||||
if (!group) return;
|
||||
var isCollapsed = group.classList.contains('collapsed');
|
||||
var arr = getCollapsed();
|
||||
if (isCollapsed) {
|
||||
arr = arr.filter(function(id){ return id !== catId; });
|
||||
applyState(group, sectionEl, false, true);
|
||||
} else {
|
||||
if (arr.indexOf(catId) === -1) arr.push(catId);
|
||||
group.style.maxHeight = group.scrollHeight + 'px';
|
||||
group.style.opacity = '1';
|
||||
requestAnimationFrame(function(){
|
||||
group.classList.add('collapsed');
|
||||
sectionEl.classList.add('collapsed');
|
||||
group.style.maxHeight = '0';
|
||||
group.style.opacity = '0';
|
||||
});
|
||||
}
|
||||
setCollapsed(arr);
|
||||
};
|
||||
|
||||
// Inicializar al cargar
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var collapsed = getCollapsed();
|
||||
document.querySelectorAll('.sb-group').forEach(function(group) {
|
||||
var catId = group.id;
|
||||
var section = document.querySelector('[data-cat="' + catId + '"]');
|
||||
if (!section) return;
|
||||
|
||||
var hasActive = !!group.querySelector('.nav-link.active');
|
||||
var isCollapsed = !hasActive && collapsed.indexOf(catId) !== -1;
|
||||
|
||||
// Medir altura real antes de colapsar
|
||||
group.style.maxHeight = group.scrollHeight + 'px';
|
||||
group.style.opacity = '1';
|
||||
|
||||
applyState(group, section, isCollapsed, false);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user