From 167ceb297b8a33eaa3b651ebf5133aa00b15d60d Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 12 May 2026 19:02:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(SelectLite):=20=E4=BD=8D=E7=BD=AE=E9=94=99?= =?UTF-8?q?=E4=BD=8D=20+=20=E5=8A=A0=E5=85=B3=E9=97=AD=E5=8A=A8=E7=94=BB?= =?UTF-8?q?=20+=20=E6=BB=9A=E5=A4=96=E9=83=A8=E8=87=AA=E5=8A=A8=E6=94=B6?= =?UTF-8?q?=E7=BC=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户在 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。 --- .../app/src/components/ui/SelectLite.tsx | 87 +++++++++++++------ openless-all/app/src/styles/global.css | 6 ++ 2 files changed, 68 insertions(+), 25 deletions(-) diff --git a/openless-all/app/src/components/ui/SelectLite.tsx b/openless-all/app/src/components/ui/SelectLite.tsx index 9b683b9f..38b312b9 100644 --- a/openless-all/app/src/components/ui/SelectLite.tsx +++ b/openless-all/app/src/components/ui/SelectLite.tsx @@ -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(-1); const triggerRef = useRef(null); - const popoverRef = useRef(null); + const popoverRef = useRef(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({ {open && anchor && createPortal(
{options.map((option, index) => { diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index e564d0ce..8cc79c58 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -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 要求。 */