feat(web): tooltips Radix + toggle sidebar al lado del brand

Cambios:
- Instalado @radix-ui/react-tooltip 1.2.8 (componente faltante de
  shadcn/ui que faltaba en el set inicial).
- Nuevo src/web/src/components/ui/tooltip.tsx (shadcn pattern):
  TooltipProvider, Tooltip, TooltipTrigger, TooltipContent con
  animaciones data-state (fade + zoom + slide direccional).
- App.tsx: TooltipProvider envuelve toda la app con delayDuration 150ms.
- AppSidebar refactorizado:
  - Toggle button MOVIDO al header (top), al lado izquierdo del nombre
    'SIG-CM 2.0'. Eliminado boton bottom (era redundante).
  - Cuando collapsed: solo el toggle visible centrado (68px width).
  - Cuando expanded: [Toggle] [SIG-CM 2.0] aligned left.
  - Quitado overflow-hidden del aside (era lo que impedia que los
    tooltips fueran visibles — los clipping containers padres tampoco
    importan ahora porque Radix portalea el tooltip a body).
  - Tooltips en TODOS los items collapsed (incluido el toggle) y en
    items disabled muestra 'Label · Próximamente'.
  - Eliminado el componente CSS-only SidebarTooltip (reemplazado por
    Radix que se renderiza fuera del DOM tree con Portal).

El bug original era que tanto el aside con overflow-hidden como el
ProtectedLayout con overflow-hidden clipean cualquier elemento que
intente escapar via absolute positioning. Radix Portal soluciona
eso renderizando el tooltip en document.body.

Tests 136/136 verde.
This commit is contained in:
2026-04-16 11:26:41 -03:00
parent 7b7ef1c137
commit 83d76b95d4
5 changed files with 191 additions and 70 deletions

View File

@@ -19,6 +19,7 @@
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.99.0", "@tanstack/react-query": "^5.99.0",
"axios": "1.7", "axios": "1.7",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -2723,6 +2724,96 @@
} }
} }
}, },
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
"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-id": "1.1.1",
"@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-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "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-tooltip/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-tooltip/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-tooltip/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-use-callback-ref": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",

View File

@@ -24,6 +24,7 @@
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.99.0", "@tanstack/react-query": "^5.99.0",
"axios": "1.7", "axios": "1.7",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View File

@@ -1,6 +1,7 @@
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AppRoutes } from './router' import { AppRoutes } from './router'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -13,10 +14,12 @@ const queryClient = new QueryClient({
function App() { function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={150}>
<BrowserRouter> <BrowserRouter>
<AppRoutes /> <AppRoutes />
</BrowserRouter> </BrowserRouter>
<Toaster richColors closeButton position="top-right" /> <Toaster richColors closeButton position="top-right" />
</TooltipProvider>
</QueryClientProvider> </QueryClientProvider>
) )
} }

View File

@@ -11,10 +11,10 @@ import {
KeyRound, KeyRound,
PanelLeftClose, PanelLeftClose,
PanelLeftOpen, 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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { useSidebar } from '@/hooks/useSidebar' import { useSidebar } from '@/hooks/useSidebar'
@@ -65,18 +65,42 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
return ( return (
<aside <aside
className={cn( className={cn(
'flex h-full flex-col bg-card text-card-foreground overflow-hidden transition-[width] duration-200 ease-out', 'flex h-full flex-col bg-card text-card-foreground transition-[width] duration-200 ease-out',
collapsed ? 'w-[68px]' : 'w-60', collapsed ? 'w-[68px]' : 'w-60',
)} )}
data-collapsed={collapsed} data-collapsed={collapsed}
> >
{/* Brand */} {/* Brand + Toggle (top header) */}
<div className="flex h-14 items-center border-b border-border px-3 shrink-0"> <div
<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"> className={cn(
<Newspaper className="h-4 w-4 text-white" strokeWidth={2.25} /> 'flex h-14 items-center border-b border-border shrink-0',
</div> collapsed ? 'justify-center px-2' : 'px-3 gap-2',
)}
>
{!forceExpanded && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggle}
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"
>
{collapsed ? (
<PanelLeftOpen className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="right">
{collapsed ? 'Expandir' : 'Colapsar'}
</TooltipContent>
</Tooltip>
)}
{!collapsed && ( {!collapsed && (
<span className="ml-3 text-sm font-semibold tracking-tight text-foreground truncate"> <span className="text-sm font-semibold tracking-tight text-foreground truncate">
SIG-CM 2.0 SIG-CM 2.0
</span> </span>
)} )}
@@ -107,32 +131,6 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
</> </>
)} )}
</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>
) )
} }
@@ -151,12 +149,13 @@ function NavRow({ item, collapsed, active }: NavRowProps) {
const Icon = item.icon const Icon = item.icon
const baseClasses = cn( const baseClasses = cn(
'group/item relative flex items-center rounded-md text-sm transition-colors', 'relative flex items-center rounded-md text-sm transition-colors',
collapsed ? 'justify-center h-10 px-0' : 'gap-3 px-3 py-2', collapsed ? 'justify-center h-10 w-10 mx-auto' : 'gap-3 px-3 py-2',
) )
// Disabled item
if (item.disabled) { if (item.disabled) {
return ( const content = (
<div <div
className={cn( className={cn(
baseClasses, baseClasses,
@@ -173,12 +172,23 @@ function NavRow({ item, collapsed, active }: NavRowProps) {
</Badge> </Badge>
</> </>
)} )}
{collapsed && <SidebarTooltip>{item.label} · Próximamente</SidebarTooltip>}
</div> </div>
) )
if (!collapsed) return content
return (
<Tooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="right">
{item.label}{' '}
<span className="text-muted-foreground">· Próximamente</span>
</TooltipContent>
</Tooltip>
)
} }
return ( // Active link
const link = (
<Link <Link
to={item.href} to={item.href}
className={cn( className={cn(
@@ -189,15 +199,22 @@ function NavRow({ item, collapsed, active }: NavRowProps) {
)} )}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
> >
{/* Active indicator bar (left edge) — sutil cuando expanded, dot cuando collapsed */} {/* Active indicator bar (left edge) when expanded */}
{active && !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" /> <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" /> <Icon className="h-4 w-4 shrink-0" />
{!collapsed && <span className="truncate">{item.label}</span>} {!collapsed && <span className="truncate">{item.label}</span>}
{collapsed && <SidebarTooltip>{item.label}</SidebarTooltip>}
</Link> </Link>
) )
if (!collapsed) return link
return (
<Tooltip>
<TooltipTrigger asChild>{link}</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
)
} }
function SectionLabel({ function SectionLabel({
@@ -208,9 +225,7 @@ function SectionLabel({
children: React.ReactNode children: React.ReactNode
}) { }) {
if (collapsed) { if (collapsed) {
return ( return <div className="my-2 mx-2 border-t border-border" aria-hidden="true" />
<div className="my-2 mx-2 border-t border-border" aria-hidden="true" />
)
} }
return ( return (
<div className="pt-3 pb-1 px-3"> <div className="pt-3 pb-1 px-3">
@@ -220,25 +235,3 @@ function SectionLabel({
</div> </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,33 @@
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 8, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs font-medium text-popover-foreground shadow-lg',
'data-[state=delayed-open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=delayed-open]:zoom-in-95',
'data-[side=right]:slide-in-from-left-1 data-[side=left]:slide-in-from-right-1',
'data-[side=top]:slide-in-from-bottom-1 data-[side=bottom]:slide-in-from-top-1',
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }