turash/bugulma/frontend/components/ui/DropdownMenu.tsx
2025-12-15 10:06:41 +01:00

303 lines
7.8 KiB
TypeScript

import { clsx } from 'clsx';
import { ChevronDown } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
export interface DropdownMenuItem {
label: string;
value: string;
icon?: React.ReactNode;
disabled?: boolean;
divider?: boolean;
onClick?: () => void;
}
/**
* Composable Dropdown Menu Components
* These are alternative exports for a more flexible API
*/
export const DropdownMenuTrigger = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { children: React.ReactNode }
>(({ children, className, ...props }, ref) => {
return (
<div
ref={ref}
className={clsx('cursor-pointer', className)}
role="button"
aria-haspopup="true"
{...props}
>
{children}
</div>
);
});
DropdownMenuTrigger.displayName = 'DropdownMenuTrigger';
export const DropdownMenuContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
children: React.ReactNode;
align?: 'left' | 'right' | 'center' | 'start' | 'end';
className?: string;
}
>(({ children, align = 'left', className, ...props }, ref) => {
const alignClasses = {
left: 'left-0',
right: 'right-0',
center: 'left-1/2 -translate-x-1/2',
start: 'left-0',
end: 'right-0',
};
return (
<div
ref={ref}
className={clsx(
'absolute z-50 min-w-[200px]',
'bg-background border rounded-lg shadow-lg',
'py-1',
'animate-in fade-in-0 zoom-in-95 duration-200',
alignClasses[align],
'top-full mt-2',
className
)}
role="menu"
{...props}
>
{children}
</div>
);
});
DropdownMenuContent.displayName = 'DropdownMenuContent';
export const DropdownMenuItemComponent = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement> & {
children: React.ReactNode;
disabled?: boolean;
className?: string;
}
>(({ children, disabled, className, ...props }, ref) => {
return (
<button
ref={ref}
type="button"
disabled={disabled}
className={clsx(
'w-full px-4 py-2 text-left text-sm',
'flex items-center gap-2',
'hover:bg-muted focus:bg-muted',
'focus:outline-none transition-colors',
{
'opacity-50 cursor-not-allowed': disabled,
'text-foreground': !disabled,
},
className
)}
role="menuitem"
{...props}
>
{children}
</button>
);
});
DropdownMenuItemComponent.displayName = 'DropdownMenuItem';
export interface DropdownMenuProps {
trigger: React.ReactNode;
items: DropdownMenuItem[];
onSelect?: (value: string) => void;
align?: 'left' | 'right' | 'center';
position?: 'top' | 'bottom';
className?: string;
triggerClassName?: string;
}
/**
* Dropdown menu component
*/
export const DropdownMenu = ({
trigger,
items,
onSelect,
align = 'left',
position = 'bottom',
className,
triggerClassName,
}: DropdownMenuProps) => {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
menuRef.current &&
triggerRef.current &&
!menuRef.current.contains(event.target as Node) &&
!triggerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
const alignClasses = {
left: 'left-0',
right: 'right-0',
center: 'left-1/2 -translate-x-1/2',
};
const positionClasses = {
top: 'bottom-full mb-2',
bottom: 'top-full mt-2',
};
const handleItemClick = (item: DropdownMenuItem) => {
if (item.disabled) return;
item.onClick?.();
onSelect?.(item.value);
setIsOpen(false);
};
return (
<div className={clsx('relative inline-block', className)}>
<div
ref={triggerRef}
onClick={() => setIsOpen(!isOpen)}
className={clsx('cursor-pointer', triggerClassName)}
role="button"
aria-haspopup="true"
aria-expanded={isOpen}
>
{trigger}
</div>
{isOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} aria-hidden="true" />
<div
ref={menuRef}
className={clsx(
'absolute z-50 min-w-[200px]',
'bg-background border rounded-lg shadow-lg',
'py-1',
'animate-in fade-in-0 zoom-in-95 duration-200',
alignClasses[align],
positionClasses[position]
)}
role="menu"
>
{items.map((item, index) => {
if (item.divider) {
return (
<div key={`divider-${index}`} className="my-1 h-px bg-border" role="separator" />
);
}
return (
<button
key={item.value}
type="button"
onClick={() => handleItemClick(item)}
disabled={item.disabled}
className={clsx(
'w-full px-4 py-2 text-left text-sm',
'flex items-center gap-2',
'hover:bg-muted focus:bg-muted',
'focus:outline-none transition-colors',
{
'opacity-50 cursor-not-allowed': item.disabled,
'text-foreground': !item.disabled,
}
)}
role="menuitem"
>
{item.icon && <span className="shrink-0">{item.icon}</span>}
<span>{item.label}</span>
</button>
);
})}
</div>
</>
)}
</div>
);
};
/**
* Simple select dropdown (alternative to native select)
*/
export interface SelectDropdownProps {
value: string;
onChange: (value: string) => void;
options: Array<{ label: string; value: string; disabled?: boolean }>;
placeholder?: string;
className?: string;
disabled?: boolean;
}
export const SelectDropdown = ({
value,
onChange,
options,
placeholder = 'Select...',
className,
disabled,
}: SelectDropdownProps) => {
const selectedOption = options.find((opt) => opt.value === value);
return (
<DropdownMenu
trigger={
<div
className={clsx(
'flex items-center justify-between gap-2',
'px-3 py-2 border rounded-md',
'bg-background text-foreground',
'hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring',
'transition-colors',
{
'opacity-50 cursor-not-allowed': disabled,
},
className
)}
>
<span className={clsx({ 'text-muted-foreground': !selectedOption })}>
{selectedOption?.label || placeholder}
</span>
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
}
items={options.map((opt) => ({
label: opt.label,
value: opt.value,
disabled: opt.disabled,
onClick: () => onChange(opt.value),
}))}
className="w-full"
triggerClassName="w-full"
/>
);
};
// Export DropdownMenuItem as a named export for consistency
export { DropdownMenuItemComponent as DropdownMenuItem };