feat(web): sidebar colapsable con tooltips + fix scroll horizontal

Cambios:
- Nuevo hook useSidebar() con persistencia en localStorage
  ('sidebar-collapsed' = '1'/'0').
- SidebarNav refactorizado:
  - Width controlled internamente (w-60 expanded, w-[68px] collapsed)
  - Toggle button al pie con PanelLeftClose/Open icon
  - Brand mark con gradient brand+violet (consistencia con login)
  - Active indicator: barra vertical sutil a la izquierda cuando expanded,
    bg accent cuando collapsed
  - SectionLabel se reemplaza por divider sutil cuando collapsed
- Custom SidebarTooltip puro CSS (sin radix dep nuevo) que aparece
  a la derecha del item con animacion fade + slide al hover. Funciona
  con group-hover/item y group-hover/toggle (Tailwind named groups).
- Items disabled muestran badge 'Próx.' chico (era 'Próximamente' largo)
  y en tooltip cuando collapsed: 'Label · Próximamente'.
- Fix scroll horizontal: overflow-x-hidden en nav, truncate en spans,
  shrink-0 en iconos y badges. Layout robusto a labels largos.
- ProtectedLayout deja de hardcodear lg:w-60 — sidebar controla su width.
- AppHeader Sheet (mobile) usa <SidebarNav forceExpanded /> para que
  en mobile siempre se vea expanded sin importar el state desktop.

Tests 136/136 verde.
This commit is contained in:
2026-04-16 11:21:42 -03:00
parent 41b6882b5c
commit 7b7ef1c137
4 changed files with 224 additions and 107 deletions

View File

@@ -57,7 +57,7 @@ export function AppHeader() {
<SheetHeader className="sr-only"> <SheetHeader className="sr-only">
<SheetTitle>Navegación</SheetTitle> <SheetTitle>Navegación</SheetTitle>
</SheetHeader> </SheetHeader>
<SidebarNav /> <SidebarNav forceExpanded />
</SheetContent> </SheetContent>
</Sheet> </Sheet>

View File

@@ -9,10 +9,14 @@ import {
Users, Users,
ShieldCheck, ShieldCheck,
KeyRound, KeyRound,
PanelLeftClose,
PanelLeftOpen,
Newspaper,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { useSidebar } from '@/hooks/useSidebar'
interface NavItem { interface NavItem {
label: string label: string
@@ -25,127 +29,216 @@ const navItems: NavItem[] = [
{ label: 'Dashboard', href: '/', icon: LayoutDashboard }, { label: 'Dashboard', href: '/', icon: LayoutDashboard },
{ label: 'Ventas', href: '/ventas', icon: ShoppingCart, disabled: true }, { label: 'Ventas', href: '/ventas', icon: ShoppingCart, disabled: true },
{ label: 'Tasación', href: '/tasacion', icon: Calculator, disabled: true }, { label: 'Tasación', href: '/tasacion', icon: Calculator, disabled: true },
{ { label: 'Integraciones', href: '/integraciones', icon: Zap, disabled: true },
label: 'Integraciones', { label: 'Administración', href: '/administracion', icon: Settings, disabled: true },
href: '/integraciones',
icon: Zap,
disabled: true,
},
{
label: 'Administración',
href: '/administracion',
icon: Settings,
disabled: true,
},
] ]
export function SidebarNav() { const adminItems: NavItem[] = [
{ label: 'Usuarios', href: '/usuarios', icon: Users },
{ label: 'Crear Usuario', href: '/usuarios/nuevo', icon: UserPlus },
{ label: 'Roles', href: '/admin/roles', icon: ShieldCheck },
{ label: 'Permisos', href: '/admin/permisos', icon: KeyRound },
]
interface SidebarNavProps {
/** When true forces expanded layout regardless of persisted state — used by mobile Sheet. */
forceExpanded?: boolean
}
export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
const { pathname } = useLocation() const { pathname } = useLocation()
const user = useAuthStore((s) => s.user) const user = useAuthStore((s) => s.user)
const isAdmin = user?.rol === 'admin' const isAdmin = user?.rol === 'admin'
const { collapsed: persisted, toggle } = useSidebar()
const collapsed = forceExpanded ? false : persisted
function isItemActive(item: NavItem): boolean {
if (item.disabled) return false
if (item.href === '/') return pathname === '/'
if (item.href === '/usuarios') {
return pathname.startsWith('/usuarios') && pathname !== '/usuarios/nuevo'
}
if (item.href === '/usuarios/nuevo') return pathname === '/usuarios/nuevo'
return pathname.startsWith(item.href)
}
return ( return (
<aside className="flex h-full flex-col bg-background border-r border-border"> <aside
className={cn(
'flex h-full flex-col bg-card text-card-foreground overflow-hidden transition-[width] duration-200 ease-out',
collapsed ? 'w-[68px]' : 'w-60',
)}
data-collapsed={collapsed}
>
{/* Brand */} {/* Brand */}
<div className="flex h-14 items-center px-4 border-b border-border"> <div className="flex h-14 items-center border-b border-border px-3 shrink-0">
<span className="text-base font-semibold tracking-tight text-foreground"> <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-brand-500 to-violet-500 shadow-md shadow-brand-500/20 shrink-0">
SIG-CM 2.0 <Newspaper className="h-4 w-4 text-white" strokeWidth={2.25} />
</span> </div>
{!collapsed && (
<span className="ml-3 text-sm font-semibold tracking-tight text-foreground truncate">
SIG-CM 2.0
</span>
)}
</div> </div>
{/* Nav */} {/* Nav */}
<nav className="flex-1 overflow-y-auto py-4 px-2 space-y-1"> <nav className="flex-1 overflow-y-auto overflow-x-hidden py-3 px-2 space-y-1">
{navItems.map((item) => { {navItems.map((item) => (
const Icon = item.icon <NavRow
const isActive = pathname === item.href && !item.disabled key={item.href}
item={item}
collapsed={collapsed}
active={isItemActive(item)}
/>
))}
return item.disabled ? (
<div
key={item.href}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground cursor-not-allowed opacity-60"
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1">{item.label}</span>
<Badge variant="secondary" className="text-xs">
Próximamente
</Badge>
</div>
) : (
<Link
key={item.href}
to={item.href}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
isActive
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground',
)}
>
<Icon className="h-4 w-4 shrink-0" />
<span>{item.label}</span>
</Link>
)
})}
{/* Admin-only section */}
{isAdmin && ( {isAdmin && (
<> <>
<div className="pt-2 pb-1 px-3"> <SectionLabel collapsed={collapsed}>Administración</SectionLabel>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60"> {adminItems.map((item) => (
Administración <NavRow
</span> key={item.href}
</div> item={item}
<Link collapsed={collapsed}
to="/usuarios" active={isItemActive(item)}
className={cn( />
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground', ))}
pathname.startsWith('/usuarios') && pathname !== '/usuarios/nuevo'
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground',
)}
>
<Users className="h-4 w-4 shrink-0" />
<span>Usuarios</span>
</Link>
<Link
to="/usuarios/nuevo"
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
pathname === '/usuarios/nuevo'
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground',
)}
>
<UserPlus className="h-4 w-4 shrink-0" />
<span>Crear Usuario</span>
</Link>
<Link
to="/admin/roles"
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
pathname.startsWith('/admin/roles')
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground',
)}
>
<ShieldCheck className="h-4 w-4 shrink-0" />
<span>Roles</span>
</Link>
<Link
to="/admin/permisos"
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground',
pathname.startsWith('/admin/permisos')
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground',
)}
>
<KeyRound className="h-4 w-4 shrink-0" />
<span>Permisos</span>
</Link>
</> </>
)} )}
</nav> </nav>
{/* Collapse toggle — visible only on desktop (no en Sheet) */}
{!forceExpanded && (
<div className="border-t border-border p-2 shrink-0">
<button
type="button"
onClick={toggle}
aria-label={collapsed ? 'Expandir sidebar' : 'Colapsar sidebar'}
className={cn(
'group/toggle relative w-full flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
collapsed && 'justify-center',
)}
>
{collapsed ? (
<PanelLeftOpen className="h-4 w-4 shrink-0" />
) : (
<PanelLeftClose className="h-4 w-4 shrink-0" />
)}
{!collapsed && <span>Colapsar</span>}
{collapsed && (
<SidebarTooltip>Expandir</SidebarTooltip>
)}
</button>
</div>
)}
</aside> </aside>
) )
} }
/* ────────────────────────────────────────────────────────────────
Sub-componentes
──────────────────────────────────────────────────────────────── */
interface NavRowProps {
item: NavItem
collapsed: boolean
active: boolean
}
function NavRow({ item, collapsed, active }: NavRowProps) {
const Icon = item.icon
const baseClasses = cn(
'group/item relative flex items-center rounded-md text-sm transition-colors',
collapsed ? 'justify-center h-10 px-0' : 'gap-3 px-3 py-2',
)
if (item.disabled) {
return (
<div
className={cn(
baseClasses,
'text-muted-foreground/70 cursor-not-allowed opacity-60',
)}
aria-disabled="true"
>
<Icon className="h-4 w-4 shrink-0" />
{!collapsed && (
<>
<span className="flex-1 truncate">{item.label}</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 shrink-0">
Próx.
</Badge>
</>
)}
{collapsed && <SidebarTooltip>{item.label} · Próximamente</SidebarTooltip>}
</div>
)
}
return (
<Link
to={item.href}
className={cn(
baseClasses,
active
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
aria-current={active ? 'page' : undefined}
>
{/* Active indicator bar (left edge) — sutil cuando expanded, dot cuando collapsed */}
{active && !collapsed && (
<span className="absolute left-0 top-1/2 -translate-y-1/2 h-5 w-0.5 rounded-r-full bg-primary" />
)}
<Icon className="h-4 w-4 shrink-0" />
{!collapsed && <span className="truncate">{item.label}</span>}
{collapsed && <SidebarTooltip>{item.label}</SidebarTooltip>}
</Link>
)
}
function SectionLabel({
collapsed,
children,
}: {
collapsed: boolean
children: React.ReactNode
}) {
if (collapsed) {
return (
<div className="my-2 mx-2 border-t border-border" aria-hidden="true" />
)
}
return (
<div className="pt-3 pb-1 px-3">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
{children}
</span>
</div>
)
}
/**
* Custom tooltip que aparece a la derecha del item cuando el sidebar está collapsed.
* Sin radix dep — pure CSS hover + group selector. Se posiciona absolute fuera del sidebar.
*/
function SidebarTooltip({ children }: { children: React.ReactNode }) {
return (
<span
role="tooltip"
className={cn(
'pointer-events-none absolute left-full ml-3 top-1/2 -translate-y-1/2 z-50',
'whitespace-nowrap rounded-md border border-border bg-popover px-2.5 py-1.5',
'text-xs font-medium text-popover-foreground shadow-lg',
'opacity-0 -translate-x-1 transition-all duration-150',
'group-hover/item:opacity-100 group-hover/item:translate-x-0',
'group-hover/toggle:opacity-100 group-hover/toggle:translate-x-0',
)}
>
{children}
</span>
)
}

View File

@@ -0,0 +1,24 @@
import { useEffect, useState, useCallback } from 'react'
const STORAGE_KEY = 'sidebar-collapsed'
function readInitial(): boolean {
if (typeof window === 'undefined') return false
return window.localStorage.getItem(STORAGE_KEY) === '1'
}
/**
* Manages desktop sidebar collapsed state with localStorage persistence.
* Mobile sidebar (Sheet) is independent — this hook only affects desktop ≥ lg.
*/
export function useSidebar() {
const [collapsed, setCollapsed] = useState<boolean>(readInitial)
useEffect(() => {
window.localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0')
}, [collapsed])
const toggle = useCallback(() => setCollapsed((prev) => !prev), [])
return { collapsed, setCollapsed, toggle }
}

View File

@@ -16,8 +16,8 @@ export function ProtectedLayout({ children }: ProtectedLayoutProps) {
<div className="absolute top-[-15%] right-[-8%] w-[500px] h-[500px] rounded-full bg-brand-500/10 dark:bg-brand-500/12 blur-[140px] pointer-events-none" /> <div className="absolute top-[-15%] right-[-8%] w-[500px] h-[500px] rounded-full bg-brand-500/10 dark:bg-brand-500/12 blur-[140px] pointer-events-none" />
<div className="absolute bottom-[-15%] left-[20%] w-[500px] h-[500px] rounded-full bg-violet-500/8 dark:bg-violet-500/10 blur-[140px] pointer-events-none" /> <div className="absolute bottom-[-15%] left-[20%] w-[500px] h-[500px] rounded-full bg-violet-500/8 dark:bg-violet-500/10 blur-[140px] pointer-events-none" />
{/* Desktop sidebar */} {/* Desktop sidebar — width controlled by SidebarNav itself (collapsed/expanded) */}
<div className="relative z-10 hidden lg:flex lg:w-60 lg:flex-col lg:shrink-0 border-r border-border"> <div className="relative z-10 hidden lg:flex lg:flex-col lg:shrink-0 border-r border-border">
<SidebarNav /> <SidebarNav />
</div> </div>