Skip to content

Commit aee09a3

Browse files
authored
feat: support close popups by escape key (#594)
* feat: support close popups by escape key * chore: adjust * adjust logic * mock useId * upgrade portal version * remove faketimer * chore: adjust * chore: adjust * lint fix * update deps * update deps
1 parent 38ac155 commit aee09a3

File tree

8 files changed

+110
-1
lines changed

8 files changed

+110
-1
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
},
4343
"dependencies": {
4444
"@rc-component/motion": "^1.1.4",
45-
"@rc-component/portal": "^2.0.0",
45+
"@rc-component/portal": "^2.2.0",
4646
"@rc-component/resize-observer": "^1.0.0",
4747
"@rc-component/util": "^1.2.1",
4848
"clsx": "^2.1.1"

src/Popup/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import Mask from './Mask';
1414
import PopupContent from './PopupContent';
1515
import useOffsetStyle from '../hooks/useOffsetStyle';
1616
import { useEvent } from '@rc-component/util';
17+
import type { PortalProps } from '@rc-component/portal';
1718

1819
export interface MobileConfig {
1920
mask?: boolean;
@@ -24,6 +25,7 @@ export interface MobileConfig {
2425
}
2526

2627
export interface PopupProps {
28+
onEsc?: PortalProps['onEsc'];
2729
prefixCls: string;
2830
className?: string;
2931
style?: React.CSSProperties;
@@ -87,6 +89,7 @@ export interface PopupProps {
8789

8890
const Popup = React.forwardRef<HTMLDivElement, PopupProps>((props, ref) => {
8991
const {
92+
onEsc,
9093
popup,
9194
className,
9295
prefixCls,
@@ -234,6 +237,7 @@ const Popup = React.forwardRef<HTMLDivElement, PopupProps>((props, ref) => {
234237
open={forceRender || isNodeVisible}
235238
getContainer={getPopupContainer && (() => getPopupContainer(target))}
236239
autoDestroy={autoDestroy}
240+
onEsc={onEsc}
237241
>
238242
<Mask
239243
prefixCls={prefixCls}

src/UniqueProvider/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ const UniqueProvider = ({
184184
<Popup
185185
ref={setPopupRef}
186186
portal={Portal}
187+
onEsc={mergedOptions.onEsc}
187188
prefixCls={prefixCls}
188189
popup={mergedOptions.popup}
189190
className={clsx(

src/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as React from 'react';
22
import type { CSSMotionProps } from '@rc-component/motion';
3+
import type { PortalProps } from '@rc-component/portal';
34
import type { TriggerProps } from './index';
45
import type { AlignType, ArrowTypeOuter, BuildInPlacements } from './interface';
56

@@ -34,6 +35,7 @@ export interface UniqueShowOptions {
3435
arrow?: ArrowTypeOuter;
3536
getPopupContainer?: TriggerProps['getPopupContainer'];
3637
getPopupClassNameFromAlign?: (align: AlignType) => string;
38+
onEsc?: PortalProps['onEsc'];
3739
}
3840

3941
export interface UniqueContextProps {

src/index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import useAlign from './hooks/useAlign';
1616
import useDelay from './hooks/useDelay';
1717
import useWatch from './hooks/useWatch';
1818
import useWinClick from './hooks/useWinClick';
19+
import type { PortalProps } from '@rc-component/portal';
20+
1921
import type {
2022
ActionType,
2123
AlignType,
@@ -347,6 +349,7 @@ export function generateTrigger(
347349
getPopupContainer,
348350
getPopupClassNameFromAlign,
349351
id,
352+
onEsc,
350353
}));
351354

352355
// Handle controlled state changes for UniqueProvider
@@ -419,6 +422,12 @@ export function generateTrigger(
419422
}, delay);
420423
};
421424

425+
function onEsc({ top }: Parameters<PortalProps['onEsc']>[0]) {
426+
if (top) {
427+
triggerOpen(false);
428+
}
429+
}
430+
422431
// ========================== Motion ============================
423432
const [inMotion, setInMotion] = React.useState(false);
424433

@@ -830,6 +839,7 @@ export function generateTrigger(
830839
forceRender={forceRender}
831840
autoDestroy={mergedAutoDestroy}
832841
getPopupContainer={getPopupContainer}
842+
onEsc={onEsc}
833843
// Arrow
834844
align={alignInfo}
835845
arrow={innerArrow}

tests/basic.test.jsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import ReactDOM, { createPortal } from 'react-dom';
66
import Trigger from '../src';
77
import { awaitFakeTimer, placementAlignMap } from './util';
88

9+
jest.mock('@rc-component/util/lib/hooks/useId', () => {
10+
const origin = jest.requireActual('react');
11+
return origin.useId;
12+
});
13+
914
describe('Trigger.Basic', () => {
1015
beforeAll(() => {
1116
spyElementPrototypes(HTMLElement, {
@@ -1200,4 +1205,75 @@ describe('Trigger.Basic', () => {
12001205
await awaitFakeTimer();
12011206
expect(isPopupHidden()).toBeTruthy();
12021207
});
1208+
1209+
describe('keyboard', () => {
1210+
it('esc should close popup', async () => {
1211+
const { container } = render(
1212+
<Trigger action="click" popup={<strong>trigger</strong>}>
1213+
<div className="target" />
1214+
</Trigger>,
1215+
);
1216+
1217+
trigger(container, '.target');
1218+
expect(isPopupHidden()).toBeFalsy();
1219+
1220+
fireEvent.keyDown(window, { key: 'Escape' });
1221+
expect(isPopupHidden()).toBeTruthy();
1222+
});
1223+
1224+
it('non-escape key should not close popup', async () => {
1225+
const { container } = render(
1226+
<Trigger action="click" popup={<strong>trigger</strong>}>
1227+
<div className="target" />
1228+
</Trigger>,
1229+
);
1230+
1231+
trigger(container, '.target');
1232+
expect(isPopupHidden()).toBeFalsy();
1233+
1234+
fireEvent.keyDown(window, { key: 'Enter' });
1235+
expect(isPopupHidden()).toBeFalsy();
1236+
});
1237+
1238+
it('esc should close nested popup from inside out', async () => {
1239+
const NestedPopup = () => (
1240+
<Trigger
1241+
action="click"
1242+
popupClassName="inner-popup"
1243+
popup={<div>Inner Content</div>}
1244+
>
1245+
<button type="button" className="inner-target">
1246+
Inner Target
1247+
</button>
1248+
</Trigger>
1249+
);
1250+
1251+
const { container } = render(
1252+
<Trigger
1253+
action="click"
1254+
popupClassName="outer-popup"
1255+
popup={
1256+
<div className="outer-popup-content">
1257+
<NestedPopup />
1258+
</div>
1259+
}
1260+
>
1261+
<div className="outer-target" />
1262+
</Trigger>,
1263+
);
1264+
1265+
trigger(container, '.outer-target');
1266+
expect(isPopupClassHidden('.outer-popup')).toBeFalsy();
1267+
1268+
fireEvent.click(document.querySelector('.inner-target'));
1269+
expect(isPopupClassHidden('.inner-popup')).toBeFalsy();
1270+
1271+
fireEvent.keyDown(window, { key: 'Escape' });
1272+
expect(isPopupClassHidden('.inner-popup')).toBeTruthy();
1273+
expect(isPopupClassHidden('.outer-popup')).toBeFalsy();
1274+
1275+
fireEvent.keyDown(window, { key: 'Escape' });
1276+
expect(isPopupClassHidden('.outer-popup')).toBeTruthy();
1277+
});
1278+
});
12031279
});

tests/unique.test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,4 +374,19 @@ describe('Trigger.Unique', () => {
374374
// Verify onAlign was called due to target change
375375
expect(mockOnAlign).toHaveBeenCalled();
376376
});
377+
378+
it('esc should close unique popup', async () => {
379+
const { container,baseElement } = render(
380+
<UniqueProvider>
381+
<Trigger action={['click']} popup={<div>Popup</div>} unique>
382+
<div className="target" />
383+
</Trigger>
384+
</UniqueProvider>,
385+
);
386+
fireEvent.click(container.querySelector('.target'));
387+
expect(baseElement.querySelector('.rc-trigger-popup-hidden')).toBeFalsy();
388+
389+
fireEvent.keyDown(window, { key: 'Escape' });
390+
expect(baseElement.querySelector('.rc-trigger-popup-hidden')).toBeTruthy();
391+
});
377392
});

tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"skipLibCheck": true,
99
"esModuleInterop": true,
1010
"allowSyntheticDefaultImports": true,
11+
"types": ["@testing-library/jest-dom", "node"],
1112
"paths": {
1213
"@/*": ["src/*"],
1314
"@@/*": [".dumi/tmp/*"],

0 commit comments

Comments
 (0)