Qassure-frontend/src/components/shared/ActionDropdown.tsx

228 lines
7.9 KiB
TypeScript

import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import type { ReactElement } from 'react';
import { MoreVertical, Eye, Edit, Trash2, Users, BarChart3 } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface ActionItem {
label: string;
onClick: () => void | Promise<void>;
icon?: React.ReactNode;
variant?: 'danger' | 'default';
}
interface ActionDropdownProps {
onView?: () => void;
onEdit?: () => void;
onDelete?: () => void;
onContacts?: () => void;
onScorecards?: () => void;
actions?: ActionItem[];
trigger?: React.ReactNode;
className?: string;
}
export const ActionDropdown = ({
onView,
onEdit,
onDelete,
onContacts,
onScorecards,
actions,
trigger,
className,
}: ActionDropdownProps): ReactElement => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [dropdownStyle, setDropdownStyle] = useState<{ top?: string; bottom?: string; right: string; width: string }>({ right: '0', width: '0' });
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
dropdownMenuRef.current &&
!dropdownMenuRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
const handleScroll = (event: Event) => {
// Don't close if scrolling inside the dropdown menu itself
const target = event.target as HTMLElement;
if (dropdownMenuRef.current && (dropdownMenuRef.current === target || dropdownMenuRef.current.contains(target))) {
return;
}
setIsOpen(false);
};
if (isOpen && buttonRef.current) {
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('scroll', handleScroll, true);
// Calculate position when dropdown opens
const rect = buttonRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const dropdownHeight = actions ? actions.length * 32 + 16 : 120; // Approximate height based on actions
// Determine if should open upward or downward
const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
// Calculate dropdown position
const right = window.innerWidth - rect.right;
const width = actions ? 140 : 76; // Wider for custom actions
if (shouldOpenUp) {
// Position above the button
const bottom = window.innerHeight - rect.top;
setDropdownStyle({
bottom: `${bottom}px`,
right: `${right}px`,
width: `${width}px`,
});
} else {
// Position below the button
const top = rect.bottom;
setDropdownStyle({
top: `${top}px`,
right: `${right}px`,
width: `${width}px`,
});
}
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('scroll', handleScroll, true);
};
}, [isOpen, actions]);
const handleAction = (action?: () => void | Promise<void>) => {
if (action) {
void Promise.resolve(action()).catch(console.error);
}
setIsOpen(false);
};
return (
<div className={cn('relative', className)} ref={dropdownRef}>
{trigger ? (
React.cloneElement(trigger as React.ReactElement<any>, {
ref: buttonRef,
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setIsOpen(!isOpen);
const triggerProps = (trigger as React.ReactElement<any>).props;
if (triggerProps.onClick) triggerProps.onClick(e);
},
})
) : (
<button
ref={buttonRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
'flex items-center justify-center w-7 h-7 rounded-full border border-[rgba(0,0,0,0.08)] transition-colors cursor-pointer',
isOpen
? 'bg-[#084cc8] text-white'
: 'bg-white text-[#0f1724] hover:bg-[#084cc8] hover:text-white'
)}
aria-label="Actions"
aria-expanded={isOpen}
>
<MoreVertical className="w-3.5 h-3.5" />
</button>
)}
{isOpen && buttonRef.current && createPortal(
<div
ref={dropdownMenuRef}
data-dropdown-menu="true"
className="fixed bg-white border border-[rgba(0,0,0,0.2)] rounded-md shadow-[0px_4px_4px_0px_rgba(0,0,0,0.08)] z-[250] overflow-hidden"
style={dropdownStyle}
>
<div className="flex flex-col py-1.5">
{actions ? (
actions.map((action, index) => (
<button
key={index}
type="button"
onClick={() => handleAction(action.onClick)}
className={cn(
"flex items-center gap-2.5 px-3 py-2 text-[11px] font-medium transition-colors cursor-pointer w-full text-left",
action.variant === 'danger'
? "text-red-600 hover:bg-red-50"
: "text-[#6b7280] hover:bg-gray-50"
)}
>
{action.icon}
<span>{action.label}</span>
</button>
))
) : (
<>
{onView && (
<button
type="button"
onClick={() => handleAction(onView)}
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Eye className="w-3.5 h-3.5" />
<span>View</span>
</button>
)}
{onEdit && (
<button
type="button"
onClick={() => handleAction(onEdit)}
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Edit className="w-3 h-3" />
<span>Edit</span>
</button>
)}
{onDelete && (
<button
type="button"
onClick={() => handleAction(onDelete)}
className="flex items-center gap-2 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Delete</span>
</button>
)}
{onContacts && (
<button
type="button"
onClick={() => handleAction(onContacts)}
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Users className="w-3.5 h-3.5" />
<span>Contacts</span>
</button>
)}
{onScorecards && (
<button
type="button"
onClick={() => handleAction(onScorecards)}
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
>
<BarChart3 className="w-3.5 h-3.5" />
<span>Scorecards</span>
</button>
)}
</>
)}
</div>
</div>,
document.body
)}
</div>
);
};