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(
+