fix: portal-based sidebar tooltips, visible when collapsed
Old tooltips were clipped by overflow-hidden on the aside. New SidebarTooltip renders via createPortal with fixed positioning, 100ms delay, rounded-lg glass styling with border and shadow.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname, useRouter } from 'next/navigation'
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
import { listSites, type Site } from '@/lib/api/sites'
|
import { listSites, type Site } from '@/lib/api/sites'
|
||||||
@@ -80,6 +81,45 @@ function Label({ children, collapsed }: { children: React.ReactNode; collapsed:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Sidebar Tooltip (portal-based, escapes overflow-hidden) ──
|
||||||
|
|
||||||
|
function SidebarTooltip({ children, label }: { children: React.ReactNode; label: string }) {
|
||||||
|
const [show, setShow] = useState(false)
|
||||||
|
const [pos, setPos] = useState({ x: 0, y: 0 })
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
|
||||||
|
const handleEnter = () => {
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
if (ref.current) {
|
||||||
|
const rect = ref.current.getBoundingClientRect()
|
||||||
|
setPos({ x: rect.right + 8, y: rect.top + rect.height / 2 })
|
||||||
|
setShow(true)
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLeave = () => {
|
||||||
|
clearTimeout(timerRef.current)
|
||||||
|
setShow(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} onMouseEnter={handleEnter} onMouseLeave={handleLeave}>
|
||||||
|
{children}
|
||||||
|
{show && typeof document !== 'undefined' && createPortal(
|
||||||
|
<span
|
||||||
|
className="fixed z-[100] px-2.5 py-1.5 rounded-lg bg-neutral-800 border border-white/[0.08] text-white text-xs font-medium whitespace-nowrap pointer-events-none shadow-lg shadow-black/20 -translate-y-1/2"
|
||||||
|
style={{ left: pos.x, top: pos.y }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Nav Item ───────────────────────────────────────────────
|
// ─── Nav Item ───────────────────────────────────────────────
|
||||||
|
|
||||||
function NavLink({
|
function NavLink({
|
||||||
@@ -94,29 +134,25 @@ function NavLink({
|
|||||||
const matchesPending = pendingHref !== null && (item.matchPrefix ? pendingHref.startsWith(href) : pendingHref === href)
|
const matchesPending = pendingHref !== null && (item.matchPrefix ? pendingHref.startsWith(href) : pendingHref === href)
|
||||||
const active = matchesPathname || matchesPending
|
const active = matchesPathname || matchesPending
|
||||||
|
|
||||||
return (
|
const link = (
|
||||||
<div className="relative group/nav">
|
<Link
|
||||||
<Link
|
href={href}
|
||||||
href={href}
|
onClick={() => { onNavigate(href); onClick?.() }}
|
||||||
onClick={() => { onNavigate(href); onClick?.() }}
|
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 ${
|
||||||
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 ${
|
active
|
||||||
active
|
? 'bg-brand-orange/10 text-brand-orange'
|
||||||
? 'bg-brand-orange/10 text-brand-orange'
|
: 'text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5'
|
||||||
: 'text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5'
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
<span className="w-7 h-7 flex items-center justify-center shrink-0">
|
||||||
<span className="w-7 h-7 flex items-center justify-center shrink-0">
|
<item.icon className="w-[18px] h-[18px]" weight={active ? 'fill' : 'regular'} />
|
||||||
<item.icon className="w-[18px] h-[18px]" weight={active ? 'fill' : 'regular'} />
|
</span>
|
||||||
</span>
|
<Label collapsed={collapsed}>{item.label}</Label>
|
||||||
<Label collapsed={collapsed}>{item.label}</Label>
|
</Link>
|
||||||
</Link>
|
|
||||||
{collapsed && (
|
|
||||||
<span className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 rounded-md bg-neutral-800 text-white text-xs whitespace-nowrap opacity-0 group-hover/nav:opacity-100 transition-opacity duration-150 delay-150 z-50">
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (collapsed) return <SidebarTooltip label={item.label}>{link}</SidebarTooltip>
|
||||||
|
return link
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Settings Button (opens unified modal instead of navigating) ─────
|
// ─── Settings Button (opens unified modal instead of navigating) ─────
|
||||||
@@ -128,27 +164,23 @@ function SettingsButton({
|
|||||||
}) {
|
}) {
|
||||||
const { openUnifiedSettings } = useUnifiedSettings()
|
const { openUnifiedSettings } = useUnifiedSettings()
|
||||||
|
|
||||||
return (
|
const btn = (
|
||||||
<div className="relative group/nav">
|
<button
|
||||||
<button
|
onClick={() => {
|
||||||
onClick={() => {
|
openUnifiedSettings({ context: settingsContext, tab: 'general' })
|
||||||
openUnifiedSettings({ context: settingsContext, tab: 'general' })
|
onClick?.()
|
||||||
onClick?.()
|
}}
|
||||||
}}
|
className="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5 w-full cursor-pointer"
|
||||||
className="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5 w-full cursor-pointer"
|
>
|
||||||
>
|
<span className="w-7 h-7 flex items-center justify-center shrink-0">
|
||||||
<span className="w-7 h-7 flex items-center justify-center shrink-0">
|
<item.icon className="w-[18px] h-[18px]" weight="regular" />
|
||||||
<item.icon className="w-[18px] h-[18px]" weight="regular" />
|
</span>
|
||||||
</span>
|
<Label collapsed={collapsed}>{item.label}</Label>
|
||||||
<Label collapsed={collapsed}>{item.label}</Label>
|
</button>
|
||||||
</button>
|
|
||||||
{collapsed && (
|
|
||||||
<span className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 rounded-md bg-neutral-800 text-white text-xs whitespace-nowrap opacity-0 group-hover/nav:opacity-100 transition-opacity duration-150 delay-150 z-50">
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (collapsed) return <SidebarTooltip label={item.label}>{btn}</SidebarTooltip>
|
||||||
|
return btn
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Home Nav Link (static href, no siteId) ───────────────
|
// ─── Home Nav Link (static href, no siteId) ───────────────
|
||||||
@@ -162,30 +194,26 @@ function HomeNavLink({
|
|||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const active = !external && pathname === href
|
const active = !external && pathname === href
|
||||||
|
|
||||||
return (
|
const link = (
|
||||||
<div className="relative group/nav">
|
<Link
|
||||||
<Link
|
href={href}
|
||||||
href={href}
|
onClick={onClick}
|
||||||
onClick={onClick}
|
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||||
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 ${
|
||||||
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 ${
|
active
|
||||||
active
|
? 'bg-brand-orange/10 text-brand-orange'
|
||||||
? 'bg-brand-orange/10 text-brand-orange'
|
: 'text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5'
|
||||||
: 'text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5'
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
<span className="w-7 h-7 flex items-center justify-center shrink-0">
|
||||||
<span className="w-7 h-7 flex items-center justify-center shrink-0">
|
<Icon className="w-[18px] h-[18px]" weight={active ? 'fill' : 'regular'} />
|
||||||
<Icon className="w-[18px] h-[18px]" weight={active ? 'fill' : 'regular'} />
|
</span>
|
||||||
</span>
|
<Label collapsed={collapsed}>{label}</Label>
|
||||||
<Label collapsed={collapsed}>{label}</Label>
|
</Link>
|
||||||
</Link>
|
|
||||||
{collapsed && (
|
|
||||||
<span className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 rounded-md bg-neutral-800 text-white text-xs whitespace-nowrap opacity-0 group-hover/nav:opacity-100 transition-opacity duration-150 delay-150 z-50">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (collapsed) return <SidebarTooltip label={label}>{link}</SidebarTooltip>
|
||||||
|
return link
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Home Site Link (favicon + name) ───────────────────────
|
// ─── Home Site Link (favicon + name) ───────────────────────
|
||||||
@@ -199,33 +227,29 @@ function HomeSiteLink({
|
|||||||
const href = `/sites/${site.id}`
|
const href = `/sites/${site.id}`
|
||||||
const active = pathname.startsWith(href)
|
const active = pathname.startsWith(href)
|
||||||
|
|
||||||
return (
|
const link = (
|
||||||
<div className="relative group/nav">
|
<Link
|
||||||
<Link
|
href={href}
|
||||||
href={href}
|
onClick={onClick}
|
||||||
onClick={onClick}
|
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 ${
|
||||||
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 ${
|
active
|
||||||
active
|
? 'bg-brand-orange/10 text-brand-orange'
|
||||||
? 'bg-brand-orange/10 text-brand-orange'
|
: 'text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5'
|
||||||
: 'text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5'
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
<span className="w-7 h-7 rounded-md bg-white/[0.04] flex items-center justify-center shrink-0 overflow-hidden">
|
||||||
<span className="w-7 h-7 rounded-md bg-white/[0.04] flex items-center justify-center shrink-0 overflow-hidden">
|
<img
|
||||||
<img
|
src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
|
||||||
src={`${FAVICON_SERVICE_URL}?domain=${site.domain}&sz=64`}
|
alt=""
|
||||||
alt=""
|
className="w-[18px] h-[18px] rounded object-contain"
|
||||||
className="w-[18px] h-[18px] rounded object-contain"
|
/>
|
||||||
/>
|
</span>
|
||||||
</span>
|
<Label collapsed={collapsed}>{site.name}</Label>
|
||||||
<Label collapsed={collapsed}>{site.name}</Label>
|
</Link>
|
||||||
</Link>
|
|
||||||
{collapsed && (
|
|
||||||
<span className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 rounded-md bg-neutral-800 text-white text-xs whitespace-nowrap opacity-0 group-hover/nav:opacity-100 transition-opacity duration-150 delay-150 z-50">
|
|
||||||
{site.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (collapsed) return <SidebarTooltip label={site.name}>{link}</SidebarTooltip>
|
||||||
|
return link
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Sidebar Content ────────────────────────────────────────
|
// ─── Sidebar Content ────────────────────────────────────────
|
||||||
@@ -354,17 +378,36 @@ function SidebarContent({
|
|||||||
<div className="border-t border-white/[0.06] px-2 py-3 shrink-0">
|
<div className="border-t border-white/[0.06] px-2 py-3 shrink-0">
|
||||||
{/* Notifications, Profile — same layout as nav items */}
|
{/* Notifications, Profile — same layout as nav items */}
|
||||||
<div className="space-y-0.5 mb-1">
|
<div className="space-y-0.5 mb-1">
|
||||||
<div className="relative group/notif">
|
{c ? (
|
||||||
|
<SidebarTooltip label="Notifications">
|
||||||
|
<NotificationCenter anchor="right" variant="sidebar">
|
||||||
|
<Label collapsed={c}>Notifications</Label>
|
||||||
|
</NotificationCenter>
|
||||||
|
</SidebarTooltip>
|
||||||
|
) : (
|
||||||
<NotificationCenter anchor="right" variant="sidebar">
|
<NotificationCenter anchor="right" variant="sidebar">
|
||||||
<Label collapsed={c}>Notifications</Label>
|
<Label collapsed={c}>Notifications</Label>
|
||||||
</NotificationCenter>
|
</NotificationCenter>
|
||||||
{c && (
|
)}
|
||||||
<span className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 rounded-md bg-neutral-800 text-white text-xs whitespace-nowrap opacity-0 group-hover/notif:opacity-100 transition-opacity duration-150 delay-150 z-50">
|
{c ? (
|
||||||
Notifications
|
<SidebarTooltip label={user?.display_name?.trim() || 'Profile'}>
|
||||||
</span>
|
<UserMenu
|
||||||
)}
|
auth={auth}
|
||||||
</div>
|
LinkComponent={Link}
|
||||||
<div className="relative group/user">
|
orgs={orgs}
|
||||||
|
activeOrgId={auth.user?.org_id}
|
||||||
|
onSwitchOrganization={onSwitchOrganization}
|
||||||
|
onCreateOrganization={() => router.push('/onboarding')}
|
||||||
|
allowPersonalOrganization={false}
|
||||||
|
onOpenSettings={openSettings}
|
||||||
|
onOpenOrgSettings={openOrgSettings}
|
||||||
|
compact
|
||||||
|
anchor="right"
|
||||||
|
>
|
||||||
|
<Label collapsed={c}>{user?.display_name?.trim() || 'Profile'}</Label>
|
||||||
|
</UserMenu>
|
||||||
|
</SidebarTooltip>
|
||||||
|
) : (
|
||||||
<UserMenu
|
<UserMenu
|
||||||
auth={auth}
|
auth={auth}
|
||||||
LinkComponent={Link}
|
LinkComponent={Link}
|
||||||
@@ -380,12 +423,7 @@ function SidebarContent({
|
|||||||
>
|
>
|
||||||
<Label collapsed={c}>{user?.display_name?.trim() || 'Profile'}</Label>
|
<Label collapsed={c}>{user?.display_name?.trim() || 'Profile'}</Label>
|
||||||
</UserMenu>
|
</UserMenu>
|
||||||
{c && (
|
)}
|
||||||
<span className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 rounded-md bg-neutral-800 text-white text-xs whitespace-nowrap opacity-0 group-hover/user:opacity-100 transition-opacity duration-150 delay-150 z-50">
|
|
||||||
{user?.display_name?.trim() || 'Profile'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user