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:原生展开层是系统样式,与应用风格脱节)。
|
// 自绘下拉(替代原生 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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user