From 57a27f7f18847c6ef6ab7f1faafd2e45ffba8351 Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Fri, 5 Dec 2025 02:01:55 +0800 Subject: [PATCH 1/2] feat: support close popups by escape key --- src/hooks/useEscCancel.ts | 89 +++++++++++++++++++++++++++++++++++++++ src/index.tsx | 3 ++ tests/basic.test.jsx | 68 ++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 src/hooks/useEscCancel.ts diff --git a/src/hooks/useEscCancel.ts b/src/hooks/useEscCancel.ts new file mode 100644 index 00000000..6bd0e155 --- /dev/null +++ b/src/hooks/useEscCancel.ts @@ -0,0 +1,89 @@ +import useEvent from '@rc-component/util/lib/hooks/useEvent'; +import * as React from 'react'; +import { getWin } from '../util'; + +interface EscEntry { + id: string; + win: Window; + triggerOpen: (open: boolean) => void; +} + +const stackMap = new Map(); +const handlerMap = new Map void>(); + +function addEscListener(win: Window) { + if (handlerMap.has(win)) { + return; + } + + const handler = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return; + } + + const stack = stackMap.get(win); + + const top = stack[stack.length - 1]; + top.triggerOpen(false); + }; + + win.addEventListener('keydown', handler); + handlerMap.set(win, handler); +} + +function removeEscListener(win: Window) { + const handler = handlerMap.get(win); + win.removeEventListener('keydown', handler); + handlerMap.delete(win); +} + +function unregisterEscEntry(id: string, win: Window) { + const stack = stackMap.get(win); + if (!stack) { + return; + } + + const next = stack.filter((item) => item.id !== id); + + if (next.length) { + stackMap.set(win, next); + } else { + stackMap.delete(win); + removeEscListener(win); + } +} + +function registerEscEntry(entry: EscEntry) { + const { win, id } = entry; + const prev = stackMap.get(win) || []; + const next = prev.filter((item) => item.id !== id); + next.push(entry); + stackMap.set(win, next); + addEscListener(win); +} + +export default function useEscCancel( + popupId: string, + open: boolean, + popupEle: HTMLElement, + triggerOpen: (open: boolean) => void, +) { + const memoTriggerOpen = useEvent((nextOpen: boolean) => { + triggerOpen(nextOpen); + }); + + React.useEffect(() => { + if (!open || !popupEle) { + return; + } + + const win = getWin(popupEle); + registerEscEntry({ + id: popupId, + win, + triggerOpen: memoTriggerOpen, + }); + + return () => unregisterEscEntry(popupId, win); + }, [popupId, open, popupEle, memoTriggerOpen]); +} diff --git a/src/index.tsx b/src/index.tsx index 789831d4..20eeb68f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -16,6 +16,7 @@ import useAlign from './hooks/useAlign'; import useDelay from './hooks/useDelay'; import useWatch from './hooks/useWatch'; import useWinClick from './hooks/useWinClick'; +import useEscCancel from './hooks/useEscCancel'; import type { ActionType, AlignType, @@ -647,6 +648,8 @@ export function generateTrigger( triggerOpen, ); + useEscCancel(id, mergedOpen, popupEle, triggerOpen); + // ======================= Action: Hover ======================== const hoverToShow = showActions.has('hover'); const hoverToHide = hideActions.has('hover'); diff --git a/tests/basic.test.jsx b/tests/basic.test.jsx index ed320816..4ce9891a 100644 --- a/tests/basic.test.jsx +++ b/tests/basic.test.jsx @@ -1200,4 +1200,72 @@ describe('Trigger.Basic', () => { await awaitFakeTimer(); expect(isPopupHidden()).toBeTruthy(); }); + + describe('keyboard', () => { + it('esc should close popup', async () => { + const { container } = render( + trigger}> +
+ , + ); + + trigger(container, '.target'); + expect(isPopupHidden()).toBeFalsy(); + + fireEvent.keyDown(window, { key: 'Escape' }); + await awaitFakeTimer(); + expect(isPopupHidden()).toBeTruthy(); + }); + + it('esc should close nested popup from inside out', async () => { + const useIdModule = require('@rc-component/util/lib/hooks/useId'); + let seed = 0; + const useIdSpy = jest + .spyOn(useIdModule, 'default') + .mockImplementation(() => `nested-popup-${(seed += 1)}`); + + try { + const NestedPopup = () => ( + Inner Content
} + > + +
+ ); + + const { container } = render( + + + + } + > +
+ , + ); + + trigger(container, '.outer-target'); + expect(isPopupClassHidden('.outer-popup')).toBeFalsy(); + + fireEvent.click(document.querySelector('.inner-target')); + expect(isPopupClassHidden('.inner-popup')).toBeFalsy(); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(isPopupClassHidden('.inner-popup')).toBeTruthy(); + expect(isPopupClassHidden('.outer-popup')).toBeFalsy(); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(isPopupClassHidden('.outer-popup')).toBeTruthy(); + } finally { + useIdSpy.mockRestore(); + } + }); + }); }); From 881eacc50861621776f37938938b23454c77a6b4 Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Fri, 5 Dec 2025 11:47:05 +0800 Subject: [PATCH 2/2] chore: adjust --- src/UniqueProvider/index.tsx | 3 +++ src/hooks/{useEscCancel.ts => useEscKeyDown.ts} | 4 ++-- src/index.tsx | 4 ++-- tests/basic.test.jsx | 14 ++++++++++++++ tests/unique.test.tsx | 17 +++++++++++++++++ tsconfig.json | 1 + 6 files changed, 39 insertions(+), 4 deletions(-) rename src/hooks/{useEscCancel.ts => useEscKeyDown.ts} (95%) diff --git a/src/UniqueProvider/index.tsx b/src/UniqueProvider/index.tsx index 32d8ee46..07f91363 100644 --- a/src/UniqueProvider/index.tsx +++ b/src/UniqueProvider/index.tsx @@ -15,6 +15,7 @@ import { isDOM } from '@rc-component/util/lib/Dom/findDOMNode'; import UniqueContainer from './UniqueContainer'; import { clsx } from 'clsx'; import { getAlignPopupClassName } from '../util'; +import useEscKeyDown from '../hooks/useEscKeyDown'; export interface UniqueProviderProps { children: React.ReactNode; @@ -91,6 +92,8 @@ const UniqueProvider = ({ onTargetVisibleChanged(visible); }); + useEscKeyDown(mergedOptions?.id, open, popupEle, () => trigger(false)); + // =========================== Align ============================ const [ ready, diff --git a/src/hooks/useEscCancel.ts b/src/hooks/useEscKeyDown.ts similarity index 95% rename from src/hooks/useEscCancel.ts rename to src/hooks/useEscKeyDown.ts index 6bd0e155..5e6fe767 100644 --- a/src/hooks/useEscCancel.ts +++ b/src/hooks/useEscKeyDown.ts @@ -62,7 +62,7 @@ function registerEscEntry(entry: EscEntry) { addEscListener(win); } -export default function useEscCancel( +export default function useEscKeyDown( popupId: string, open: boolean, popupEle: HTMLElement, @@ -73,7 +73,7 @@ export default function useEscCancel( }); React.useEffect(() => { - if (!open || !popupEle) { + if (!popupId || !open || !popupEle) { return; } diff --git a/src/index.tsx b/src/index.tsx index 20eeb68f..9f7f5cfc 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -16,7 +16,7 @@ import useAlign from './hooks/useAlign'; import useDelay from './hooks/useDelay'; import useWatch from './hooks/useWatch'; import useWinClick from './hooks/useWinClick'; -import useEscCancel from './hooks/useEscCancel'; +import useEscKeyDown from './hooks/useEscKeyDown'; import type { ActionType, AlignType, @@ -648,7 +648,7 @@ export function generateTrigger( triggerOpen, ); - useEscCancel(id, mergedOpen, popupEle, triggerOpen); + useEscKeyDown(id, mergedOpen, popupEle, triggerOpen); // ======================= Action: Hover ======================== const hoverToShow = showActions.has('hover'); diff --git a/tests/basic.test.jsx b/tests/basic.test.jsx index 4ce9891a..afb84031 100644 --- a/tests/basic.test.jsx +++ b/tests/basic.test.jsx @@ -1217,6 +1217,20 @@ describe('Trigger.Basic', () => { expect(isPopupHidden()).toBeTruthy(); }); + it('non-escape key should not close popup', async () => { + const { container } = render( + trigger}> +
+ , + ); + + trigger(container, '.target'); + expect(isPopupHidden()).toBeFalsy(); + + fireEvent.keyDown(window, { key: 'Enter' }); + expect(isPopupHidden()).toBeFalsy(); + }); + it('esc should close nested popup from inside out', async () => { const useIdModule = require('@rc-component/util/lib/hooks/useId'); let seed = 0; diff --git a/tests/unique.test.tsx b/tests/unique.test.tsx index bcb5063b..3be82d45 100644 --- a/tests/unique.test.tsx +++ b/tests/unique.test.tsx @@ -374,4 +374,21 @@ describe('Trigger.Unique', () => { // Verify onAlign was called due to target change expect(mockOnAlign).toHaveBeenCalled(); }); + + it('esc should close unique popup', async () => { + const { container,baseElement } = render( + + Popup
} unique> +
+ + , + ); + fireEvent.click(container.querySelector('.target')); + await awaitFakeTimer(); + expect(baseElement.querySelector('.rc-trigger-popup-hidden')).toBeFalsy(); + + fireEvent.keyDown(window, { key: 'Escape' }); + await awaitFakeTimer(); + expect(baseElement.querySelector('.rc-trigger-popup-hidden')).toBeTruthy(); + }); }); diff --git a/tsconfig.json b/tsconfig.json index 6db6d940..c5605081 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, + "types": ["@testing-library/jest-dom", "node"], "paths": { "@/*": ["src/*"], "@@/*": [".dumi/tmp/*"],