Files
claudecodeui/src/shared/view/ui/ActionMenu.tsx
2026-06-30 23:29:53 +00:00

190 lines
5.9 KiB
TypeScript

import * as React from 'react';
import { ChevronDown, Loader2, type LucideIcon } from 'lucide-react';
import { cn } from '../../../lib/utils';
import { Button } from './Button';
type ButtonVariant = 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
type ButtonSize = 'default' | 'sm' | 'lg' | 'icon';
export type ActionMenuItem = {
key: string;
label: string;
description?: string;
icon?: LucideIcon;
onSelect: () => void;
disabled?: boolean;
loading?: boolean;
isDanger?: boolean;
showDividerBefore?: boolean;
};
type ActionMenuProps = {
label: string;
items: ActionMenuItem[];
icon?: LucideIcon;
ariaLabel?: string;
align?: 'left' | 'right';
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
triggerClassName?: string;
disabled?: boolean;
};
export default function ActionMenu({
label,
items,
icon: TriggerIcon,
ariaLabel,
align = 'right',
variant = 'outline',
size = 'sm',
className,
triggerClassName,
disabled,
}: ActionMenuProps) {
const [isOpen, setIsOpen] = React.useState(false);
const rootRef = React.useRef<HTMLDivElement | null>(null);
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
const menuRef = React.useRef<HTMLDivElement | null>(null);
// Whether closing should move focus back to the trigger. Set for keyboard
// (Escape) and item selection, but left false for outside pointer clicks so
// focus is not stolen from wherever the user clicked.
const restoreFocusRef = React.useRef(false);
const wasOpenRef = React.useRef(false);
const menuId = React.useId();
React.useEffect(() => {
if (!isOpen) {
return;
}
const closeOnOutsideClick = (event: MouseEvent) => {
const target = event.target as Node;
if (rootRef.current && !rootRef.current.contains(target)) {
setIsOpen(false);
}
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
restoreFocusRef.current = true;
setIsOpen(false);
}
};
document.addEventListener('mousedown', closeOnOutsideClick);
document.addEventListener('keydown', closeOnEscape);
return () => {
document.removeEventListener('mousedown', closeOnOutsideClick);
document.removeEventListener('keydown', closeOnEscape);
};
}, [isOpen]);
// Move focus into the menu on open and back to the trigger on a keyboard or
// selection close, so keyboard and screen-reader navigation match the menu role.
React.useEffect(() => {
if (isOpen) {
wasOpenRef.current = true;
const menu = menuRef.current;
const firstItem = menu?.querySelector<HTMLButtonElement>('[role="menuitem"]:not([disabled])');
(firstItem ?? menu)?.focus();
return;
}
if (wasOpenRef.current) {
wasOpenRef.current = false;
if (restoreFocusRef.current) {
triggerRef.current?.focus();
}
restoreFocusRef.current = false;
}
}, [isOpen]);
const runItem = (item: ActionMenuItem) => {
if (item.disabled || item.loading) {
return;
}
restoreFocusRef.current = true;
setIsOpen(false);
item.onSelect();
};
return (
<div ref={rootRef} className={cn('relative inline-flex', className)}>
<Button
ref={triggerRef}
type="button"
variant={variant}
size={size}
className={triggerClassName}
disabled={disabled}
aria-label={ariaLabel || label}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls={isOpen ? menuId : undefined}
onClick={() => setIsOpen((current) => !current)}
>
{TriggerIcon && <TriggerIcon className="h-4 w-4" />}
<span>{label}</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', isOpen && 'rotate-180')} />
</Button>
{isOpen && (
<div
ref={menuRef}
id={menuId}
role="menu"
tabIndex={-1}
className={cn(
'absolute top-full z-50 mt-2 min-w-[220px] rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg',
'animate-in fade-in-0 zoom-in-95',
align === 'right' ? 'right-0' : 'left-0',
)}
>
{items.map((item) => {
const Icon = item.icon;
return (
<React.Fragment key={item.key}>
{item.showDividerBefore && <div className="mx-2 my-1 h-px bg-border" />}
<button
type="button"
role="menuitem"
disabled={item.disabled || item.loading}
onClick={() => runItem(item)}
className={cn(
'flex w-full items-start gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors',
'focus:bg-accent focus:outline-none',
item.disabled || item.loading
? 'cursor-not-allowed opacity-50'
: item.isDanger
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950'
: 'hover:bg-accent',
)}
>
{item.loading ? (
<Loader2 className="mt-0.5 h-4 w-4 flex-shrink-0 animate-spin" />
) : (
Icon && <Icon className="mt-0.5 h-4 w-4 flex-shrink-0" />
)}
<span className="min-w-0 flex-1">
<span className="block font-medium leading-5">{item.label}</span>
{item.description && (
<span className="mt-0.5 block text-xs leading-4 text-muted-foreground">
{item.description}
</span>
)}
</span>
</button>
</React.Fragment>
);
})}
</div>
)}
</div>
);
}