From 6c7598a858d08cb39955d3f4d992d969bb90904f Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 12 May 2026 14:28:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(a11y):=20SelectLite=20=E9=94=AE=E7=9B=98?= =?UTF-8?q?=E9=AB=98=E4=BA=AE=E8=87=AA=E5=8A=A8=E6=BB=9A=E5=8A=A8=20+=20po?= =?UTF-8?q?pover=20=E6=A8=AA=E5=90=91=20viewport=20clamp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr-agent #419 第二轮 review 指出的两个 focus areas: 1. Keyboard scroll:长 dropdown 超过 280px 时 ArrowUp/Down 切换 highlight 不会把高亮项 scroll into view,键盘用户看不到当前选中。新增 useEffect 监听 highlight 改变,找到 data-option-index 对应 DOM 元素后 scrollIntoView({ block: 'nearest' }),已可见的项不会被强行滚动。 2. Viewport clipping:popover 用 trigger.left 作 anchor,没有横向边界 clamp。窗口右边的 select 弹出后可能溢出屏幕。positionPopover 改成测 真实 popover 宽度(首次开打用 trigger 宽度估),然后把 left clamp 到 [8, viewport - width - 8] 区间。 不动 keyboard 导航 / disabled 处理 / vertical flip 等已 verified 行为。 --- .../app/src/components/ui/SelectLite.tsx | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/openless-all/app/src/components/ui/SelectLite.tsx b/openless-all/app/src/components/ui/SelectLite.tsx index 2bdfa124..9b683b9f 100644 --- a/openless-all/app/src/components/ui/SelectLite.tsx +++ b/openless-all/app/src/components/ui/SelectLite.tsx @@ -80,12 +80,18 @@ export function SelectLite({ const trigger = triggerRef.current; if (!trigger) return; const rect = trigger.getBoundingClientRect(); - // 默认在触发器下方;若下方空间放不下 popover(按 maxHeight 280 估),翻转向上以避免被视口裁剪。 - const popoverMaxHeight = popoverRef.current?.getBoundingClientRect().height ?? 280; + const popoverRect = popoverRef.current?.getBoundingClientRect(); + const popoverHeight = popoverRect?.height ?? 280; + const popoverWidth = popoverRect?.width ?? rect.width; + // 纵向:默认在触发器下方;若下方空间放不下 popover,翻转向上以避免被视口裁剪。 const spaceBelow = window.innerHeight - rect.bottom; - const flipUp = spaceBelow < popoverMaxHeight + 8 && rect.top > popoverMaxHeight + 8; - const top = flipUp ? rect.top - popoverMaxHeight - 4 : rect.bottom + 4; - setAnchor({ left: rect.left, top, width: rect.width }); + 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] 区间。 + 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(() => { @@ -100,6 +106,17 @@ export function SelectLite({ }; }, [open, positionPopover]); + // 键盘 ArrowUp/Down 改 highlight 后把高亮项 scroll into view —— 长 dropdown 超过 + // maxHeight 280 时键盘用户能看到当前高亮。{ block: 'nearest' } 避免把已经可见的项 + // 强行滚到顶部,符合 listbox 滚动惯例。 + useEffect(() => { + if (!open || highlight < 0) return; + const target = popoverRef.current?.querySelector( + `[data-option-index="${highlight}"]`, + ) as HTMLElement | null; + target?.scrollIntoView({ block: 'nearest' }); + }, [highlight, open]); + useEffect(() => { if (!open) return; const handlePointerDown = (event: MouseEvent) => { @@ -238,6 +255,7 @@ export function SelectLite({ return (