3,200+ hardcoded color values replaced with CSS variable-backed Tailwind classes (bg-card, text-foreground, border-border, etc.). Enables light mode via CSS variable swap. Only syntax highlighting colors and intentional one-offs remain hardcoded (~15 values). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
101 lines
2.8 KiB
TypeScript
101 lines
2.8 KiB
TypeScript
import { useState, useRef, useEffect } from 'react'
|
|
import { MoreVertical } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
interface MenuAction {
|
|
label: string
|
|
icon?: React.ComponentType<{ className?: string }>
|
|
onClick: () => void
|
|
disabled?: boolean
|
|
variant?: 'default' | 'destructive'
|
|
}
|
|
|
|
interface ActionMenuProps {
|
|
actions: MenuAction[]
|
|
align?: 'left' | 'right' // default 'right'
|
|
}
|
|
|
|
export function ActionMenu({ actions, align = 'right' }: ActionMenuProps) {
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const menuRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return
|
|
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false)
|
|
}
|
|
}
|
|
|
|
const handleEscape = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
setIsOpen(false)
|
|
}
|
|
}
|
|
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
document.addEventListener('keydown', handleEscape)
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside)
|
|
document.removeEventListener('keydown', handleEscape)
|
|
}
|
|
}, [isOpen])
|
|
|
|
const handleItemClick = (action: MenuAction) => {
|
|
if (action.disabled) return
|
|
action.onClick()
|
|
setIsOpen(false)
|
|
}
|
|
|
|
return (
|
|
<div className="relative" ref={menuRef}>
|
|
<button
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className={cn(
|
|
'rounded-md border border-border p-2 text-muted-foreground',
|
|
'hover:bg-accent hover:text-foreground'
|
|
)}
|
|
aria-label="Actions"
|
|
>
|
|
<MoreVertical className="h-4 w-4" />
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div
|
|
className={cn(
|
|
'absolute z-50 mt-1 min-w-[180px] bg-card border border-border rounded-lg p-1',
|
|
align === 'right' ? 'right-0' : 'left-0'
|
|
)}
|
|
>
|
|
{actions.map((action, index) => {
|
|
const Icon = action.icon
|
|
return (
|
|
<button
|
|
key={index}
|
|
onClick={() => handleItemClick(action)}
|
|
disabled={action.disabled}
|
|
className={cn(
|
|
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm',
|
|
action.disabled
|
|
? 'cursor-not-allowed opacity-40'
|
|
: action.variant === 'destructive'
|
|
? 'text-red-400 hover:bg-accent hover:text-red-300'
|
|
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
)}
|
|
>
|
|
{Icon && <Icon className="h-4 w-4" />}
|
|
{action.label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export type { MenuAction, ActionMenuProps }
|
|
|
|
export default ActionMenu
|