弹窗(.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。
67 lines
3.3 KiB
TypeScript
67 lines
3.3 KiB
TypeScript
// 自绘下拉回归:展开/选中/点别处关闭(替代原生 select)。
|
||
import { describe, it, expect, vi } from 'vitest'
|
||
import { createRoot } from 'react-dom/client'
|
||
import { act } from 'react-dom/test-utils'
|
||
import { Dropdown } from '../src/components/Dropdown'
|
||
|
||
const OPTIONS = [
|
||
{ value: '', label: '跟随全局主题' },
|
||
{ value: 'github', label: 'GitHub' },
|
||
{ value: 'notion', label: 'Notion' },
|
||
]
|
||
|
||
describe('Dropdown', () => {
|
||
it('展开列出选项且标出选中项;点击选项回调并关闭;点别处关闭', async () => {
|
||
const container = document.createElement('div')
|
||
document.body.appendChild(container)
|
||
const root = createRoot(container)
|
||
const onChange = vi.fn()
|
||
await act(async () => {
|
||
root.render(<Dropdown value="github" options={OPTIONS} onChange={onChange} />)
|
||
})
|
||
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 = 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(document.querySelector('.dropdown-menu')).toBeNull()
|
||
// 再次展开,点别处关闭
|
||
await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||
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()
|
||
})
|
||
})
|