From bcb0c94fc5858877b900499a1eb1d88ee149af0e Mon Sep 17 00:00:00 2001 From: dmolinari Date: Tue, 21 Apr 2026 14:07:12 -0300 Subject: [PATCH] feat(frontend): sidebar secciones colapsables + fly-out en modo colapsado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mejora UX post-refactor (PR #61): las 4 secciones del sidebar expandido son ahora colapsables individualmente, y el modo colapsado reemplaza la lista larga de iconos por un icono por grupo con fly-out panel on hover. Expandido (240px): - Click en header de sección (Seguridad/Maestros/Catálogo/Tasación) toggle collapse con chevron que rota. - Default: todas colapsadas EXCEPTO la que contiene la ruta activa (auto-expand override). - Sección activa tiene el header disabled + sin chevron (no se puede colapsar mientras estás ahí — evita esconder items de la ruta actual). - Preferencia per-sección persistida en localStorage. Colapsado (68px): - Un icono por grupo en lugar de listar TODOS los items (evitando scroll largo en usuarios con muchos permisos). - Hover sobre el grupo despliega un fly-out panel al lado derecho con el título del grupo + sus items clickeables. - Grupo que contiene la ruta activa tiene un dot indicator. - Icons de grupo: ShieldCheck (Seguridad), Building2 (Maestros), Package (Catálogo), Calculator (Tasación). Accessibility: - Headers expandidos: aria-expanded refleja estado. - Fly-out: aria-haspopup='menu' + role='menu' + keyboard focus. z-index management (pedido explícito del user): - aside wrapper en ProtectedLayout: z-10 -> z-30 (sobre contenido). - HoverCardContent del fly-out: z-[60] (sobre cualquier overlay app-level, excepto modal dialogs que siguen siendo z-50 por convención Radix). - hover-card.tsx: envuelto en HoverCardPrimitive.Portal (faltaba en el shadcn generated) — previene que el fly-out quede cortado por overflow del aside. Dependencies: - shadcn hover-card agregado via 'npx shadcn@latest add' (+ @radix-ui/react-hover-card). Tests: - 16 tests (antes 10) — agregados 6 casos: default collapsed except active, click toggle expand/collapse, aria-expanded reflection, disabled header when active, root route collapses all, localStorage persistence. --- src/web/package-lock.json | 88 ++++++++ src/web/package.json | 1 + src/web/src/components/layout/AppSidebar.tsx | 211 +++++++++++++++--- .../layout/__tests__/AppSidebar.test.tsx | 79 ++++++- src/web/src/components/ui/hover-card.tsx | 29 +++ src/web/src/hooks/useSidebarSections.ts | 61 +++++ src/web/src/layouts/ProtectedLayout.tsx | 6 +- 7 files changed, 438 insertions(+), 37 deletions(-) create mode 100644 src/web/src/components/ui/hover-card.tsx create mode 100644 src/web/src/hooks/useSidebarSections.ts diff --git a/src/web/package-lock.json b/src/web/package-lock.json index 20d8a17..1d539cf 100644 --- a/src/web/package-lock.json +++ b/src/web/package-lock.json @@ -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", diff --git a/src/web/package.json b/src/web/package.json index 10ebb73..0700165 100644 --- a/src/web/package.json +++ b/src/web/package.json @@ -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", diff --git a/src/web/src/components/layout/AppSidebar.tsx b/src/web/src/components/layout/AppSidebar.tsx index 1f61acd..ec8f977 100644 --- a/src/web/src/components/layout/AppSidebar.tsx +++ b/src/web/src/components/layout/AppSidebar.tsx @@ -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) { + {expanded && ( +
+ {section.items.map((item) => ( + + ))} +
+ )} ) } + +/* ── 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 ( + + + + + +
+ {section.label} +
+
+ {section.items.map((item) => { + const Icon = item.icon + const isActive = isItemActive(item) + return ( + + + {item.label} + + ) + })} +
+
+
+ ) +} diff --git a/src/web/src/components/layout/__tests__/AppSidebar.test.tsx b/src/web/src/components/layout/__tests__/AppSidebar.test.tsx index 6ca8798..be0054f 100644 --- a/src/web/src/components/layout/__tests__/AppSidebar.test.tsx +++ b/src/web/src/components/layout/__tests__/AppSidebar.test.tsx @@ -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 + }) }) diff --git a/src/web/src/components/ui/hover-card.tsx b/src/web/src/components/ui/hover-card.tsx new file mode 100644 index 0000000..83f263e --- /dev/null +++ b/src/web/src/components/ui/hover-card.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName + +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/src/web/src/hooks/useSidebarSections.ts b/src/web/src/hooks/useSidebarSections.ts new file mode 100644 index 0000000..c1b9f70 --- /dev/null +++ b/src/web/src/hooks/useSidebarSections.ts @@ -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 + +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(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 } +} diff --git a/src/web/src/layouts/ProtectedLayout.tsx b/src/web/src/layouts/ProtectedLayout.tsx index 3c7c9e2..87a0a0f 100644 --- a/src/web/src/layouts/ProtectedLayout.tsx +++ b/src/web/src/layouts/ProtectedLayout.tsx @@ -16,8 +16,10 @@ export function ProtectedLayout({ children }: ProtectedLayoutProps) {
- {/* Desktop sidebar — width controlled by SidebarNav itself (collapsed/expanded) */} -
+ {/* 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). */} +
-- 2.49.1