fix(dropdown): 展开层改 Portal + fixed 定位,逃出弹窗 overflow 裁剪

弹窗(.modal 带 overflow-y:auto)里的下拉展开层是 absolute 定位,
会被弹窗滚动盒裁掉下半截(Git 同步设置里实测被遮挡)。展开层改经
Portal 挂到 body、按触发钮 rect 用 fixed 定位:下方空间不足时
向上翻,maxHeight 按可用空间夹取;窗口 resize 时关闭。
useDismiss 泛化为可传多个 ref(触发器与 Portal 菜单两棵子树都算内)。
CSS 相应去掉 .dropdown-menu 的 absolute 定位(几何改由内联给出)。
验证:vitest 124+2(新增翻转几何断言:贴底触发钮翻上展开、
Portal 落点不在组件容器内);无头 Chromium 实测弹窗内下拉菜单
完整落在视口内、8 项全显、已逃出弹窗 DOM。
This commit is contained in:
Muse
2026-11-14 20:17:51 +08:00
parent 4fca4b7143
commit eb27e4ca2d
4 changed files with 98 additions and 14 deletions

View File

@@ -1,11 +1,21 @@
// 自绘下拉(替代原生 select:原生展开层是系统样式,与应用风格脱节)。 // 自绘下拉(替代原生 select:原生展开层是系统样式,与应用风格脱节)。
// 外观对齐 .input;展开层沿用应用菜单语言(面板底、6px 圆角、悬停 --hover、 // 外观对齐 .input;展开层沿用应用菜单语言(面板底、6px 圆角、悬停 --hover、
// 选中 --accent-weak);点别处/Esc 关闭走统一的 useDismiss;支持上下键选行。 // 选中 --accent-weak);点别处/Esc 关闭走统一的 useDismiss;支持上下键选行。
import { useCallback, useEffect, useRef, useState } from 'react' // 展开层经 Portal 挂到 body 并用 fixed 定位(按触发钮 rect 计算、下方空间
// 不足时向上翻):弹窗等带 overflow 的容器里 absolute 定位会被滚动盒裁掉
// (Git 同步设置里实测被遮挡),Portal + fixed 才能真正逃出裁剪。
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useDismiss } from '../hooks/useDismiss' import { useDismiss } from '../hooks/useDismiss'
export interface DropdownOption { value: string; label: string } export interface DropdownOption { value: string; label: string }
interface MenuPos { left: number; top: number; width: number; maxHeight: number }
const ITEM_H = 31
const MENU_PAD = 8
const MAX_H = 264
export function Dropdown({ value, options, onChange, disabled, ariaLabel }: { export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
value: string value: string
options: DropdownOption[] options: DropdownOption[]
@@ -14,13 +24,35 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
ariaLabel?: string ariaLabel?: string
}) { }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [pos, setPos] = useState<MenuPos | null>(null)
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const btnRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const close = useCallback(() => setOpen(false), []) const close = useCallback(() => setOpen(false), [])
useDismiss(ref, open, close) const dismissRefs = useMemo(() => [ref, menuRef], [])
useDismiss(dismissRefs, open, close)
const current = options.find((o) => o.value === value) ?? options[0] const current = options.find((o) => o.value === value) ?? options[0]
// 打开时按触发钮位置算展开层几何:优先向下,下方放不下且上方更宽裕时翻上去
const openMenu = useCallback(() => {
const rect = btnRef.current?.getBoundingClientRect()
if (!rect) { setOpen(true); return }
const est = Math.min(MAX_H, options.length * ITEM_H + MENU_PAD)
const spaceBelow = window.innerHeight - rect.bottom - 8
const spaceAbove = rect.top - 8
const above = spaceBelow < est && spaceAbove > spaceBelow
const maxHeight = Math.max(80, Math.min(MAX_H, above ? spaceAbove : spaceBelow))
const height = Math.min(est, maxHeight)
setPos({
left: Math.max(4, Math.min(rect.left, window.innerWidth - rect.width - 4)),
top: above ? Math.max(4, rect.top - height - 4) : rect.bottom + 4,
width: rect.width,
maxHeight,
})
setOpen(true)
}, [options.length])
// 打开时把焦点放到选中项,便于直接上下键选行 // 打开时把焦点放到选中项,便于直接上下键选行
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
@@ -29,6 +61,14 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open ]) }, [open ])
// 窗口尺寸变化时展开层位置失效,直接关闭(与菜单类组件惯例一致)
useEffect(() => {
if (!open) return
const onResize = () => setOpen(false)
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [open ])
const pick = (v: string) => { setOpen(false); onChange(v) } const pick = (v: string) => { setOpen(false); onChange(v) }
const onMenuKey = (e: React.KeyboardEvent) => { const onMenuKey = (e: React.KeyboardEvent) => {
@@ -46,17 +86,23 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
<div className="dropdown" ref={ref}> <div className="dropdown" ref={ref}>
<button <button
type="button" type="button"
ref={btnRef}
className="dropdown-button" className="dropdown-button"
disabled={disabled} disabled={disabled}
aria-label={ariaLabel} aria-label={ariaLabel}
aria-expanded={open} aria-expanded={open}
onClick={() => setOpen((o) => !o)} onClick={() => { if (open) setOpen(false); else openMenu() }}
> >
<span className="dropdown-label">{current?.label ?? ''}</span> <span className="dropdown-label">{current?.label ?? ''}</span>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.5 L5 6.5 L8 3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" /></svg> <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.5 L5 6.5 L8 3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button> </button>
{open && ( {open && pos && createPortal(
<div className="dropdown-menu" ref={menuRef} onKeyDown={onMenuKey}> <div
className="dropdown-menu"
ref={menuRef}
onKeyDown={onMenuKey}
style={{ position: 'fixed', left: pos.left, top: pos.top, width: pos.width, maxHeight: pos.maxHeight, zIndex: 200 }}
>
{options.map((o) => ( {options.map((o) => (
<button <button
key={o.value} key={o.value}
@@ -67,7 +113,8 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
{o.label} {o.label}
</button> </button>
))} ))}
</div> </div>,
document.body,
)} )}
</div> </div>
) )

View File

@@ -5,12 +5,22 @@ import { useEffect, type RefObject } from 'react'
* 按 Esc、窗口失焦、或菜单外发生滚动时关闭。 * 按 Esc、窗口失焦、或菜单外发生滚动时关闭。
* 指针监听用捕获阶段:先于 React 的 contextmenu 处理执行, * 指针监听用捕获阶段:先于 React 的 contextmenu 处理执行,
* 这样在另一个标签上右键时是「先关旧菜单、再开新菜单」,不会互相冲掉。 * 这样在另一个标签上右键时是「先关旧菜单、再开新菜单」,不会互相冲掉。
* refs 可传多个:菜单经 Portal 渲染到 body 时(如下拉展开层),触发器与
* 菜单是两个分离的 DOM 子树,两边都算「内」。
*/ */
export function useDismiss(ref: RefObject<HTMLElement | null>, active: boolean, onClose: () => void) { export function useDismiss(
refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],
active: boolean,
onClose: () => void,
) {
useEffect(() => { useEffect(() => {
if (!active) return if (!active) return
const outside = (target: EventTarget | null) => const list = Array.isArray(refs) ? refs : [refs]
ref.current && target instanceof Node && !ref.current.contains(target) const outside = (target: EventTarget | null) => {
if (!(target instanceof Node)) return false
const containers = list.map((r) => r.current).filter(Boolean) as HTMLElement[]
return containers.length > 0 && containers.every((el) => !el.contains(target))
}
const onPointer = (e: MouseEvent) => { if (outside(e.target)) onClose() } const onPointer = (e: MouseEvent) => { if (outside(e.target)) onClose() }
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
const onScroll = (e: Event) => { if (outside(e.target)) onClose() } const onScroll = (e: Event) => { if (outside(e.target)) onClose() }
@@ -26,5 +36,5 @@ export function useDismiss(ref: RefObject<HTMLElement | null>, active: boolean,
document.removeEventListener('scroll', onScroll, true) document.removeEventListener('scroll', onScroll, true)
window.removeEventListener('blur', onClose) window.removeEventListener('blur', onClose)
} }
}, [ref, active, onClose]) }, [refs, active, onClose])
} }

View File

@@ -358,7 +358,8 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.dropdown-button:disabled { opacity: 0.5; cursor: default; } .dropdown-button:disabled { opacity: 0.5; cursor: default; }
.dropdown-button svg { flex: none; color: var(--text-dim); } .dropdown-button svg { flex: none; color: var(--text-dim); }
.dropdown-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dropdown-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dropdown-menu { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 60; background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 10px 28px rgba(0,0,0,0.18); display: flex; flex-direction: column; max-height: 264px; overflow-y: auto; } /* 展开层几何(fixed 定位/宽/最大高)由 Dropdown 组件内联给出:Portal 到 body 逃出弹窗等 overflow 容器的裁剪 */
.dropdown-menu { background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 10px 28px rgba(0,0,0,0.18); display: flex; flex-direction: column; overflow-y: auto; }
.dropdown-menu button { border: 0; background: transparent; color: var(--text); padding: 6px 9px; border-radius: 5px; font-size: 13px; font-family: inherit; text-align: left; cursor: pointer; } .dropdown-menu button { border: 0; background: transparent; color: var(--text); padding: 6px 9px; border-radius: 5px; font-size: 13px; font-family: inherit; text-align: left; cursor: pointer; }
.dropdown-menu button:hover, .dropdown-menu button:focus-visible { background: var(--hover); outline: none; } .dropdown-menu button:hover, .dropdown-menu button:focus-visible { background: var(--hover); outline: none; }
.dropdown-menu button.selected { background: var(--accent-weak); } .dropdown-menu button.selected { background: var(--accent-weak); }

View File

@@ -22,18 +22,44 @@ describe('Dropdown', () => {
const button = container.querySelector('.dropdown-button') as HTMLButtonElement const button = container.querySelector('.dropdown-button') as HTMLButtonElement
expect(button.textContent).toContain('GitHub') expect(button.textContent).toContain('GitHub')
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
const menu = container.querySelector('.dropdown-menu') const menu = document.querySelector('.dropdown-menu')
expect(menu).not.toBeNull() expect(menu).not.toBeNull()
expect(menu?.querySelector('button.selected')?.textContent).toBe('GitHub') expect(menu?.querySelector('button.selected')?.textContent).toBe('GitHub')
const notion = [...menu!.querySelectorAll('button')].find((b) => b.textContent === 'Notion') const notion = [...menu!.querySelectorAll('button')].find((b) => b.textContent === 'Notion')
await act(async () => { notion?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) await act(async () => { notion?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(onChange).toHaveBeenCalledWith('notion') expect(onChange).toHaveBeenCalledWith('notion')
expect(container.querySelector('.dropdown-menu')).toBeNull() expect(document.querySelector('.dropdown-menu')).toBeNull()
// 再次展开,点别处关闭 // 再次展开,点别处关闭
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(container.querySelector('.dropdown-menu')).not.toBeNull() expect(document.querySelector('.dropdown-menu')).not.toBeNull()
await act(async () => { document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) }) await act(async () => { document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) })
expect(document.querySelector('.dropdown-menu')).toBeNull()
await act(async () => { root.unmount() })
container.remove()
})
it('展开层经 Portal 挂到 body;触发钮靠近窗口底部时向上翻且不溢出', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
await act(async () => {
root.render(<Dropdown value="github" options={OPTIONS} onChange={() => undefined} />)
})
const button = container.querySelector('.dropdown-button') as HTMLButtonElement
// 模拟触发钮贴近窗口底部(jsdom 默认视口高 768):下方只剩 30px
button.getBoundingClientRect = () => ({
top: 700, bottom: 730, left: 100, right: 300, width: 200, height: 30, x: 100, y: 700, toJSON: () => ({}),
}) as DOMRect
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
// Portal:菜单不在组件容器内,而是 body 直属
expect(container.querySelector('.dropdown-menu')).toBeNull() expect(container.querySelector('.dropdown-menu')).toBeNull()
const menu = document.querySelector('.dropdown-menu') as HTMLElement | null
expect(menu).not.toBeNull()
expect(menu!.style.position).toBe('fixed')
// 3 项估算高 101,下方放不下 → 翻到触发钮上方:top = 700 - 101 - 4
expect(menu!.style.top).toBe('595px')
expect(menu!.style.left).toBe('100px')
expect(menu!.style.width).toBe('200px')
await act(async () => { root.unmount() }) await act(async () => { root.unmount() })
container.remove() container.remove()
}) })