feat(frontend): sidebar colapsable por secciones + fly-out en modo colapsado #62

Merged
dmolinari merged 1 commits from chore/sidebar-collapsible-sections into main 2026-04-21 17:09:07 +00:00
7 changed files with 438 additions and 37 deletions
Showing only changes of commit bcb0c94fc5 - Show all commits

View File

@@ -16,6 +16,7 @@
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
@@ -2264,6 +2265,93 @@
}
}
},
"node_modules/@radix-ui/react-hover-card": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz",
"integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",

View File

@@ -21,6 +21,7 @@
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",

View File

@@ -15,11 +15,16 @@ import {
Layers,
Package,
Hash,
Building2,
Calculator,
ChevronDown,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { useAuthStore } from '@/stores/authStore'
import { useSidebar } from '@/hooks/useSidebar'
import { useSidebarSections } from '@/hooks/useSidebarSections'
interface NavItem {
label: string
@@ -31,6 +36,8 @@ interface NavItem {
interface NavSection {
label: string
/** Icon de grupo, usado en el modo colapsado como trigger del fly-out. */
icon: React.ElementType
/** Si true, la sección solo se muestra si el user tiene rol admin. */
adminOnly?: boolean
items: NavItem[]
@@ -43,12 +50,12 @@ const dashboardItem: NavItem = {
icon: LayoutDashboard,
}
// Secciones de navegación agrupadas por dominio.
// Orden: Seguridad → Maestros → Catálogo → Tasación (coincide con la arquitectura del proyecto).
// Secciones agrupadas por dominio (Seguridad → Maestros → Catálogo → Tasación).
// Cada sección se oculta si todos sus items están filtrados por permisos.
const navSections: NavSection[] = [
{
label: 'Seguridad',
icon: ShieldCheck,
adminOnly: true,
items: [
{ label: 'Usuarios', href: '/usuarios', icon: Users },
@@ -65,6 +72,7 @@ const navSections: NavSection[] = [
},
{
label: 'Maestros',
icon: Building2,
adminOnly: true,
items: [
{
@@ -89,6 +97,7 @@ const navSections: NavSection[] = [
},
{
label: 'Catálogo',
icon: Package,
adminOnly: true,
items: [
{
@@ -113,6 +122,7 @@ const navSections: NavSection[] = [
},
{
label: 'Tasación',
icon: Calculator,
adminOnly: true,
items: [
{
@@ -134,8 +144,9 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
const { pathname } = useLocation()
const user = useAuthStore((s) => s.user)
const isAdmin = user?.rol === 'admin'
const { collapsed: persisted, toggle } = useSidebar()
const { collapsed: persisted, toggle: toggleSidebar } = useSidebar()
const collapsed = forceExpanded ? false : persisted
const { isCollapsed: isSectionCollapsed, toggle: toggleSection } = useSidebarSections()
function isItemActive(item: NavItem): boolean {
if (item.href === '/') return pathname === '/'
@@ -150,8 +161,12 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
return !item.requiredPermission || (user?.permisos.includes(item.requiredPermission) ?? false)
}
// Computa las secciones visibles (con items filtrados por permiso).
// Si una sección queda sin items tras el filtro, se oculta el header.
/** Returns true if any item in the section matches the active route. */
function sectionContainsActive(section: NavSection): boolean {
return section.items.some(isItemActive)
}
// Filter sections + items by role + permissions. Empty sections are hidden.
const visibleSections = navSections
.filter((section) => !section.adminOnly || isAdmin)
.map((section) => ({
@@ -180,7 +195,7 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
<TooltipTrigger asChild>
<button
type="button"
onClick={toggle}
onClick={toggleSidebar}
aria-label={collapsed ? 'Expandir sidebar' : 'Colapsar sidebar'}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground shrink-0"
>
@@ -212,21 +227,32 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
active={isItemActive(dashboardItem)}
/>
{visibleSections.map((section) => (
<div key={section.label} className="pt-2">
<SectionLabel collapsed={collapsed}>{section.label}</SectionLabel>
<div className="space-y-1">
{section.items.map((item) => (
<NavRow
key={item.href}
item={item}
collapsed={collapsed}
active={isItemActive(item)}
{collapsed
? visibleSections.map((section) => (
<CollapsedSectionFlyout
key={section.label}
section={section}
isItemActive={isItemActive}
active={sectionContainsActive(section)}
/>
))
: visibleSections.map((section) => {
const containsActive = sectionContainsActive(section)
const userPrefCollapsed = isSectionCollapsed(section.label)
// Auto-expand override: if the active route lives here, keep it open
// regardless of persisted preference.
const expanded = containsActive ? true : !userPrefCollapsed
return (
<ExpandedSection
key={section.label}
section={section}
expanded={expanded}
onToggle={() => toggleSection(section.label)}
toggleDisabled={containsActive}
isItemActive={isItemActive}
/>
))}
</div>
</div>
))}
)
})}
</nav>
</aside>
)
@@ -278,21 +304,138 @@ function NavRow({ item, collapsed, active }: NavRowProps) {
)
}
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" />
}
/* ── Expanded mode — collapsible section with chevron toggle ──────── */
interface ExpandedSectionProps {
section: NavSection
expanded: boolean
onToggle: () => void
/** When true, the header is not clickable (because the section contains the active route and must stay open). */
toggleDisabled: boolean
isItemActive: (item: NavItem) => boolean
}
function ExpandedSection({
section,
expanded,
onToggle,
toggleDisabled,
isItemActive,
}: ExpandedSectionProps) {
return (
<div className="pt-1 pb-1 px-3">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
{children}
</span>
<div className="pt-2">
<button
type="button"
onClick={toggleDisabled ? undefined : onToggle}
aria-expanded={expanded}
disabled={toggleDisabled}
className={cn(
'w-full flex items-center justify-between pt-1 pb-1 px-3 rounded-md',
'text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60',
toggleDisabled
? 'cursor-default'
: 'hover:text-muted-foreground transition-colors cursor-pointer',
)}
>
<span>{section.label}</span>
{!toggleDisabled && (
<ChevronDown
className={cn(
'h-3 w-3 shrink-0 transition-transform duration-200',
expanded ? 'rotate-0' : '-rotate-90',
)}
/>
)}
</button>
{expanded && (
<div className="space-y-1 mt-1">
{section.items.map((item) => (
<NavRow
key={item.href}
item={item}
collapsed={false}
active={isItemActive(item)}
/>
))}
</div>
)}
</div>
)
}
/* ── Collapsed mode — group icon + fly-out hover panel ──────────── */
interface CollapsedSectionFlyoutProps {
section: NavSection
isItemActive: (item: NavItem) => boolean
/** When true, the group icon shows an active indicator (contains the active route). */
active: boolean
}
function CollapsedSectionFlyout({
section,
isItemActive,
active,
}: CollapsedSectionFlyoutProps) {
const GroupIcon = section.icon
return (
<HoverCard openDelay={120} closeDelay={80}>
<HoverCardTrigger asChild>
<button
type="button"
aria-label={section.label}
aria-haspopup="menu"
className={cn(
'relative flex items-center justify-center h-10 w-10 mx-auto rounded-md transition-colors',
active
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
>
{active && (
<span
aria-hidden="true"
className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-primary"
/>
)}
<GroupIcon className="h-4 w-4 shrink-0" />
</button>
</HoverCardTrigger>
<HoverCardContent
side="right"
align="start"
sideOffset={8}
className="w-56 p-2 z-[60]"
role="menu"
>
<div className="px-2 pt-1 pb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
{section.label}
</div>
<div className="space-y-0.5">
{section.items.map((item) => {
const Icon = item.icon
const isActive = isItemActive(item)
return (
<Link
key={item.href}
to={item.href}
role="menuitem"
className={cn(
'flex items-center gap-3 px-2 py-1.5 rounded-md text-sm transition-colors',
isActive
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
aria-current={isActive ? 'page' : undefined}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="truncate">{item.label}</span>
</Link>
)
})}
</div>
</HoverCardContent>
</HoverCard>
)
}

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { SidebarNav } from '../AppSidebar'
import { useAuthStore } from '@/stores/authStore'
@@ -42,6 +43,8 @@ function renderSidebar(initialPath = '/') {
describe('AppSidebar', () => {
beforeEach(() => {
setUser()
// Clean per-section collapse preferences between tests
window.localStorage.removeItem('sidebar-sections-collapsed')
})
it('Dashboard visible para todo usuario autenticado', () => {
@@ -67,8 +70,15 @@ describe('AppSidebar', () => {
expect(screen.getByText('Tasación')).toBeInTheDocument()
})
it('Cada item vive en la sección correcta', () => {
it('Cada item vive en la sección correcta (tras expandir todas las secciones)', async () => {
const user = userEvent.setup()
// Expandir manualmente las 4 secciones (arrancan colapsadas por default)
window.localStorage.setItem(
'sidebar-sections-collapsed',
JSON.stringify({ Seguridad: false, Maestros: false, Catálogo: false, Tasación: false }),
)
renderSidebar()
void user // keep setup import used
// Seguridad
expect(screen.getByRole('link', { name: /usuarios/i })).toBeInTheDocument()
@@ -128,6 +138,7 @@ describe('AppSidebar', () => {
})
it('Marca el item activo según la ruta actual (Caracteres Tasables)', () => {
// Ruta activa contenida en Tasación → auto-expand
renderSidebar('/admin/tasacion/chargeable-chars')
const link = screen.getByRole('link', { name: /caracteres tasables/i })
expect(link).toHaveAttribute('aria-current', 'page')
@@ -158,4 +169,70 @@ describe('AppSidebar', () => {
'Tasación',
])
})
// ── Collapse per-section (modo expandido) ──────────────────────────────
it('Default: todas las secciones arrancan colapsadas EXCEPTO la que contiene la ruta activa', () => {
renderSidebar('/admin/medios')
// Maestros contiene la ruta activa → expandida → Medios visible
expect(screen.getByRole('link', { name: /^medios$/i })).toBeInTheDocument()
// Otras secciones colapsadas → sus items NO visibles
expect(screen.queryByRole('link', { name: /^usuarios$/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /caracteres tasables/i })).not.toBeInTheDocument()
})
it('Header de sección activa NO es toggleable (sin chevron, disabled)', () => {
renderSidebar('/admin/medios')
const maestrosBtn = screen.getByRole('button', { name: /maestros/i })
expect(maestrosBtn).toBeDisabled()
expect(maestrosBtn).toHaveAttribute('aria-expanded', 'true')
})
it('Click en header de sección no-activa expande/colapsa sus items', async () => {
const user = userEvent.setup()
renderSidebar('/admin/medios')
// Catálogo arranca colapsado → Rubros NO visible
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
// Click en "Catálogo" → expande
await user.click(screen.getByRole('button', { name: /catálogo/i }))
expect(screen.getByRole('link', { name: /rubros/i })).toBeInTheDocument()
// Click de nuevo → colapsa
await user.click(screen.getByRole('button', { name: /catálogo/i }))
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
})
it('aria-expanded refleja estado expandido/colapsado', async () => {
const user = userEvent.setup()
renderSidebar('/admin/medios')
const catalogoBtn = screen.getByRole('button', { name: /catálogo/i })
expect(catalogoBtn).toHaveAttribute('aria-expanded', 'false')
await user.click(catalogoBtn)
expect(catalogoBtn).toHaveAttribute('aria-expanded', 'true')
})
it('Ruta en Dashboard (raíz): ninguna sección contiene active → todas colapsadas', () => {
renderSidebar('/')
expect(screen.queryByRole('link', { name: /^medios$/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /rubros/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /caracteres tasables/i })).not.toBeInTheDocument()
})
it('Preferencia de collapse persiste en localStorage', async () => {
const user = userEvent.setup()
renderSidebar('/')
// Expandir Catálogo → guarda preferencia
await user.click(screen.getByRole('button', { name: /catálogo/i }))
const stored = window.localStorage.getItem('sidebar-sections-collapsed')
expect(stored).toBeTruthy()
const parsed = JSON.parse(stored!)
expect(parsed['Catálogo']).toBe(false) // false = expandido
})
})

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,61 @@
import { useCallback, useEffect, useState } from 'react'
const STORAGE_KEY = 'sidebar-sections-collapsed'
/**
* Map: sectionName → isCollapsed.
* Missing key = expanded (default).
*/
export type SectionState = Record<string, boolean>
function readInitial(): SectionState {
if (typeof window === 'undefined') return {}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as SectionState
}
return {}
} catch {
return {}
}
}
/**
* Manages per-section collapse state in the sidebar with localStorage persistence.
*
* - Initially, ALL sections start collapsed EXCEPT the one containing the active route.
* That behavior is enforced at render-time by the caller via `overrideExpanded`
* (the section currently active is ALWAYS shown expanded regardless of stored pref).
* - `toggle(name)` flips the stored preference for a specific section. The override
* for the active section remains unaffected — it will stay expanded while active.
* - `isCollapsed(name)` returns the stored preference (does NOT apply the active-route
* override — that's the caller's job).
*/
export function useSidebarSections(defaultCollapsed = true) {
const [state, setState] = useState<SectionState>(readInitial)
useEffect(() => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}, [state])
const isCollapsed = useCallback(
(name: string): boolean => {
// Missing key → fall back to default (collapsed = true by design).
if (!(name in state)) return defaultCollapsed
return state[name]
},
[state, defaultCollapsed],
)
const toggle = useCallback((name: string) => {
setState((prev) => {
const currentlyCollapsed = name in prev ? prev[name] : defaultCollapsed
return { ...prev, [name]: !currentlyCollapsed }
})
}, [defaultCollapsed])
return { isCollapsed, toggle }
}

View File

@@ -16,8 +16,10 @@ 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 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 — width controlled by SidebarNav itself (collapsed/expanded) */}
<div className="relative z-10 hidden lg:flex lg:flex-col lg:shrink-0 border-r border-border">
{/* Desktop sidebar — width controlled by SidebarNav itself (collapsed/expanded).
z-30 keeps the sidebar above page content; fly-out HoverCard uses z-[60] to
guarantee it sits above any app-level overlay (except modal dialogs). */}
<div className="relative z-30 hidden lg:flex lg:flex-col lg:shrink-0 border-r border-border">
<SidebarNav />
</div>