Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/UniqueProvider/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -91,6 +92,8 @@ const UniqueProvider = ({
onTargetVisibleChanged(visible);
});

useEscKeyDown(mergedOptions?.id, open, popupEle, () => trigger(false));

// =========================== Align ============================
const [
ready,
Expand Down
89 changes: 89 additions & 0 deletions src/hooks/useEscKeyDown.ts
Original file line number Diff line number Diff line change
@@ -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<Window, EscEntry[]>();
const handlerMap = new Map<Window, (event: KeyboardEvent) => 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]);
}
3 changes: 3 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
Expand Down
82 changes: 82 additions & 0 deletions tests/basic.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -1200,4 +1200,86 @@ describe('Trigger.Basic', () => {
await awaitFakeTimer();
expect(isPopupHidden()).toBeTruthy();
});

describe('keyboard', () => {
it('esc should close popup', async () => {
const { container } = render(
<Trigger action="click" popup={<strong>trigger</strong>}>
<div className="target" />
</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 action="click" popup={<strong>trigger</strong>}>
<div className="target" />
</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 = () => (
<Trigger
action="click"
popupClassName="inner-popup"
popup={<div>Inner Content</div>}
>
<button type="button" className="inner-target">
Inner Target
</button>
</Trigger>
);

const { container } = render(
<Trigger
action="click"
popupClassName="outer-popup"
popup={
<div className="outer-popup-content">
<NestedPopup />
</div>
}
>
<div className="outer-target" />
</Trigger>,
);

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();
}
});
});
});
17 changes: 17 additions & 0 deletions tests/unique.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<UniqueProvider>
<Trigger action={['click']} popup={<div>Popup</div>} unique>
<div className="target" />
</Trigger>
</UniqueProvider>,
);
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();
});
});
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"types": ["@testing-library/jest-dom", "node"],
"paths": {
"@/*": ["src/*"],
"@@/*": [".dumi/tmp/*"],
Expand Down
Loading