fix(SelectLite): 位置错位 + 加关闭动画 + 滚外部自动收缩

用户在 macOS 上反馈 LLM provider 下拉 3 个问题:
1. 位置完全错位(popover 跟 trigger 不对齐)
2. 没有关闭动画
3. 在 popover 外滚动应自动收缩,但当前会跟着 trigger 重定位

[位置错位根因]
positionPopover useCallback deps [],useLayoutEffect 在 open=true 时 fire
一次 —— 此时 popoverRef.current 还没挂载,popoverWidth 用 trigger.width 兜底。
Popover mount 后真实宽度 ≠ trigger 宽(长选项撑大),但 effect 不再重算。

修法:popoverRef 改用 callback ref,每次 popover DOM mount 时 RAF 推到下一帧
重算 anchor,拿到真实 popover 宽。同时 positionPopover 用 max(trigger 宽,
popoverRect 宽) 保证 popoverWidth 不低估。

[滚动外部 → 关闭]
之前 useLayoutEffect 监听 scroll 事件做 reposition。改成监听 'scroll' + 'wheel'
capture phase,target 在 popover 内(popoverRef.contains)保留打开(长列表
内部 scroll 不关),target 在外部 → closeMenu。同时 window resize 也强制关闭。

[关闭动画]
加 leaving state 让 popover 在 unmount 前播 ol-select-pop-out 反向 keyframe
(opacity 1→0 + translateY 0→-4 + scale 1→.98,跟入场对偶)。140ms 后真正
unmount + 清 anchor / highlight。
This commit is contained in:
baiqing committed 2026-05-12 19:02:43 +08:00
1 parent ca5cb5277a
commit 167ceb297b
2 files changed
+68 -25

No files matched your search

@@ -5,12 +5,12 @@
// - 触发器是一个 button(chevron + 当前值标签),样式可被 `style` 覆盖
// - popover 用 portal 渲染到 document.body,避开父容器 overflow:hidden
// - 键盘:ArrowDown/ArrowUp 切换高亮,Enter 确认,Esc 关闭
// - 点击外部 / 滚动 / resize 都会关闭或重定位
// - 点击外部 / 滚动外部容器都会关闭(popover 内部 scroll 不关闭)
// - 关闭有 .14s exit 动画;mount 时 callback ref + RAF 二次定位防 first-paint 错位
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -55,6 +55,8 @@ const DEFAULT_TRIGGER_STYLE: CSSProperties = {
minWidth: 160,
};
const EXIT_ANIM_MS = 140;
export function SelectLite({
value,
onChange,
@@ -65,9 +67,11 @@ export function SelectLite({
ariaLabel,
}: SelectLiteProps) {
const [open, setOpen] = useState(false);
// leaving 让 popover 在卸载前播完 exit keyframe(用户报"没有收缩动画"——之前直接 unmount)
const [leaving, setLeaving] = useState(false);
const [highlight, setHighlight] = useState<number>(-1);
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const [anchor, setAnchor] = useState<{ left: number; top: number; width: number } | null>(null);
const selected = useMemo(
@@ -82,33 +86,35 @@ export function SelectLite({
const rect = trigger.getBoundingClientRect();
const popoverRect = popoverRef.current?.getBoundingClientRect();
const popoverHeight = popoverRect?.height ?? 280;
const popoverWidth = popoverRect?.width ?? rect.width;
// 纵向:默认在触发器下方;若下方空间放不下 popover,翻转向上以避免被视口裁剪。
// popover 宽度优先用真实测量值(>= trigger 宽),fallback 才用 trigger 宽。
const popoverWidth = Math.max(popoverRect?.width ?? 0, rect.width);
// 纵向:默认在触发器下方;若下方空间放不下 popover,翻转向上避免被视口裁剪。
const spaceBelow = window.innerHeight - rect.bottom;
const flipUp = spaceBelow < popoverHeight + 8 && rect.top > popoverHeight + 8;
const top = flipUp ? rect.top - popoverHeight - 4 : rect.bottom + 4;
// 横向:在窗口右边的 select 可能让 popover 溢出屏幕;clamp 到 [8, viewport - width - 8] 区间。
// 横向:窗口右边的 select 可能让 popover 溢出屏幕;clamp 到 [8, viewport-width-8]。
const minLeft = 8;
const maxLeft = Math.max(minLeft, window.innerWidth - popoverWidth - 8);
const left = Math.min(Math.max(rect.left, minLeft), maxLeft);
setAnchor({ left, top, width: rect.width });
}, []);
useLayoutEffect(() => {
if (!open) return;
positionPopover();
const handleReflow = () => positionPopover();
window.addEventListener('resize', handleReflow);
window.addEventListener('scroll', handleReflow, true);
return () => {
window.removeEventListener('resize', handleReflow);
window.removeEventListener('scroll', handleReflow, true);
};
}, [open, positionPopover]);
// popover ref callback:每次 popover DOM mount/unmount 调一次。
// 关键:mount 时拿到真实 popover 宽(content 撑大),requestAnimationFrame
// 推到下一帧 paint 前再重算 anchor —— 修复"first paint 用 trigger 宽 fallback 后
// popover 位置漂掉"的 bug。
const setPopoverRef = useCallback(
(node: HTMLDivElement | null) => {
popoverRef.current = node;
if (node) {
requestAnimationFrame(() => positionPopover());
}
},
[positionPopover],
);
// 键盘 ArrowUp/Down 改 highlight 后把高亮项 scroll into view —— 长 dropdown 超过
// maxHeight 280 时键盘用户能看到当前高亮。{ block: 'nearest' } 避免把已经可见的项
// 强行滚到顶部,符合 listbox 滚动惯例。
// maxHeight 280 时键盘用户能看到当前高亮。
useEffect(() => {
if (!open || highlight < 0) return;
const target = popoverRef.current?.querySelector(
@@ -117,6 +123,7 @@ export function SelectLite({
target?.scrollIntoView({ block: 'nearest' });
}, [highlight, open]);
// 点击外部 / 滚动外部 → 关闭。popover 内部 scroll 保持打开。
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: MouseEvent) => {
@@ -124,22 +131,49 @@ export function SelectLite({
if (!target) return;
if (triggerRef.current?.contains(target)) return;
if (popoverRef.current?.contains(target)) return;
setOpen(false);
closeMenu();
};
// 用户在 popover 外部任何位置滚动(wheel 或 scroll 事件)→ 关闭。
// popover 内部滚动(长列表 scroll)popover.contains(target) → 保留打开。
const handleScrollOutside = (event: Event) => {
const target = event.target as Node | null;
if (target && popoverRef.current?.contains(target)) return;
closeMenu();
};
// window resize 强制关闭:重算位置成本高且大多数 resize 表明 user 不再想看 popover。
const handleResize = () => closeMenu();
document.addEventListener('mousedown', handlePointerDown);
return () => document.removeEventListener('mousedown', handlePointerDown);
window.addEventListener('scroll', handleScrollOutside, { capture: true, passive: true });
window.addEventListener('wheel', handleScrollOutside, { capture: true, passive: true });
window.addEventListener('resize', handleResize);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
window.removeEventListener('scroll', handleScrollOutside, true);
window.removeEventListener('wheel', handleScrollOutside, true);
window.removeEventListener('resize', handleResize);
};
// closeMenu 是稳定引用(无 React state 依赖),不放 deps。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const openMenu = () => {
if (disabled) return;
const initial = options.findIndex(opt => opt.value === value && !opt.disabled);
setHighlight(initial >= 0 ? initial : options.findIndex(opt => !opt.disabled));
setLeaving(false);
setOpen(true);
};
const closeMenu = () => {
setOpen(false);
setHighlight(-1);
if (!open) return;
setLeaving(true);
window.setTimeout(() => {
setOpen(false);
setLeaving(false);
setHighlight(-1);
setAnchor(null);
}, EXIT_ANIM_MS);
};
const selectIndex = (index: number) => {
@@ -227,7 +261,7 @@ export function SelectLite({
</button>
{open && anchor && createPortal(
<div
ref={popoverRef}
ref={setPopoverRef}
role="listbox"
style={{
position: 'fixed',
@@ -246,7 +280,10 @@ export function SelectLite({
zIndex: 9999,
fontFamily: 'inherit',
fontSize: 12.5,
animation: 'ol-select-pop .14s var(--ol-motion-quick) both',
animation: leaving
? 'ol-select-pop-out .14s cubic-bezier(.4,.0,.7,.2) forwards'
: 'ol-select-pop .14s var(--ol-motion-quick) both',
transformOrigin: 'top center',
}}
>
{options.map((option, index) => {
+6
View File
@@ -43,6 +43,12 @@ button {
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* SelectLite popover 关闭动画 —— 反向收缩 + 淡出 + 上移,跟入场对偶。 */
@keyframes ol-select-pop-out {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(-4px) scale(.98); }
}
/* 键盘焦点指示:鼠标点击 (:focus) 不显示,键盘 Tab (:focus-visible) 才显示。
用 box-shadow 而非 outline 是因为组件内联 style 可能用 outline:none
把默认 ring 干掉,box-shadow 不受影响仍能上色。配合 pr-agent #407 的 a11y 要求。 */