diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx index 1aa92f5..1d6a8a0 100644 --- a/src/components/Dropdown.tsx +++ b/src/components/Dropdown.tsx @@ -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(null) const ref = useRef(null) + const btnRef = useRef(null) const menuRef = useRef(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 }: {
- {open && ( -
+ {open && pos && createPortal( +
{options.map((o) => (
+
, + document.body, )}
) diff --git a/src/hooks/useDismiss.ts b/src/hooks/useDismiss.ts index a18a2aa..6ca9bba 100644 --- a/src/hooks/useDismiss.ts +++ b/src/hooks/useDismiss.ts @@ -5,12 +5,22 @@ import { useEffect, type RefObject } from 'react' * 按 Esc、窗口失焦、或菜单外发生滚动时关闭。 * 指针监听用捕获阶段:先于 React 的 contextmenu 处理执行, * 这样在另一个标签上右键时是「先关旧菜单、再开新菜单」,不会互相冲掉。 + * refs 可传多个:菜单经 Portal 渲染到 body 时(如下拉展开层),触发器与 + * 菜单是两个分离的 DOM 子树,两边都算「内」。 */ -export function useDismiss(ref: RefObject, active: boolean, onClose: () => void) { +export function useDismiss( + refs: RefObject | RefObject[], + 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, active: boolean, document.removeEventListener('scroll', onScroll, true) window.removeEventListener('blur', onClose) } - }, [ref, active, onClose]) + }, [refs, active, onClose]) } diff --git a/src/styles.css b/src/styles.css index 50f1d18..13bcc57 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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); } diff --git a/tests/dropdown.test.tsx b/tests/dropdown.test.tsx index 81aa0f8..2928ed2 100644 --- a/tests/dropdown.test.tsx +++ b/tests/dropdown.test.tsx @@ -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( 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() })