mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
216 lines
5.6 KiB
TypeScript
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"
|
|
/>
|
|
);
|
|
};
|
|
|