ui: portal-based dropdown (no clipping in scroll containers, viewport-aware flip/clamp, close on scroll)

This commit is contained in:
root
2026-10-17 10:18:42 +08:00
parent c39a4c80d5
commit 4a73fddd0c
15 changed files with 69 additions and 36 deletions
+45 -12
View File
@@ -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>
);