diff --git a/src/Preview/index.tsx b/src/Preview/index.tsx index 05594fc..845be9a 100644 --- a/src/Preview/index.tsx +++ b/src/Preview/index.tsx @@ -327,10 +327,25 @@ const Preview: React.FC = props => { } }; + const escClosingRef = useRef(false); + const openRef = useRef(open); + openRef.current = open; + // >>>>> Effect: Keyboard const onKeyDown = useEvent((event: KeyboardEvent) => { - if (open) { - const { keyCode } = event; + if (openRef.current) { + const { keyCode, key } = event; + + if (keyCode === KeyCode.ESC || key === 'Escape') { + escClosingRef.current = true; + openRef.current = false; + event.preventDefault(); + if (keyCode === KeyCode.ESC) { + event.stopPropagation(); + } + onClose?.(); + return; + } if (showLeftOrRightSwitches) { if (keyCode === KeyCode.LEFT) { @@ -344,10 +359,10 @@ const Preview: React.FC = props => { useEffect(() => { if (open) { - window.addEventListener('keydown', onKeyDown); + window.addEventListener('keydown', onKeyDown, true); return () => { - window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keydown', onKeyDown, true); }; } }, [open]); @@ -364,6 +379,7 @@ const Preview: React.FC = props => { const onVisibleChanged = (nextVisible: boolean) => { if (!nextVisible) { setLockScroll(false); + escClosingRef.current = false; } afterOpenChange?.(nextVisible); }; @@ -377,7 +393,7 @@ const Preview: React.FC = props => { }, [open]); const onEsc: PortalProps['onEsc'] = ({ top }) => { - if (top) { + if (top && !escClosingRef.current) { onClose?.(); } }; diff --git a/tests/preview.portal.test.tsx b/tests/preview.portal.test.tsx new file mode 100644 index 0000000..54aec53 --- /dev/null +++ b/tests/preview.portal.test.tsx @@ -0,0 +1,91 @@ +import { act, fireEvent, render } from '@testing-library/react'; +import React from 'react'; + +jest.mock('@rc-component/motion', () => { + const MockCSSMotion = ({ children }: any) => children({ className: '', style: {} }); + return { + __esModule: true, + default: MockCSSMotion, + }; +}); + +jest.mock('@rc-component/portal', () => { + const React = require('react'); + + const MockPortal = (props: any) => { + (global as any).__portalProps = props; + return <>{props.children}; + }; + + return { + __esModule: true, + default: MockPortal, + }; +}); + +import Preview from '../src/Preview'; + +describe('Preview portal esc fallback', () => { + it('uses capture phase for window keydown listener', () => { + const addSpy = jest.spyOn(window, 'addEventListener'); + const removeSpy = jest.spyOn(window, 'removeEventListener'); + + const { unmount } = render( + , + ); + + expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true); + + unmount(); + expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true); + + addSpy.mockRestore(); + removeSpy.mockRestore(); + }); + + it('keeps portal onEsc as fallback', () => { + const onClose = jest.fn(); + + render( + , + ); + + act(() => { + (global as any).__portalProps.onEsc({ top: true }); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('avoids duplicate close when keydown esc already handled (key only)', () => { + const onClose = jest.fn(); + + render( + , + ); + + fireEvent.keyDown(window, { key: 'Escape' }); + + act(() => { + (global as any).__portalProps.onEsc({ top: true }); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('avoids duplicate close when keydown esc already handled (keyCode only)', () => { + const onClose = jest.fn(); + + render( + , + ); + + fireEvent.keyDown(window, { keyCode: 27 }); + + act(() => { + (global as any).__portalProps.onEsc({ top: true }); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +});