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/useEscKeyDown.ts b/src/hooks/useEscKeyDown.ts new file mode 100644 index 00000000..5e6fe767 --- /dev/null +++ b/src/hooks/useEscKeyDown.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 useEscKeyDown( + popupId: string, + open: boolean, + popupEle: HTMLElement, + triggerOpen: (open: boolean) => void, +) { + const memoTriggerOpen = useEvent((nextOpen: boolean) => { + triggerOpen(nextOpen); + }); + + React.useEffect(() => { + if (!popupId || !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..9f7f5cfc 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 useEscKeyDown from './hooks/useEscKeyDown'; import type { ActionType, AlignType, @@ -647,6 +648,8 @@ export function generateTrigger( triggerOpen, ); + useEscKeyDown(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..afb84031 100644 --- a/tests/basic.test.jsx +++ b/tests/basic.test.jsx @@ -1200,4 +1200,86 @@ 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('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; + 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(); + } + }); + }); }); 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/*"],