feat(ui): implementa sidebar colapsavel com icones e tooltips
All checks were successful
All checks were successful
- Muda collapsible de offcanvas para icon na sidebar - Adiciona tooltips aos itens de menu quando colapsado - Itens com submenu mostram mini-menu no tooltip - Logo mostra apenas icone quando colapsado - NavUser mostra apenas avatar quando colapsado - Adiciona separadores entre secoes quando colapsado - Centraliza icones horizontalmente no modo colapsado - Persiste estado da sidebar via cookie entre navegacoes - Corrige hydration mismatch com sincronizacao pos-hidratacao - Desabilita transicoes durante sincronizacao inicial - Remove bolinha do tooltip e ajusta espacamento - Corrige redirecionamento ao resetar dispositivo no Tauri 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
826b376dd3
commit
84117e6821
6 changed files with 305 additions and 158 deletions
|
|
@ -44,7 +44,14 @@ import {
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarRail,
|
SidebarRail,
|
||||||
|
SidebarSeparator,
|
||||||
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { NavUser } from "@/components/nav-user"
|
import { NavUser } from "@/components/nav-user"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
|
@ -151,9 +158,11 @@ const navigation: NavigationGroup[] = [
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar(props: React.ComponentProps<typeof Sidebar>) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { session, isLoading, isAdmin, isStaff, role } = useAuth()
|
const { session, isLoading, isAdmin, isStaff, role } = useAuth()
|
||||||
|
const { state: sidebarState } = useSidebar()
|
||||||
|
const isCollapsed = sidebarState === "collapsed"
|
||||||
const [isHydrated, setIsHydrated] = React.useState(false)
|
const [isHydrated, setIsHydrated] = React.useState(false)
|
||||||
const canAccess = React.useCallback(
|
const canAccess = React.useCallback(
|
||||||
(requiredRole?: NavRoleRequirement) => {
|
(requiredRole?: NavRoleRequirement) => {
|
||||||
|
|
@ -221,7 +230,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
|
|
||||||
if (!isHydrated) {
|
if (!isHydrated) {
|
||||||
return (
|
return (
|
||||||
<Sidebar {...props}>
|
<Sidebar collapsible="icon" {...props}>
|
||||||
<SidebarHeader className="pt-4">
|
<SidebarHeader className="pt-4">
|
||||||
<Skeleton className="h-14 w-full rounded-lg" />
|
<Skeleton className="h-14 w-full rounded-lg" />
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
@ -247,7 +256,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar {...props}>
|
<Sidebar collapsible="icon" {...props}>
|
||||||
<SidebarHeader className="pt-4">
|
<SidebarHeader className="pt-4">
|
||||||
<SidebarBrand
|
<SidebarBrand
|
||||||
logoSrc="/logo-raven.png"
|
logoSrc="/logo-raven.png"
|
||||||
|
|
@ -257,13 +266,19 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
/>
|
/>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
{navigation.map((group) => {
|
{navigation.map((group, groupIndex) => {
|
||||||
if (!canAccess(group.requiredRole)) return null
|
if (!canAccess(group.requiredRole)) return null
|
||||||
const visibleItems = group.items.filter((item) => !item.hidden && canAccess(item.requiredRole))
|
const visibleItems = group.items.filter((item) => !item.hidden && canAccess(item.requiredRole))
|
||||||
if (visibleItems.length === 0) return null
|
if (visibleItems.length === 0) return null
|
||||||
|
|
||||||
|
// Verifica se deve mostrar separador (quando colapsado e nao e o primeiro grupo)
|
||||||
|
const showSeparator = isCollapsed && groupIndex > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<React.Fragment key={group.title}>
|
||||||
|
{showSeparator && <SidebarSeparator className="my-2" />}
|
||||||
<SidebarGroup key={group.title}>
|
<SidebarGroup key={group.title}>
|
||||||
<SidebarGroupLabel>{group.title}</SidebarGroupLabel>
|
{!isCollapsed && <SidebarGroupLabel>{group.title}</SidebarGroupLabel>}
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{visibleItems.map((item) => {
|
{visibleItems.map((item) => {
|
||||||
|
|
@ -271,13 +286,58 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const childItems = item.children.filter((child) => !child.hidden && canAccess(child.requiredRole))
|
const childItems = item.children.filter((child) => !child.hidden && canAccess(child.requiredRole))
|
||||||
const isExpanded = expanded.has(item.title)
|
const isExpanded = expanded.has(item.title)
|
||||||
const isChildActive = childItems.some((child) => isActive(child))
|
const isChildActive = childItems.some((child) => isActive(child))
|
||||||
const parentActive = isChildActive
|
|
||||||
const isToggleOnly = true // Todos os menus com filhos expandem ao clicar, nao navegam
|
|
||||||
|
|
||||||
|
// Quando colapsado, mostra tooltip com submenu
|
||||||
|
if (isCollapsed) {
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
isActive={isChildActive}
|
||||||
|
className="font-medium"
|
||||||
|
>
|
||||||
|
<Link href={childItems[0]?.url ?? item.url} className="gap-2">
|
||||||
|
{item.icon ? <item.icon className="size-5" /> : null}
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="start"
|
||||||
|
className="bg-sidebar border-sidebar-border p-0 shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col min-w-[180px]">
|
||||||
|
<div className="border-b border-sidebar-border px-3 py-2">
|
||||||
|
<p className="font-semibold text-sm text-sidebar-foreground">{item.title}</p>
|
||||||
|
</div>
|
||||||
|
<div className="py-1">
|
||||||
|
{childItems.map((child) => (
|
||||||
|
<Link
|
||||||
|
key={child.title}
|
||||||
|
href={child.url}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-1.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors",
|
||||||
|
isActive(child) && "bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{child.icon ? <child.icon className="size-3.5 text-sidebar-foreground/70" /> : null}
|
||||||
|
<span>{child.title}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modo expandido - comportamento normal
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={item.title}>
|
<React.Fragment key={item.title}>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
{isToggleOnly ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -298,29 +358,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
<ChevronDown className="size-3" />
|
<ChevronDown className="size-3" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
|
||||||
<SidebarMenuButton asChild isActive={parentActive}>
|
|
||||||
<Link href={item.url} className={cn("gap-2", "relative pr-7") }>
|
|
||||||
{item.icon ? <item.icon className="size-4" /> : null}
|
|
||||||
<span className="flex-1">{item.title}</span>
|
|
||||||
<span
|
|
||||||
role="button"
|
|
||||||
aria-label={isExpanded ? "Recolher submenu" : "Expandir submenu"}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
toggleExpanded(item.title)
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"absolute right-1.5 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-neutral-500 transition hover:bg-slate-200 hover:text-neutral-700",
|
|
||||||
isExpanded && "rotate-180"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ChevronDown className="size-3" />
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
)}
|
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
{isExpanded
|
{isExpanded
|
||||||
? childItems.map((child) => (
|
? childItems.map((child) => (
|
||||||
|
|
@ -338,6 +375,30 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Item simples (sem filhos)
|
||||||
|
if (isCollapsed) {
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<SidebarMenuButton asChild isActive={isActive(item)} className="font-medium">
|
||||||
|
<Link href={item.url} className="gap-2">
|
||||||
|
{item.icon ? <item.icon className="size-5" /> : null}
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
className="bg-sidebar border-sidebar-border text-sidebar-foreground shadow-lg"
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem key={item.title}>
|
||||||
<SidebarMenuButton asChild isActive={isActive(item)} className="font-medium">
|
<SidebarMenuButton asChild isActive={isActive(item)} className="font-medium">
|
||||||
|
|
@ -352,6 +413,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import { signOut, useAuth } from "@/lib/auth-client"
|
import { signOut, useAuth } from "@/lib/auth-client"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type NavUserProps = {
|
type NavUserProps = {
|
||||||
user?: {
|
user?: {
|
||||||
|
|
@ -42,7 +43,8 @@ type NavUserProps = {
|
||||||
|
|
||||||
export function NavUser({ user }: NavUserProps) {
|
export function NavUser({ user }: NavUserProps) {
|
||||||
const normalizedUser = user ?? { name: null, email: null, avatarUrl: null }
|
const normalizedUser = user ?? { name: null, email: null, avatarUrl: null }
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile, state } = useSidebar()
|
||||||
|
const isCollapsed = state === "collapsed"
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isSigningOut, setIsSigningOut] = useState(false)
|
const [isSigningOut, setIsSigningOut] = useState(false)
|
||||||
const [isDesktopShell, setIsDesktopShell] = useState(false)
|
const [isDesktopShell, setIsDesktopShell] = useState(false)
|
||||||
|
|
@ -96,19 +98,25 @@ export function NavUser({ user }: NavUserProps) {
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
size="lg"
|
size="lg"
|
||||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground transition-all duration-200 ease-linear"
|
||||||
>
|
>
|
||||||
<Avatar className="h-8 w-8 rounded-lg">
|
<Avatar className="h-8 w-8 shrink-0 rounded-lg transition-all duration-200 ease-linear">
|
||||||
<AvatarImage src={normalizedUser.avatarUrl ?? undefined} alt={displayName} />
|
<AvatarImage src={normalizedUser.avatarUrl ?? undefined} alt={displayName} />
|
||||||
<AvatarFallback className="rounded-lg bg-neutral-200 text-neutral-700 font-medium">{initials}</AvatarFallback>
|
<AvatarFallback className="rounded-lg bg-neutral-200 text-neutral-700 font-medium">{initials}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className={cn(
|
||||||
<span className="truncate font-medium">{displayName}</span>
|
"grid flex-1 text-left text-sm leading-tight transition-all duration-200 ease-linear overflow-hidden",
|
||||||
<span className="text-muted-foreground truncate text-xs">
|
isCollapsed ? "w-0 opacity-0" : "w-auto opacity-100"
|
||||||
|
)}>
|
||||||
|
<span className="truncate font-medium whitespace-nowrap">{displayName}</span>
|
||||||
|
<span className="text-muted-foreground truncate text-xs whitespace-nowrap">
|
||||||
{displayEmail}
|
{displayEmail}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<IconDotsVertical className="ml-auto size-4" />
|
<IconDotsVertical className={cn(
|
||||||
|
"ml-auto size-4 transition-all duration-200 ease-linear",
|
||||||
|
isCollapsed ? "w-0 opacity-0" : "w-4 opacity-100"
|
||||||
|
)} />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,18 @@ export function PortalShell({ children }: PortalShellProps) {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleTokenRevoked = useCallback(() => {
|
const handleTokenRevoked = useCallback(() => {
|
||||||
console.log("[PortalShell] Token foi revogado - redirecionando para login")
|
console.log("[PortalShell] Token foi revogado - redirecionando para tela de registro")
|
||||||
toast.error("Este dispositivo foi resetado. Faça login novamente.")
|
toast.error("Este dispositivo foi resetado. Faça login novamente.")
|
||||||
|
// Se estiver rodando dentro do Tauri, navega para a URL do app para voltar à tela de registro
|
||||||
|
const isTauri = typeof window !== "undefined" && Boolean((window as typeof window & { __TAURI__?: unknown }).__TAURI__)
|
||||||
|
if (isTauri) {
|
||||||
|
// URL do app Tauri - em producao usa tauri.localhost, em dev usa localhost:1420
|
||||||
|
const isDev = process.env.NODE_ENV === "development"
|
||||||
|
const tauriUrl = isDev ? "http://localhost:1420/" : "http://tauri.localhost/"
|
||||||
|
console.log("[PortalShell] Detectado ambiente Tauri, navegando para:", tauriUrl)
|
||||||
|
window.location.href = tauriUrl
|
||||||
|
return
|
||||||
|
}
|
||||||
router.replace("/login")
|
router.replace("/login")
|
||||||
}, [router])
|
}, [router])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,14 @@ import {
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface SidebarBrandProps {
|
interface SidebarBrandProps {
|
||||||
logoSrc: string
|
logoSrc: string
|
||||||
|
|
@ -16,33 +23,69 @@ interface SidebarBrandProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarBrand({ logoSrc, logoAlt, title, subtitle }: SidebarBrandProps) {
|
export function SidebarBrand({ logoSrc, logoAlt, title, subtitle }: SidebarBrandProps) {
|
||||||
return (
|
const { state } = useSidebar()
|
||||||
<SidebarMenu>
|
const isCollapsed = state === "collapsed"
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton
|
const brandContent = (
|
||||||
asChild
|
<div className="flex items-center gap-3 transition-all duration-200 ease-linear">
|
||||||
size="lg"
|
<div className={cn(
|
||||||
className="h-auto cursor-default select-none hover:bg-transparent hover:text-inherit active:bg-transparent active:text-inherit focus-visible:ring-0"
|
"flex shrink-0 items-center justify-center transition-all duration-200 ease-linear",
|
||||||
>
|
isCollapsed ? "size-9" : "size-8"
|
||||||
<div className="flex items-center gap-3">
|
)}>
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
|
||||||
<Image
|
<Image
|
||||||
src={logoSrc}
|
src={logoSrc}
|
||||||
alt={logoAlt}
|
alt={logoAlt}
|
||||||
width={48}
|
width={36}
|
||||||
height={48}
|
height={36}
|
||||||
className="h-12 w-12 object-contain"
|
className={cn(
|
||||||
|
"object-contain transition-all duration-200 ease-linear",
|
||||||
|
isCollapsed ? "size-9" : "size-8"
|
||||||
|
)}
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-start gap-1 leading-none">
|
<div className={cn(
|
||||||
<span className="text-lg font-semibold">{title}</span>
|
"flex flex-col items-start gap-1 leading-none transition-all duration-200 ease-linear overflow-hidden",
|
||||||
|
isCollapsed ? "w-0 opacity-0" : "w-auto opacity-100"
|
||||||
|
)}>
|
||||||
|
<span className="text-lg font-semibold whitespace-nowrap">{title}</span>
|
||||||
<span className="inline-flex whitespace-nowrap rounded-full bg-neutral-900 px-2.5 py-1 text-[11px] font-medium text-white">
|
<span className="inline-flex whitespace-nowrap rounded-full bg-neutral-900 px-2.5 py-1 text-[11px] font-medium text-white">
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
size="lg"
|
||||||
|
className="h-auto cursor-default select-none hover:bg-transparent hover:text-inherit active:bg-transparent active:text-inherit focus-visible:ring-0"
|
||||||
|
>
|
||||||
|
{brandContent}
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
{button}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
className="bg-sidebar border-sidebar-border text-sidebar-foreground shadow-lg"
|
||||||
|
>
|
||||||
|
<p className="font-semibold">{title}</p>
|
||||||
|
<p className="text-xs text-sidebar-foreground/70">{subtitle}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
button
|
||||||
|
)}
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||||
const SIDEBAR_WIDTH = "clamp(12rem, 15vw + 1.5rem, 16.5rem)"
|
const SIDEBAR_WIDTH = "clamp(12rem, 15vw + 1.5rem, 16.5rem)"
|
||||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
const SIDEBAR_WIDTH_ICON = "4rem"
|
||||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||||
|
|
||||||
type SidebarContextProps = {
|
type SidebarContextProps = {
|
||||||
|
|
@ -40,6 +40,7 @@ type SidebarContextProps = {
|
||||||
setOpenMobile: (open: boolean) => void
|
setOpenMobile: (open: boolean) => void
|
||||||
isMobile: boolean
|
isMobile: boolean
|
||||||
toggleSidebar: () => void
|
toggleSidebar: () => void
|
||||||
|
isHydrated: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||||
|
|
@ -68,11 +69,31 @@ function SidebarProvider({
|
||||||
}) {
|
}) {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const [openMobile, setOpenMobile] = React.useState(false)
|
const [openMobile, setOpenMobile] = React.useState(false)
|
||||||
|
const [isHydrated, setIsHydrated] = React.useState(false)
|
||||||
|
|
||||||
// This is the internal state of the sidebar.
|
// This is the internal state of the sidebar.
|
||||||
// We use openProp and setOpenProp for control from outside the component.
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||||
const open = openProp ?? _open
|
const open = openProp ?? _open
|
||||||
|
|
||||||
|
// Sync state from cookie after hydration to avoid hydration mismatch
|
||||||
|
React.useEffect(() => {
|
||||||
|
const cookie = document.cookie
|
||||||
|
.split("; ")
|
||||||
|
.find((row) => row.startsWith(`${SIDEBAR_COOKIE_NAME}=`))
|
||||||
|
if (cookie) {
|
||||||
|
const cookieValue = cookie.split("=")[1] === "true"
|
||||||
|
if (cookieValue !== _open) {
|
||||||
|
_setOpen(cookieValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mark as hydrated after a small delay to allow state to settle without animation
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setIsHydrated(true)
|
||||||
|
})
|
||||||
|
// Only run once on mount
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
const setOpen = React.useCallback(
|
const setOpen = React.useCallback(
|
||||||
(value: boolean | ((value: boolean) => boolean)) => {
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
const openState = typeof value === "function" ? value(open) : value
|
const openState = typeof value === "function" ? value(open) : value
|
||||||
|
|
@ -122,8 +143,9 @@ function SidebarProvider({
|
||||||
openMobile,
|
openMobile,
|
||||||
setOpenMobile,
|
setOpenMobile,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
|
isHydrated,
|
||||||
}),
|
}),
|
||||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, isHydrated]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -132,6 +154,7 @@ function SidebarProvider({
|
||||||
<div
|
<div
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
data-slot="sidebar-wrapper"
|
data-slot="sidebar-wrapper"
|
||||||
|
data-hydrated={isHydrated ? "true" : "false"}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--sidebar-width": SIDEBAR_WIDTH,
|
"--sidebar-width": SIDEBAR_WIDTH,
|
||||||
|
|
@ -141,6 +164,7 @@ function SidebarProvider({
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||||
|
!isHydrated && "[&_*]:!transition-none",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -466,7 +490,11 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
<ul
|
<ul
|
||||||
data-slot="sidebar-menu"
|
data-slot="sidebar-menu"
|
||||||
data-sidebar="menu"
|
data-sidebar="menu"
|
||||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
className={cn(
|
||||||
|
"flex w-full min-w-0 flex-col gap-1",
|
||||||
|
"group-data-[collapsible=icon]:items-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ function TooltipTrigger({
|
||||||
|
|
||||||
function TooltipContent({
|
function TooltipContent({
|
||||||
className,
|
className,
|
||||||
sideOffset = 20,
|
sideOffset = 8,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
|
@ -46,16 +46,12 @@ function TooltipContent({
|
||||||
data-slot="tooltip-content"
|
data-slot="tooltip-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 relative z-50 w-fit origin-(--radix-tooltip-content-transform-origin) overflow-visible rounded-md px-3 py-1.5 text-xs text-balance group",
|
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) overflow-hidden rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute z-50 size-2 rounded-full bg-neutral-900 group-data-[side=top]:left-1/2 group-data-[side=top]:top-full group-data-[side=top]:-translate-x-1/2 group-data-[side=top]:mt-1 group-data-[side=bottom]:left-1/2 group-data-[side=bottom]:bottom-full group-data-[side=bottom]:-translate-x-1/2 group-data-[side=bottom]:mb-1 group-data-[side=left]:left-full group-data-[side=left]:top-1/2 group-data-[side=left]:-translate-y-1/2 group-data-[side=left]:ml-1 group-data-[side=right]:right-full group-data-[side=right]:top-1/2 group-data-[side=right]:-translate-y-1/2 group-data-[side=right]:mr-1"
|
|
||||||
/>
|
|
||||||
</TooltipPrimitive.Content>
|
</TooltipPrimitive.Content>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue