diff --git a/apps/web/src/components/ui/Drawer.test.tsx b/apps/web/src/components/ui/Drawer.test.tsx new file mode 100644 index 000000000..b5888898c --- /dev/null +++ b/apps/web/src/components/ui/Drawer.test.tsx @@ -0,0 +1,238 @@ +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('react-dom', async () => { + const actual = await vi.importActual('react-dom'); + return { + ...actual, + createPortal: (children: unknown) => children, + }; +}); + +vi.mock('./Drawer.module.scss', () => ({ + default: { + overlay: 'overlay', + overlayClosing: 'overlayClosing', + overlayEntering: 'overlayEntering', + panel: 'panel', + panelClosing: 'panelClosing', + panelEntering: 'panelEntering', + header: 'header', + title: 'title', + closeButton: 'closeButton', + body: 'body', + footer: 'footer', + }, +})); + +vi.mock('./icons', () => ({ + IconX: () => null, +})); + +import { Drawer } from './Drawer'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const CLOSE_ANIMATION_DURATION = 280; + +const createPointerEvent = ( + target: unknown, + currentTarget: unknown, + options: { button?: number; pointerId?: number } = {} +) => ({ + target, + currentTarget, + button: options.button ?? 0, + pointerId: options.pointerId ?? 1, +}); + +const installMinimalDom = () => { + const previousHTMLElement = Object.getOwnPropertyDescriptor(globalThis, 'HTMLElement'); + const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const installedWindow = typeof globalThis.window === 'undefined'; + + const bodyStyle = { overflow: '' }; + const htmlStyle = { overflow: '' }; + const listeners = new Map>(); + + class HTMLElementMock {} + + const documentMock = { + body: { style: bodyStyle }, + documentElement: { style: htmlStyle }, + activeElement: null as unknown, + addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => { + const set = listeners.get(type) ?? new Set(); + set.add(listener); + listeners.set(type, set); + }, + removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => { + listeners.get(type)?.delete(listener); + }, + }; + + Object.defineProperty(globalThis, 'HTMLElement', { + configurable: true, + writable: true, + value: HTMLElementMock, + }); + + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: documentMock, + }); + + if (installedWindow) { + Object.defineProperty(globalThis, 'window', { + configurable: true, + writable: true, + value: globalThis, + }); + } + + return () => { + if (previousHTMLElement) { + Object.defineProperty(globalThis, 'HTMLElement', previousHTMLElement); + } else { + Reflect.deleteProperty(globalThis, 'HTMLElement'); + } + + if (previousDocument) { + Object.defineProperty(globalThis, 'document', previousDocument); + } else { + Reflect.deleteProperty(globalThis, 'document'); + } + + if (installedWindow) { + if (previousWindow) { + Object.defineProperty(globalThis, 'window', previousWindow); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } + } + }; +}; + +describe('Drawer overlay close guard', () => { + let renderer: ReactTestRenderer | undefined; + let restoreDom: (() => void) | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + restoreDom = installMinimalDom(); + }); + + afterEach(() => { + act(() => { + renderer?.unmount(); + }); + renderer = undefined; + restoreDom?.(); + restoreDom = undefined; + vi.useRealTimers(); + }); + + const mountDrawer = async (onClose: () => void) => { + await act(async () => { + renderer = create( + + + + ); + }); + + // open effect 通过 queueMicrotask 切换可见态 + await act(async () => { + await Promise.resolve(); + }); + + return renderer!; + }; + + const findOverlay = (root: ReactTestRenderer) => + root.root.find((node) => String(node.props.className ?? '').includes('overlay')); + + it('closes when pointer starts and ends on overlay', async () => { + const onClose = vi.fn(); + const mounted = await mountDrawer(onClose); + const overlay = findOverlay(mounted); + + await act(async () => { + overlay.props.onPointerDown(createPointerEvent(overlay, overlay, { pointerId: 1 })); + overlay.props.onPointerUp(createPointerEvent(overlay, overlay, { pointerId: 1 })); + }); + + expect(onClose).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(CLOSE_ANIMATION_DURATION); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not close when pointer starts inside drawer and ends on overlay', async () => { + const onClose = vi.fn(); + const mounted = await mountDrawer(onClose); + const overlay = findOverlay(mounted); + const panel = mounted.root.findByProps({ role: 'dialog' }); + + await act(async () => { + // 模拟:在面板内按下,在遮罩上释放(拖选文字场景) + overlay.props.onPointerDown(createPointerEvent(panel, overlay, { pointerId: 1 })); + overlay.props.onPointerUp(createPointerEvent(overlay, overlay, { pointerId: 1 })); + }); + + await act(async () => { + vi.advanceTimersByTime(CLOSE_ANIMATION_DURATION); + }); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it('does not close when pointer starts on overlay and ends inside drawer', async () => { + const onClose = vi.fn(); + const mounted = await mountDrawer(onClose); + const overlay = findOverlay(mounted); + const panel = mounted.root.findByProps({ role: 'dialog' }); + + await act(async () => { + overlay.props.onPointerDown(createPointerEvent(overlay, overlay, { pointerId: 1 })); + overlay.props.onPointerUp(createPointerEvent(panel, overlay, { pointerId: 1 })); + }); + + await act(async () => { + vi.advanceTimersByTime(CLOSE_ANIMATION_DURATION); + }); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it('does not close when interleaved pointers mix panel and overlay starts', async () => { + const onClose = vi.fn(); + const mounted = await mountDrawer(onClose); + const overlay = findOverlay(mounted); + const panel = mounted.root.findByProps({ role: 'dialog' }); + + await act(async () => { + // 指针 1:面板内按下;指针 2:遮罩上按下;释放指针 1 在遮罩上 → 不应关闭 + overlay.props.onPointerDown(createPointerEvent(panel, overlay, { pointerId: 1 })); + overlay.props.onPointerDown(createPointerEvent(overlay, overlay, { pointerId: 2 })); + overlay.props.onPointerUp(createPointerEvent(overlay, overlay, { pointerId: 1 })); + }); + + await act(async () => { + vi.advanceTimersByTime(CLOSE_ANIMATION_DURATION); + }); + + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/ui/Drawer.tsx b/apps/web/src/components/ui/Drawer.tsx index 796818094..1b2ae4814 100644 --- a/apps/web/src/components/ui/Drawer.tsx +++ b/apps/web/src/components/ui/Drawer.tsx @@ -4,6 +4,7 @@ import { useId, useRef, useState, + type PointerEvent as ReactPointerEvent, type PropsWithChildren, type ReactNode, } from 'react'; @@ -63,6 +64,7 @@ export function Drawer({ const closeTimerRef = useRef | null>(null); const panelRef = useRef(null); const previouslyFocusedRef = useRef(null); + const overlayPointerIdsRef = useRef>(new Set()); const startClose = useCallback( (notifyParent: boolean) => { @@ -117,6 +119,35 @@ export function Drawer({ startClose(true); }, [startClose]); + // 按 pointerId 配对:仅当同一指针在遮罩上按下且在遮罩上释放时才关闭。 + // 避免「面板内拖选到遮罩释放」与多指针交错状态互相覆盖。 + const handleOverlayPointerDown = useCallback((event: ReactPointerEvent) => { + if (event.target === event.currentTarget && event.button === 0) { + overlayPointerIdsRef.current.add(event.pointerId); + } else { + overlayPointerIdsRef.current.delete(event.pointerId); + } + }, []); + + const handleOverlayPointerUp = useCallback( + (event: ReactPointerEvent) => { + const startedOnOverlay = overlayPointerIdsRef.current.delete(event.pointerId); + + if ( + startedOnOverlay && + event.target === event.currentTarget && + event.button === 0 + ) { + handleClose(); + } + }, + [handleClose] + ); + + const handleOverlayPointerCancel = useCallback((event: ReactPointerEvent) => { + overlayPointerIdsRef.current.delete(event.pointerId); + }, []); + const shouldLockScroll = open || isVisible; useEffect(() => { @@ -173,7 +204,12 @@ export function Drawer({ .join(' '); const drawerContent = ( -
+