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:
@@ -1,11 +1,21 @@
|
||||
// 自绘下拉(替代原生 select:原生展开层是系统样式,与应用风格脱节)。
|
||||
// 外观对齐 .input;展开层沿用应用菜单语言(面板底、6px 圆角、悬停 --hover、
|
||||
// 选中 --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'
|
||||
|
||||
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 }: {
|
||||
value: string
|
||||
options: DropdownOption[]
|
||||
@@ -14,13 +24,35 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
||||
ariaLabel?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState<MenuPos | null>(null)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
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 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(() => {
|
||||
if (!open) return
|
||||
@@ -29,6 +61,14 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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 onMenuKey = (e: React.KeyboardEvent) => {
|
||||
@@ -46,17 +86,23 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
||||
<div className="dropdown" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
ref={btnRef}
|
||||
className="dropdown-button"
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
onClick={() => { if (open) setOpen(false); else openMenu() }}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="dropdown-menu" ref={menuRef} onKeyDown={onMenuKey}>
|
||||
{open && pos && createPortal(
|
||||
<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) => (
|
||||
<button
|
||||
key={o.value}
|
||||
@@ -67,7 +113,8 @@ export function Dropdown({ value, options, onChange, disabled, ariaLabel }: {
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,12 +5,22 @@ import { useEffect, type RefObject } from 'react'
|
||||
* 按 Esc、窗口失焦、或菜单外发生滚动时关闭。
|
||||
* 指针监听用捕获阶段:先于 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(() => {
|
||||
if (!active) return
|
||||
const outside = (target: EventTarget | null) =>
|
||||
ref.current && target instanceof Node && !ref.current.contains(target)
|
||||
const list = Array.isArray(refs) ? refs : [refs]
|
||||
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 onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') 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)
|
||||
window.removeEventListener('blur', onClose)
|
||||
}
|
||||
}, [ref, active, onClose])
|
||||
}, [refs, active, onClose])
|
||||
}
|
||||
|
||||
@@ -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 svg { flex: none; color: var(--text-dim); }
|
||||
.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:hover, .dropdown-menu button:focus-visible { background: var(--hover); outline: none; }
|
||||
.dropdown-menu button.selected { background: var(--accent-weak); }
|
||||
|
||||
@@ -22,18 +22,44 @@ describe('Dropdown', () => {
|
||||
const button = container.querySelector('.dropdown-button') as HTMLButtonElement
|
||||
expect(button.textContent).toContain('GitHub')
|
||||
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?.querySelector('button.selected')?.textContent).toBe('GitHub')
|
||||
const notion = [...menu!.querySelectorAll('button')].find((b) => b.textContent === 'Notion')
|
||||
await act(async () => { notion?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||
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 })) })
|
||||
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 })) })
|
||||
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()
|
||||
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() })
|
||||
container.remove()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user