diff --git a/src/useEscKeyDown.ts b/src/useEscKeyDown.ts index 31b2cb9..fc99020 100644 --- a/src/useEscKeyDown.ts +++ b/src/useEscKeyDown.ts @@ -1,42 +1,93 @@ +import { useEvent } from '@rc-component/util'; +import useId from '@rc-component/util/lib/hooks/useId'; import { useEffect, useMemo } from 'react'; import { type EscCallback } from './Portal'; -import useId from '@rc-component/util/lib/hooks/useId'; -import { useEvent } from '@rc-component/util'; -export let stack: string[] = []; // export for testing +let stack: { id: string; onEsc?: EscCallback }[] = []; + +const IME_LOCK_DURATION = 200; +let lastCompositionEndTime = 0; + +// Export for testing +export const _test = + process.env.NODE_ENV === 'test' + ? () => ({ + stack, + reset: () => { + // Not reset stack to ensure effect will clean up correctly + lastCompositionEndTime = 0; + }, + }) + : null; + +// Global event handlers +const onGlobalKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !event.isComposing) { + const now = Date.now(); + if (now - lastCompositionEndTime < IME_LOCK_DURATION) { + return; + } + + const len = stack.length; + for (let i = len - 1; i >= 0; i -= 1) { + stack[i].onEsc({ + top: i === len - 1, + event, + }); + } + } +}; + +const onGlobalCompositionEnd = () => { + lastCompositionEndTime = Date.now(); +}; + +function attachGlobalEventListeners() { + window.addEventListener('keydown', onGlobalKeyDown); + window.addEventListener('compositionend', onGlobalCompositionEnd); +} + +function detachGlobalEventListeners() { + if (stack.length === 0) { + window.removeEventListener('keydown', onGlobalKeyDown); + window.removeEventListener('compositionend', onGlobalCompositionEnd); + } +} export default function useEscKeyDown(open: boolean, onEsc?: EscCallback) { const id = useId(); - const handleEscKeyDown = useEvent((event: KeyboardEvent) => { - if (event.key === 'Escape' && !event.isComposing) { - const top = stack[stack.length - 1] === id; - onEsc?.({ top, event }); + const onEventEsc = useEvent(onEsc); + + const ensure = () => { + if (!stack.find(item => item.id === id)) { + stack.push({ id, onEsc: onEventEsc }); } - }); + }; + + const clear = () => { + stack = stack.filter(item => item.id !== id); + }; useMemo(() => { - if (open && !stack.includes(id)) { - stack.push(id); + if (open) { + ensure(); } else if (!open) { - stack = stack.filter(item => item !== id); + clear(); } - }, [open, id]); + }, [open]); useEffect(() => { - if (open && !stack.includes(id)) { - stack.push(id); - } + if (open) { + ensure(); + // Attach global event listeners + attachGlobalEventListeners(); - if (!open) { - return; + return () => { + clear(); + // Remove global event listeners if instances is empty + detachGlobalEventListeners(); + }; } - - window.addEventListener('keydown', handleEscKeyDown); - - return () => { - stack = stack.filter(item => item !== id); - window.removeEventListener('keydown', handleEscKeyDown); - }; - }, [open, id]); + }, [open]); } diff --git a/tests/index.test.tsx b/tests/index.test.tsx index db50b20..bc2b3a0 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -1,7 +1,7 @@ -import { render, fireEvent } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; import React from 'react'; import Portal from '../src'; -import { stack } from '../src/useEscKeyDown'; +import { _test } from '../src/useEscKeyDown'; global.isOverflow = true; @@ -298,6 +298,16 @@ describe('Portal', () => { }); describe('onEsc', () => { + beforeEach(() => { + _test().reset(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + it('only last opened portal is top', () => { const onEscA = jest.fn(); const onEscB = jest.fn(); @@ -370,6 +380,31 @@ describe('Portal', () => { expect(onEsc).not.toHaveBeenCalled(); }); + it('should not trigger onEsc within IME lock duration after compositionend', () => { + const onEsc = jest.fn(); + + render( + + + , + ); + + fireEvent.compositionEnd(window); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onEsc).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(100); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onEsc).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(150); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onEsc).toHaveBeenCalledWith( + expect.objectContaining({ top: true }), + ); + }); + it('should clear stack when unmount', () => { const { unmount } = render( @@ -377,28 +412,30 @@ describe('Portal', () => { , ); - expect(stack).toHaveLength(1); + expect(_test().stack).toHaveLength(1); unmount(); - expect(stack).toHaveLength(0); + expect(_test().stack).toHaveLength(0); }); it('onEsc should treat first mounted portal as top in StrictMode', () => { const onEsc = jest.fn(); - + const Demo = ({ visible }: { visible: boolean }) => visible ? (
) : null; - + render(, { wrapper: React.StrictMode }); - - expect(stack).toHaveLength(1); - + + expect(_test().stack).toHaveLength(1); + fireEvent.keyDown(window, { key: 'Escape' }); - - expect(onEsc).toHaveBeenCalledWith(expect.objectContaining({ top: true })); + + expect(onEsc).toHaveBeenCalledWith( + expect.objectContaining({ top: true }), + ); }); it('nested portals should trigger in correct order', () => { @@ -415,15 +452,20 @@ describe('Portal', () => {
- + , ); fireEvent.keyDown(window, { key: 'Escape' }); - expect(onEsc).toHaveBeenCalledWith(expect.objectContaining({ top: false })); - expect(onEsc2).toHaveBeenCalledWith(expect.objectContaining({ top: false })); - expect(onEsc3).toHaveBeenCalledWith(expect.objectContaining({ top: true })); + expect(onEsc).toHaveBeenCalledWith( + expect.objectContaining({ top: false }), + ); + expect(onEsc2).toHaveBeenCalledWith( + expect.objectContaining({ top: false }), + ); + expect(onEsc3).toHaveBeenCalledWith( + expect.objectContaining({ top: true }), + ); }); - }); });