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..0d6d1538 100644 --- a/tests/basic.test.jsx +++ b/tests/basic.test.jsx @@ -1200,4 +1200,97 @@ describe('Trigger.Basic', () => { await awaitFakeTimer(); expect(isPopupHidden()).toBeTruthy(); }); + + describe('keyboard', () => { + const useIdModule = require('@rc-component/util/lib/hooks/useId'); + let useIdSpy; + let uuid = 0; + + beforeEach(() => { + useIdSpy = jest.spyOn(useIdModule, 'default').mockImplementation(() => { + const idRef = React.useRef(); + + if (!idRef.current) { + uuid += 1; + idRef.current = `test-id-${uuid}`; + } + + return idRef.current; + }); + }); + + afterEach(() => { + useIdSpy.mockRestore(); + }); + + 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/*"],