turash/bugulma/frontend/components/ui/DropdownMenu.tsx

216 lines
5.6 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import { clsx } from 'clsx';
import { ChevronDown } from 'lucide-react';
export interface DropdownMenuItem {
label: string;
value: string;
icon?: React.ReactNode;
disabled?: boolean;
divider?: boolean;
onClick?: () => void;
}
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="flex-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 flex-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"
/>
);
};