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:
parent
c5e072abf1
commit
bd68fa9c7f
8 changed files with 520 additions and 71 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
26
apps/web/src/components/layout/PageTransition.tsx
Normal file
26
apps/web/src/components/layout/PageTransition.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
358
apps/web/src/components/ui/context-menu/ContextMenu.tsx
Normal file
358
apps/web/src/components/ui/context-menu/ContextMenu.tsx
Normal 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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
7
apps/web/src/components/ui/context-menu/index.ts
Normal file
7
apps/web/src/components/ui/context-menu/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export { ContextMenu } from './ContextMenu';
|
||||
export type {
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuEntry,
|
||||
ContextMenuProps,
|
||||
} from './types';
|
||||
29
apps/web/src/components/ui/context-menu/types.ts
Normal file
29
apps/web/src/components/ui/context-menu/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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)` }}
|
||||
|
|
|
|||
Loading…
Reference in a new issue