feat(sidebar): mobile exit animation, site picker entrance, hover nudge, CSS tooltips

This commit is contained in:
Usman Baig
2026-03-23 15:23:31 +01:00
parent 645e3e78ef
commit 414e112d3d

View File

@@ -193,7 +193,7 @@ function SitePicker({ sites, siteId, collapsed, onExpand, onCollapse, wasCollaps
</button> </button>
{open && ( {open && (
<div className="absolute left-3 top-full mt-1 z-50 w-[240px] bg-neutral-900 border border-neutral-700 rounded-xl shadow-xl overflow-hidden"> <div className="absolute left-3 top-full mt-1 z-50 w-[240px] bg-neutral-900 border border-neutral-700 rounded-xl shadow-xl overflow-hidden animate-in fade-in zoom-in-95 duration-150">
<div className="p-2"> <div className="p-2">
<input <input
type="text" type="text"
@@ -262,21 +262,27 @@ function NavLink({
const active = matchesPathname || matchesPending const active = matchesPathname || matchesPending
return ( return (
<Link <div className="relative group/nav">
href={href} <Link
onClick={() => { onNavigate(href); onClick?.() }} href={href}
title={collapsed ? item.label : undefined} onClick={() => { onNavigate(href); onClick?.() }}
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden ${ 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-neutral-800' : 'text-neutral-400 hover:text-white hover:bg-neutral-800 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>
) )
} }
@@ -365,12 +371,17 @@ function SidebarContent({
<div className="border-t border-neutral-800/60 px-2 py-3 shrink-0"> <div className="border-t border-neutral-800/60 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">
<span title={c ? 'Notifications' : undefined}> <div className="relative group/notif">
<NotificationCenter anchor="right" variant="sidebar"> <NotificationCenter anchor="right" variant="sidebar">
<Label collapsed={c}>Notifications</Label> <Label collapsed={c}>Notifications</Label>
</NotificationCenter> </NotificationCenter>
</span> {c && (
<span title={c ? (user?.display_name?.trim() || 'Profile') : undefined}> <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">
Notifications
</span>
)}
</div>
<div className="relative group/user">
<UserMenu <UserMenu
auth={auth} auth={auth}
LinkComponent={Link} LinkComponent={Link}
@@ -385,22 +396,33 @@ function SidebarContent({
> >
<Label collapsed={c}>{user?.display_name?.trim() || 'Profile'}</Label> <Label collapsed={c}>{user?.display_name?.trim() || 'Profile'}</Label>
</UserMenu> </UserMenu>
</span> {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>
{/* Settings + Collapse */} {/* Settings + Collapse */}
<div className="space-y-0.5"> <div className="space-y-0.5">
{!isMobile && ( {!isMobile && (
<button <div className="relative group/collapse">
onClick={onToggle} <button
className="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-400 dark:text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 w-full overflow-hidden" onClick={onToggle}
title={collapsed ? 'Expand sidebar (press [)' : 'Collapse sidebar (press [)'} className="flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-neutral-400 dark:text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 w-full overflow-hidden"
> >
<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">
<CollapseLeftIcon className={`w-[18px] h-[18px] transition-transform duration-200 ${c ? 'rotate-180' : ''}`} /> <CollapseLeftIcon className={`w-[18px] h-[18px] transition-transform duration-200 ${c ? 'rotate-180' : ''}`} />
</span> </span>
<Label collapsed={c}>{c ? 'Expand' : 'Collapse'}</Label> <Label collapsed={c}>{c ? 'Expand' : 'Collapse'}</Label>
</button> </button>
{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/collapse:opacity-100 transition-opacity duration-150 delay-150 z-50">
Expand (press [)
</span>
)}
</div>
)} )}
</div> </div>
</div> </div>
@@ -424,6 +446,7 @@ export default function Sidebar({
const [sites, setSites] = useState<Site[]>([]) const [sites, setSites] = useState<Site[]>([])
const [orgs, setOrgs] = useState<OrganizationMember[]>([]) const [orgs, setOrgs] = useState<OrganizationMember[]>([])
const [pendingHref, setPendingHref] = useState<string | null>(null) const [pendingHref, setPendingHref] = useState<string | null>(null)
const [mobileClosing, setMobileClosing] = useState(false)
const wasCollapsedRef = useRef(false) const wasCollapsedRef = useRef(false)
const pickerOpenCallbackRef = useRef<(() => void) | null>(null) const pickerOpenCallbackRef = useRef<(() => void) | null>(null)
// Safe to read localStorage directly — this component is loaded with ssr:false // Safe to read localStorage directly — this component is loaded with ssr:false
@@ -477,6 +500,14 @@ export default function Sidebar({
setCollapsed(true); localStorage.setItem(SIDEBAR_KEY, 'true') setCollapsed(true); localStorage.setItem(SIDEBAR_KEY, 'true')
}, []) }, [])
const handleMobileClose = useCallback(() => {
setMobileClosing(true)
setTimeout(() => {
setMobileClosing(false)
onMobileClose()
}, 200)
}, [onMobileClose])
const handleNavigate = useCallback((href: string) => { setPendingHref(href) }, []) const handleNavigate = useCallback((href: string) => { setPendingHref(href) }, [])
return ( return (
@@ -514,13 +545,24 @@ export default function Sidebar({
</aside> </aside>
{/* Mobile overlay */} {/* Mobile overlay */}
{mobileOpen && ( {(mobileOpen || mobileClosing) && (
<> <>
<div className="fixed inset-0 z-40 bg-black/30 md:hidden" onClick={onMobileClose} /> <div
<aside className="fixed inset-y-0 left-0 z-50 w-72 bg-neutral-900 border-r border-neutral-800 shadow-xl md:hidden animate-in slide-in-from-left duration-200"> className={`fixed inset-0 z-40 md:hidden transition-opacity duration-200 ${
mobileClosing ? 'bg-black/0' : 'bg-black/30'
}`}
onClick={handleMobileClose}
/>
<aside
className={`fixed inset-y-0 left-0 z-50 w-72 bg-neutral-900 border-r border-neutral-800 shadow-xl md:hidden ${
mobileClosing
? 'animate-out slide-out-to-left duration-200 fill-mode-forwards'
: 'animate-in slide-in-from-left duration-200'
}`}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-800"> <div className="flex items-center justify-between px-4 py-3 border-b border-neutral-800">
<span className="text-sm font-semibold text-white">Navigation</span> <span className="text-sm font-semibold text-white">Navigation</span>
<button onClick={onMobileClose} className="p-1.5 text-neutral-400 hover:text-neutral-300"> <button onClick={handleMobileClose} className="p-1.5 text-neutral-400 hover:text-neutral-300">
<XIcon className="w-5 h-5" /> <XIcon className="w-5 h-5" />
</button> </button>
</div> </div>
@@ -532,7 +574,7 @@ export default function Sidebar({
canEdit={canEdit} canEdit={canEdit}
pendingHref={pendingHref} pendingHref={pendingHref}
onNavigate={handleNavigate} onNavigate={handleNavigate}
onMobileClose={onMobileClose} onMobileClose={handleMobileClose}
onExpand={expand} onExpand={expand}
onCollapse={collapse} onCollapse={collapse}
onToggle={toggle} onToggle={toggle}