diff --git a/src/Popup/index.tsx b/src/Popup/index.tsx index e8a50f54..51cff879 100644 --- a/src/Popup/index.tsx +++ b/src/Popup/index.tsx @@ -14,6 +14,7 @@ import Mask from './Mask'; import PopupContent from './PopupContent'; import useOffsetStyle from '../hooks/useOffsetStyle'; import { useEvent } from '@rc-component/util'; +import type { PortalProps } from '@rc-component/portal'; export interface MobileConfig { mask?: boolean; @@ -24,6 +25,7 @@ export interface MobileConfig { } export interface PopupProps { + onEsc?: PortalProps['onEsc']; prefixCls: string; className?: string; style?: React.CSSProperties; @@ -87,6 +89,7 @@ export interface PopupProps { const Popup = React.forwardRef((props, ref) => { const { + onEsc, popup, className, prefixCls, @@ -234,6 +237,7 @@ const Popup = React.forwardRef((props, ref) => { open={forceRender || isNodeVisible} getContainer={getPopupContainer && (() => getPopupContainer(target))} autoDestroy={autoDestroy} + onEsc={onEsc} > void; /** Additional handle options data to do the customize info */ postTriggerProps?: (options: UniqueShowOptions) => UniqueShowOptions; } @@ -25,6 +26,7 @@ export interface UniqueProviderProps { const UniqueProvider = ({ children, postTriggerProps, + onKeyDown, }: UniqueProviderProps) => { const [trigger, open, options, onTargetVisibleChanged] = useTargetState(); @@ -91,6 +93,13 @@ const UniqueProvider = ({ onTargetVisibleChanged(visible); }); + const onEsc: PortalProps['onEsc'] = ({ top, event }) => { + if (top) { + trigger(false); + } + onKeyDown?.(event); + }; + // =========================== Align ============================ const [ ready, @@ -184,6 +193,7 @@ const UniqueProvider = ({ void; children: | React.ReactElement | ((info: { open: boolean }) => React.ReactElement); @@ -146,6 +149,7 @@ export function generateTrigger( const { prefixCls = 'rc-trigger-popup', children, + onKeyDown, // Action action = 'hover', @@ -419,6 +423,13 @@ export function generateTrigger( }, delay); }; + const onEsc: PortalProps['onEsc'] = ({ top, event }) => { + if (top) { + triggerOpen(false); + } + onKeyDown?.(event); + }; + // ========================== Motion ============================ const [inMotion, setInMotion] = React.useState(false); @@ -830,6 +841,7 @@ export function generateTrigger( forceRender={forceRender} autoDestroy={mergedAutoDestroy} getPopupContainer={getPopupContainer} + onEsc={onEsc} // Arrow align={alignInfo} arrow={innerArrow} diff --git a/tests/basic.test.jsx b/tests/basic.test.jsx index ed320816..c8c9a0fe 100644 --- a/tests/basic.test.jsx +++ b/tests/basic.test.jsx @@ -6,6 +6,11 @@ import ReactDOM, { createPortal } from 'react-dom'; import Trigger from '../src'; import { awaitFakeTimer, placementAlignMap } from './util'; +jest.mock('@rc-component/util/lib/hooks/useId', () => { + const origin = jest.requireActual('react'); + return origin.useId; +}); + describe('Trigger.Basic', () => { beforeAll(() => { spyElementPrototypes(HTMLElement, { @@ -1200,4 +1205,76 @@ 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 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(); + }); + }); }); 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/*"],