ui: portal-based dropdown (no clipping in scroll containers, viewport-aware flip/clamp, close on scroll)
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Portal 化下拉菜单:渲染到 body + fixed 定位,不再被表格/滚动容器裁剪;
|
||||
// 视口边缘自动上翻/收拢;任何滚动即关闭。
|
||||
export function Dropdown({ trigger, children, align = 'end', className }: {
|
||||
trigger: ReactNode;
|
||||
children: ReactNode | ((close: () => void) => ReactNode);
|
||||
@@ -8,35 +11,65 @@ export function Dropdown({ trigger, children, align = 'end', className }: {
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const openMenu = () => {
|
||||
const r = triggerRef.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
setPos({ top: r.bottom + 4, left: align === 'end' ? r.right - 176 : r.left });
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
// 渲染后实测菜单尺寸:越界上翻、水平收拢
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !pos) return;
|
||||
const m = menuRef.current;
|
||||
const t = triggerRef.current?.getBoundingClientRect();
|
||||
if (!m || !t) return;
|
||||
const rect = m.getBoundingClientRect();
|
||||
let top = pos.top;
|
||||
if (top + rect.height > window.innerHeight - 8) top = Math.max(8, t.top - rect.height - 4);
|
||||
let left = Math.min(Math.max(8, pos.left), window.innerWidth - rect.width - 8);
|
||||
if (top !== pos.top || left !== pos.left) setPos({ top, left });
|
||||
}, [open, pos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
const t = e.target as Node;
|
||||
if (triggerRef.current?.contains(t) || menuRef.current?.contains(t)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||
const onScroll = () => setOpen(false);
|
||||
const onResize = () => setOpen(false);
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<div onClick={() => setOpen(v => !v)}>{trigger}</div>
|
||||
{open && (
|
||||
<div ref={triggerRef}>
|
||||
<div onClick={openMenu}>{trigger}</div>
|
||||
{open && pos && createPortal(
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-40 mt-1 min-w-[160px] rounded-md border border-line bg-surface py-1 shadow-pop',
|
||||
align === 'end' ? 'right-0' : 'left-0',
|
||||
className
|
||||
)}
|
||||
ref={menuRef}
|
||||
className={cn('fixed z-[60] min-w-[160px] rounded-md border border-line bg-surface py-1 shadow-pop', className)}
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
role="menu"
|
||||
>
|
||||
{typeof children === 'function' ? children(() => setOpen(false)) : children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user