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

128 lines
3.0 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import { clsx } from 'clsx';
export interface PopoverProps {
trigger: React.ReactNode;
content: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
align?: 'start' | 'center' | 'end';
side?: 'top' | 'bottom' | 'left' | 'right';
className?: string;
triggerClassName?: string;
modal?: boolean;
}
const alignClasses = {
start: 'start-0',
center: 'left-1/2 -translate-x-1/2',
end: 'end-0',
};
const sideClasses = {
top: 'bottom-full mb-2',
bottom: 'top-full mt-2',
left: 'right-full mr-2',
right: 'left-full ml-2',
};
/**
* Popover component - contextual popup (different from Tooltip)
*/
export const Popover = ({
trigger,
content,
open: controlledOpen,
onOpenChange,
align = 'center',
side = 'bottom',
className,
triggerClassName,
modal = false,
}: PopoverProps) => {
const [internalOpen, setInternalOpen] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLDivElement>(null);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const setOpen = (value: boolean) => {
if (!isControlled) {
setInternalOpen(value);
}
onOpenChange?.(value);
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
popoverRef.current &&
triggerRef.current &&
!popoverRef.current.contains(event.target as Node) &&
!triggerRef.current.contains(event.target as Node)
) {
setOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && open) {
setOpen(false);
}
};
if (open) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [open]);
return (
<div className={clsx('relative inline-block', className)}>
<div
ref={triggerRef}
onClick={() => setOpen(!open)}
className={clsx('cursor-pointer', triggerClassName)}
role="button"
aria-haspopup="true"
aria-expanded={open}
>
{trigger}
</div>
{open && (
<>
{modal && (
<div
className="fixed inset-0 z-40"
onClick={() => setOpen(false)}
aria-hidden="true"
/>
)}
<div
ref={popoverRef}
className={clsx(
'absolute z-50',
'bg-background border rounded-lg shadow-lg',
'p-4 min-w-[200px]',
'animate-in fade-in-0 zoom-in-95 duration-200',
alignClasses[align],
sideClasses[side]
)}
role="dialog"
>
{content}
</div>
</>
)}
</div>
);
};