Skip to content

Commit 57a27f7

Browse files
committed
feat: support close popups by escape key
1 parent 338a80f commit 57a27f7

File tree

3 files changed

+160
-0
lines changed

3 files changed

+160
-0
lines changed

src/hooks/useEscCancel.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import useEvent from '@rc-component/util/lib/hooks/useEvent';
2+
import * as React from 'react';
3+
import { getWin } from '../util';
4+
5+
interface EscEntry {
6+
id: string;
7+
win: Window;
8+
triggerOpen: (open: boolean) => void;
9+
}
10+
11+
const stackMap = new Map<Window, EscEntry[]>();
12+
const handlerMap = new Map<Window, (event: KeyboardEvent) => void>();
13+
14+
function addEscListener(win: Window) {
15+
if (handlerMap.has(win)) {
16+
return;
17+
}
18+
19+
const handler = (event: KeyboardEvent) => {
20+
if (event.key !== 'Escape') {
21+
return;
22+
}
23+
24+
const stack = stackMap.get(win);
25+
26+
const top = stack[stack.length - 1];
27+
top.triggerOpen(false);
28+
};
29+
30+
win.addEventListener('keydown', handler);
31+
handlerMap.set(win, handler);
32+
}
33+
34+
function removeEscListener(win: Window) {
35+
const handler = handlerMap.get(win);
36+
win.removeEventListener('keydown', handler);
37+
handlerMap.delete(win);
38+
}
39+
40+
function unregisterEscEntry(id: string, win: Window) {
41+
const stack = stackMap.get(win);
42+
if (!stack) {
43+
return;
44+
}
45+
46+
const next = stack.filter((item) => item.id !== id);
47+
48+
if (next.length) {
49+
stackMap.set(win, next);
50+
} else {
51+
stackMap.delete(win);
52+
removeEscListener(win);
53+
}
54+
}
55+
56+
function registerEscEntry(entry: EscEntry) {
57+
const { win, id } = entry;
58+
const prev = stackMap.get(win) || [];
59+
const next = prev.filter((item) => item.id !== id);
60+
next.push(entry);
61+
stackMap.set(win, next);
62+
addEscListener(win);
63+
}
64+
65+
export default function useEscCancel(
66+
popupId: string,
67+
open: boolean,
68+
popupEle: HTMLElement,
69+
triggerOpen: (open: boolean) => void,
70+
) {
71+
const memoTriggerOpen = useEvent((nextOpen: boolean) => {
72+
triggerOpen(nextOpen);
73+
});
74+
75+
React.useEffect(() => {
76+
if (!open || !popupEle) {
77+
return;
78+
}
79+
80+
const win = getWin(popupEle);
81+
registerEscEntry({
82+
id: popupId,
83+
win,
84+
triggerOpen: memoTriggerOpen,
85+
});
86+
87+
return () => unregisterEscEntry(popupId, win);
88+
}, [popupId, open, popupEle, memoTriggerOpen]);
89+
}

src/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ 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 useEscCancel from './hooks/useEscCancel';
1920
import type {
2021
ActionType,
2122
AlignType,
@@ -647,6 +648,8 @@ export function generateTrigger(
647648
triggerOpen,
648649
);
649650

651+
useEscCancel(id, mergedOpen, popupEle, triggerOpen);
652+
650653
// ======================= Action: Hover ========================
651654
const hoverToShow = showActions.has('hover');
652655
const hoverToHide = hideActions.has('hover');

tests/basic.test.jsx

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,4 +1200,72 @@ describe('Trigger.Basic', () => {
12001200
await awaitFakeTimer();
12011201
expect(isPopupHidden()).toBeTruthy();
12021202
});
1203+
1204+
describe('keyboard', () => {
1205+
it('esc should close popup', async () => {
1206+
const { container } = render(
1207+
<Trigger action="click" popup={<strong>trigger</strong>}>
1208+
<div className="target" />
1209+
</Trigger>,
1210+
);
1211+
1212+
trigger(container, '.target');
1213+
expect(isPopupHidden()).toBeFalsy();
1214+
1215+
fireEvent.keyDown(window, { key: 'Escape' });
1216+
await awaitFakeTimer();
1217+
expect(isPopupHidden()).toBeTruthy();
1218+
});
1219+
1220+
it('esc should close nested popup from inside out', async () => {
1221+
const useIdModule = require('@rc-component/util/lib/hooks/useId');
1222+
let seed = 0;
1223+
const useIdSpy = jest
1224+
.spyOn(useIdModule, 'default')
1225+
.mockImplementation(() => `nested-popup-${(seed += 1)}`);
1226+
1227+
try {
1228+
const NestedPopup = () => (
1229+
<Trigger
1230+
action="click"
1231+
popupClassName="inner-popup"
1232+
popup={<div>Inner Content</div>}
1233+
>
1234+
<button type="button" className="inner-target">
1235+
Inner Target
1236+
</button>
1237+
</Trigger>
1238+
);
1239+
1240+
const { container } = render(
1241+
<Trigger
1242+
action="click"
1243+
popupClassName="outer-popup"
1244+
popup={
1245+
<div className="outer-popup-content">
1246+
<NestedPopup />
1247+
</div>
1248+
}
1249+
>
1250+
<div className="outer-target" />
1251+
</Trigger>,
1252+
);
1253+
1254+
trigger(container, '.outer-target');
1255+
expect(isPopupClassHidden('.outer-popup')).toBeFalsy();
1256+
1257+
fireEvent.click(document.querySelector('.inner-target'));
1258+
expect(isPopupClassHidden('.inner-popup')).toBeFalsy();
1259+
1260+
fireEvent.keyDown(window, { key: 'Escape' });
1261+
expect(isPopupClassHidden('.inner-popup')).toBeTruthy();
1262+
expect(isPopupClassHidden('.outer-popup')).toBeFalsy();
1263+
1264+
fireEvent.keyDown(window, { key: 'Escape' });
1265+
expect(isPopupClassHidden('.outer-popup')).toBeTruthy();
1266+
} finally {
1267+
useIdSpy.mockRestore();
1268+
}
1269+
});
1270+
});
12031271
});

0 commit comments

Comments
 (0)