feat(ui): premium polish — route transitions, context menu, slider, sidebar tooltips

Route transitions:
- New PageTransition component with framer-motion fade+slide (0.2s)
- Layout wraps children in AnimatePresence mode="wait" for smooth page changes

Context menu (new component):
- Right-click menu with portal rendering and viewport clamping
- Keyboard navigation (arrows, Home/End, Enter/Space, Escape)
- framer-motion scale+fade animation matching dropdown aesthetic
- ARIA compliant (role=menu/menuitem), destructive/disabled item support

Sidebar polish:
- Replaced browser-native title tooltips with custom Tooltip component
- position="right" tooltips on collapsed nav items, Settings, Sign Out
- Tooltips auto-disabled when sidebar is expanded

Slider — Spotify-style hover:
- Track thins to h-1 by default, expands to h-1.5 on hover
- Thumb hidden by default, scales in on hover (group-hover)
- Filled portion gains subtle primary glow on hover

Toast enhancements:
- New ToastAction interface with label + onClick
- Action button renders below message, auto-dismisses on click
- Enables "Undo" / "View" patterns

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
senke 2026-02-09 23:10:10 +01:00
parent c5e072abf1
commit bd68fa9c7f
8 changed files with 520 additions and 71 deletions

View file

@ -3,11 +3,17 @@ import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Card } from '@/components/ui/card';
export interface ToastAction {
label: string;
onClick: () => void;
}
export interface Toast {
id: string;
message: string;
type?: 'success' | 'error' | 'warning' | 'info';
duration?: number;
action?: ToastAction;
}
export interface ToastProps {
@ -79,8 +85,19 @@ export function ToastComponent({ toast, onDismiss }: ToastProps) {
aria-live="polite"
>
<Icon className="h-5 w-5 flex-shrink-0 animate-[pulse-ring_2s_infinite]" />
<div className="flex-1">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-relaxed font-sans">{toast.message}</p>
{toast.action && (
<button
onClick={() => {
toast.action?.onClick();
handleDismiss();
}}
className="mt-1.5 text-xs font-semibold underline underline-offset-2 hover:no-underline transition-colors"
>
{toast.action.label}
</button>
)}
</div>
<button
onClick={handleDismiss}

View file

@ -1,6 +1,8 @@
import { ReactNode } from 'react';
import { AnimatePresence } from 'framer-motion';
import { Header } from './Header';
import { Sidebar } from './Sidebar';
import { PageTransition } from './PageTransition';
import { useUIStore } from '@/stores/ui';
import { cn } from '@/lib/utils';
import { AstralBackground } from '../ui/AstralBackground';
@ -27,8 +29,12 @@ export function Layout({ children }: LayoutProps) {
sidebarOpen ? 'lg:ml-64' : 'ml-0',
)}
>
<div className="max-w-layout-content mx-auto p-4 sm:p-6 lg:p-8 animate-fadeIn">
{children}
<div className="max-w-layout-content mx-auto p-4 sm:p-6 lg:p-8">
<AnimatePresence mode="wait">
<PageTransition>
{children}
</PageTransition>
</AnimatePresence>
</div>
</main>
</div>

View file

@ -0,0 +1,26 @@
import { motion } from 'framer-motion';
import { useLocation } from 'react-router-dom';
import { ReactNode } from 'react';
interface PageTransitionProps {
children: ReactNode;
}
export function PageTransition({ children }: PageTransitionProps) {
const location = useLocation();
return (
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{
duration: 0.2,
ease: [0.25, 0.1, 0.25, 1],
}}
>
{children}
</motion.div>
);
}

View file

@ -11,6 +11,7 @@ import { useAuthStore } from '@/features/auth/store/authStore';
import { useUIStore } from '@/stores/ui';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Tooltip } from '@/components/ui/tooltip';
interface SidebarProps {
currentView?: string;
@ -154,49 +155,49 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onNavigate, onLog
const isActive = activeView === item.id;
return (
<Link
key={item.id}
to={route}
onClick={() => {
handleMobileNav();
onNavigate?.(item.id);
}}
className={cn(
'w-full flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-[var(--duration-fast)] group relative',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background',
isActive
? 'bg-primary/10 text-primary sidebar-active-indicator'
: 'text-muted-foreground hover:text-foreground hover:bg-sidebar-accent',
!sidebarOpen && "justify-center px-0"
)}
title={!sidebarOpen ? item.label : undefined}
>
<div className={cn("flex items-center gap-3 relative z-10 min-w-0", !sidebarOpen && "justify-center")}>
<span className={cn(
'shrink-0 transition-colors duration-[var(--duration-fast)]',
isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'
)}>
{item.icon}
</span>
<Tooltip key={item.id} content={item.label} position="right" disabled={sidebarOpen}>
<Link
to={route}
onClick={() => {
handleMobileNav();
onNavigate?.(item.id);
}}
className={cn(
'w-full flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-[var(--duration-fast)] group relative',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background',
isActive
? 'bg-primary/10 text-primary sidebar-active-indicator'
: 'text-muted-foreground hover:text-foreground hover:bg-sidebar-accent',
!sidebarOpen && "justify-center px-0"
)}
>
<div className={cn("flex items-center gap-3 relative z-10 min-w-0", !sidebarOpen && "justify-center")}>
<span className={cn(
'shrink-0 transition-colors duration-[var(--duration-fast)]',
isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'
)}>
{item.icon}
</span>
<span className={cn(
"transition-all duration-[var(--duration-normal)] whitespace-nowrap truncate",
sidebarOpen ? "opacity-100" : "w-0 opacity-0 overflow-hidden"
)}>
{item.label}
</span>
</div>
<span className={cn(
"transition-all duration-[var(--duration-normal)] whitespace-nowrap truncate",
sidebarOpen ? "opacity-100" : "w-0 opacity-0 overflow-hidden"
)}>
{item.label}
</span>
</div>
{item.badge && sidebarOpen && (
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">
{item.badge}
</span>
)}
{item.badge && sidebarOpen && (
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">
{item.badge}
</span>
)}
{item.badge && !sidebarOpen && (
<span className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-primary" />
)}
</Link>
{item.badge && !sidebarOpen && (
<span className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-primary" />
)}
</Link>
</Tooltip>
);
})}
</div>
@ -206,31 +207,35 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onNavigate, onLog
{/* Footer */}
<div className="p-2 border-t border-sidebar-border">
<Link
to="/settings"
onClick={() => { handleMobileNav(); onNavigate?.('settings'); }}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors duration-[var(--duration-fast)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background',
activeView === 'settings' ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:text-foreground hover:bg-sidebar-accent',
!sidebarOpen && "justify-center px-0"
)}
>
<Settings className="w-4 h-4 shrink-0" />
<span className={cn("truncate", sidebarOpen ? "opacity-100" : "w-0 opacity-0 overflow-hidden")}>Settings</span>
</Link>
<Tooltip content="Settings" position="right" disabled={sidebarOpen}>
<Link
to="/settings"
onClick={() => { handleMobileNav(); onNavigate?.('settings'); }}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors duration-[var(--duration-fast)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background',
activeView === 'settings' ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:text-foreground hover:bg-sidebar-accent',
!sidebarOpen && "justify-center px-0"
)}
>
<Settings className="w-4 h-4 shrink-0" />
<span className={cn("truncate", sidebarOpen ? "opacity-100" : "w-0 opacity-0 overflow-hidden")}>Settings</span>
</Link>
</Tooltip>
<Button
variant="ghost"
onClick={handleLogout}
className={cn(
"w-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 mt-0.5 gap-3 justify-start rounded-lg",
!sidebarOpen && "justify-center px-0"
)}
>
<LogOut className="w-4 h-4 shrink-0" />
<span className={cn("whitespace-nowrap", sidebarOpen ? "opacity-100" : "w-0 opacity-0 overflow-hidden")}>Sign Out</span>
</Button>
<Tooltip content="Sign Out" position="right" disabled={sidebarOpen}>
<Button
variant="ghost"
onClick={handleLogout}
className={cn(
"w-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 mt-0.5 gap-3 justify-start rounded-lg",
!sidebarOpen && "justify-center px-0"
)}
>
<LogOut className="w-4 h-4 shrink-0" />
<span className={cn("whitespace-nowrap", sidebarOpen ? "opacity-100" : "w-0 opacity-0 overflow-hidden")}>Sign Out</span>
</Button>
</Tooltip>
</div>
</aside>
</>

View file

@ -0,0 +1,358 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '@/lib/utils';
import type {
ContextMenuProps,
ContextMenuItem as ContextMenuItemType,
ContextMenuEntry,
} from './types';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function isSeparator(
entry: ContextMenuEntry,
): entry is { type: 'separator' } {
return 'type' in entry && entry.type === 'separator';
}
function isMenuItem(entry: ContextMenuEntry): entry is ContextMenuItemType {
return !isSeparator(entry);
}
/** Clamp menu position so it never overflows the viewport. */
function clampPosition(
x: number,
y: number,
menuWidth: number,
menuHeight: number,
padding = 8,
) {
const clampedX =
x + menuWidth + padding > window.innerWidth
? window.innerWidth - menuWidth - padding
: x;
const clampedY =
y + menuHeight + padding > window.innerHeight
? window.innerHeight - menuHeight - padding
: y;
return {
x: Math.max(padding, clampedX),
y: Math.max(padding, clampedY),
};
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function Separator() {
return <div className="h-px bg-border my-1" role="separator" />;
}
interface MenuItemProps {
item: ContextMenuItemType;
focused: boolean;
onSelect: (item: ContextMenuItemType) => void;
onHover: () => void;
}
function MenuItem({ item, focused, onSelect, onHover }: MenuItemProps) {
return (
<button
type="button"
role="menuitem"
aria-disabled={item.disabled || undefined}
disabled={item.disabled}
tabIndex={focused ? 0 : -1}
className={cn(
'flex items-center gap-3 px-3 py-2 text-sm rounded-lg cursor-pointer',
'transition-colors duration-150 w-full text-left select-none outline-none',
'focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset',
item.destructive
? 'text-destructive hover:bg-destructive/10 focus-visible:bg-destructive/10'
: 'text-foreground hover:bg-accent focus-visible:bg-accent',
item.disabled && 'opacity-50 cursor-not-allowed',
focused && !item.disabled && (item.destructive ? 'bg-destructive/10' : 'bg-accent'),
)}
onClick={() => {
if (!item.disabled) onSelect(item);
}}
onMouseEnter={onHover}
>
{item.icon && (
<span className="flex-shrink-0 size-4">{item.icon}</span>
)}
<span className="truncate">{item.label}</span>
{item.shortcut && (
<span className="ml-auto text-xs text-muted-foreground tracking-wide">
{item.shortcut}
</span>
)}
</button>
);
}
// ---------------------------------------------------------------------------
// ContextMenu
// ---------------------------------------------------------------------------
export function ContextMenu({
items,
children,
disabled = false,
className,
}: ContextMenuProps) {
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const menuRef = useRef<HTMLDivElement>(null);
const focusedIndexRef = useRef(-1);
const [focusedIndex, setFocusedIndex] = useState(-1);
// Gather actionable (non-separator) item indices for keyboard navigation
const actionableIndices = items.reduce<number[]>((acc, entry, i) => {
if (isMenuItem(entry) && !entry.disabled) acc.push(i);
return acc;
}, []);
// ------ Open / Close helpers ------
const close = useCallback(() => {
setOpen(false);
setFocusedIndex(-1);
focusedIndexRef.current = -1;
}, []);
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
if (disabled) return;
e.preventDefault();
e.stopPropagation();
setPosition({ x: e.clientX, y: e.clientY });
setOpen(true);
},
[disabled],
);
// ------ Viewport edge clamping after render ------
useEffect(() => {
if (!open || !menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
const clamped = clampPosition(
position.x,
position.y,
rect.width,
rect.height,
);
if (clamped.x !== position.x || clamped.y !== position.y) {
setPosition(clamped);
}
}, [open, position.x, position.y]);
// ------ Auto-focus first actionable item ------
useEffect(() => {
if (!open) return;
const firstIdx = actionableIndices[0] ?? -1;
focusedIndexRef.current = firstIdx;
setFocusedIndex(firstIdx);
// Wait one frame for the portal to mount
requestAnimationFrame(() => {
if (menuRef.current && firstIdx >= 0) {
const buttons = menuRef.current.querySelectorAll<HTMLElement>(
'button[role="menuitem"]:not([disabled])',
);
buttons[0]?.focus();
}
});
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
// ------ Click-outside ------
useEffect(() => {
if (!open) return;
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
close();
}
};
// Use capture so we fire before anything else
document.addEventListener('mousedown', handleClickOutside, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside, true);
};
}, [open, close]);
// ------ Keyboard navigation ------
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (!menuRef.current) return;
const buttons = menuRef.current.querySelectorAll<HTMLElement>(
'button[role="menuitem"]:not([disabled])',
);
const count = buttons.length;
if (count === 0) return;
// Map focused item-index to button-index
const currentButtonIdx = Array.from(buttons).findIndex(
(btn) => btn === document.activeElement,
);
switch (e.key) {
case 'Escape': {
e.preventDefault();
close();
break;
}
case 'ArrowDown': {
e.preventDefault();
const next =
currentButtonIdx < count - 1 ? currentButtonIdx + 1 : 0;
buttons[next]?.focus();
// Sync the focusedIndex with the item index
const nextItemIdx = actionableIndices[next];
if (nextItemIdx !== undefined) {
focusedIndexRef.current = nextItemIdx;
setFocusedIndex(nextItemIdx);
}
break;
}
case 'ArrowUp': {
e.preventDefault();
const prev =
currentButtonIdx > 0 ? currentButtonIdx - 1 : count - 1;
buttons[prev]?.focus();
const prevItemIdx = actionableIndices[prev];
if (prevItemIdx !== undefined) {
focusedIndexRef.current = prevItemIdx;
setFocusedIndex(prevItemIdx);
}
break;
}
case 'Home': {
e.preventDefault();
buttons[0]?.focus();
const homeIdx = actionableIndices[0];
if (homeIdx !== undefined) {
focusedIndexRef.current = homeIdx;
setFocusedIndex(homeIdx);
}
break;
}
case 'End': {
e.preventDefault();
buttons[count - 1]?.focus();
const endIdx = actionableIndices[count - 1];
if (endIdx !== undefined) {
focusedIndexRef.current = endIdx;
setFocusedIndex(endIdx);
}
break;
}
case 'Enter':
case ' ': {
e.preventDefault();
if (document.activeElement instanceof HTMLElement) {
document.activeElement.click();
}
break;
}
default:
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [open, close, actionableIndices]);
// ------ Item selection ------
const handleSelect = useCallback(
(item: ContextMenuItemType) => {
item.onClick?.();
close();
},
[close],
);
// ------ Render ------
return (
<>
<div
onContextMenu={handleContextMenu}
className={className}
style={{ display: 'contents' }}
>
{children}
</div>
{createPortal(
<AnimatePresence>
{open && (
<motion.div
ref={menuRef}
role="menu"
aria-orientation="vertical"
className={cn(
'fixed z-50 min-w-48 p-1',
'bg-popover/95 backdrop-blur-md border border-border shadow-lg rounded-xl',
'outline-none',
)}
style={{ top: position.y, left: position.x }}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.12 }}
>
{items.map((entry, index) => {
if (isSeparator(entry)) {
return <Separator key={`sep-${index}`} />;
}
return (
<MenuItem
key={entry.id}
item={entry}
focused={focusedIndex === index}
onSelect={handleSelect}
onHover={() => {
focusedIndexRef.current = index;
setFocusedIndex(index);
}}
/>
);
})}
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
</>
);
}

View file

@ -0,0 +1,7 @@
export { ContextMenu } from './ContextMenu';
export type {
ContextMenuItem,
ContextMenuSeparator,
ContextMenuEntry,
ContextMenuProps,
} from './types';

View file

@ -0,0 +1,29 @@
import type React from 'react';
export interface ContextMenuItem {
id: string;
label: string;
icon?: React.ReactNode;
shortcut?: string;
disabled?: boolean;
destructive?: boolean;
onClick?: () => void;
}
export interface ContextMenuSeparator {
type: 'separator';
}
export interface ContextMenuGroup {
label?: string;
items: (ContextMenuItem | ContextMenuSeparator)[];
}
export type ContextMenuEntry = ContextMenuItem | ContextMenuSeparator;
export interface ContextMenuProps {
items: ContextMenuEntry[];
children: React.ReactNode;
disabled?: boolean;
className?: string;
}

View file

@ -125,13 +125,13 @@ const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
return (
<div
className={cn(
'relative flex w-full touch-none select-none items-center',
'group relative flex w-full touch-none select-none items-center',
className,
)}
>
<div className="relative h-2 w-full grow overflow-hidden rounded-full bg-muted">
<div className="relative h-1 group-hover:h-1.5 w-full grow overflow-hidden rounded-full bg-muted transition-all duration-150">
<div
className="absolute h-full bg-primary transition-all duration-[var(--duration-fast)] shadow-slider-thumb"
className="absolute h-full bg-primary transition-all duration-[var(--duration-fast)] shadow-slider-thumb group-hover:shadow-[0_0_8px_var(--primary)]"
style={{ width: `${percentage}%` }}
/>
</div>
@ -155,7 +155,8 @@ const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
/>
<div
className={cn(
'absolute h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-transform duration-[var(--duration-fast)] pointer-events-none shadow-slider-thumb',
'absolute h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background pointer-events-none shadow-slider-thumb',
'scale-0 opacity-0 group-hover:scale-100 group-hover:opacity-100 transition-all duration-150',
disabled && 'opacity-50',
)}
style={{ left: `calc(${percentage}% - 10px)` }}