diff --git a/.gitignore b/.gitignore index 7b52cf3..3e76d9a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ yarn.lock package-lock.json pnpm-lock.yaml .doc +.vscode # dumi .dumi/tmp diff --git a/docs/examples/basic.tsx b/docs/examples/basic.tsx index 3844a92..b1bb1ea 100644 --- a/docs/examples/basic.tsx +++ b/docs/examples/basic.tsx @@ -41,11 +41,17 @@ export default () => { /> - + { + console.log('root onEsc', { top, event }); + }}>

Hello Root

- + { + console.log('parent onEsc', { top, event }); + }}>

Hello Parent

- + { + console.log('children onEsc', { top, event }); + }}>

Hello Children

diff --git a/package.json b/package.json index beedbd1..7699adc 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "devDependencies": { "@rc-component/father-plugin": "^2.0.2", "@rc-component/np": "^1.0.3", - "@testing-library/jest-dom": "^5.16.4", + "@testing-library/jest-dom": "^6.9.0", "@testing-library/react": "^13.0.0", "@types/jest": "^26.0.20", "@types/node": "^20.9.0", diff --git a/script/update-content.js b/script/update-content.js deleted file mode 100644 index e9ed487..0000000 --- a/script/update-content.js +++ /dev/null @@ -1,34 +0,0 @@ -/* - 用于 dumi 改造使用, - 可用于将 examples 的文件批量修改为 demo 引入形式, - 其他项目根据具体情况使用。 -*/ - -const fs = require('fs'); -const glob = require('glob'); - -const paths = glob.sync('./docs/examples/*.tsx'); - -paths.forEach(path => { - const name = path.split('/').pop().split('.')[0]; - fs.writeFile( - `./docs/demo/${name}.md`, - `--- -title: ${name} -nav: - title: Demo - path: /demo ---- - - -`, - 'utf8', - function(error) { - if(error){ - console.log(error); - return false; - } - console.log(`${name} 更新成功~`); - } - ) -}); diff --git a/src/Portal.tsx b/src/Portal.tsx index 3678b74..c92dce0 100644 --- a/src/Portal.tsx +++ b/src/Portal.tsx @@ -11,6 +11,7 @@ import OrderContext from './Context'; import { inlineMock } from './mock'; import useDom from './useDom'; import useScrollLocker from './useScrollLocker'; +import useEscKeyDown from './useEscKeyDown'; export type ContainerType = Element | DocumentFragment; @@ -20,6 +21,14 @@ export type GetContainer = | (() => ContainerType) | false; +export type EscCallback = ({ + top, + event, +}: { + top: boolean; + event: KeyboardEvent; +}) => void; + export interface PortalProps { /** Customize container element. Default will create a div in document.body when `open` */ getContainer?: GetContainer; @@ -30,6 +39,7 @@ export interface PortalProps { autoDestroy?: boolean; /** Lock screen scroll when open */ autoLock?: boolean; + onEsc?: EscCallback; /** @private debug name. Do not use in prod */ debug?: string; @@ -61,6 +71,7 @@ const Portal = React.forwardRef((props, ref) => { debug, autoDestroy = true, children, + onEsc, } = props; const [shouldRender, setShouldRender] = React.useState(open); @@ -109,6 +120,9 @@ const Portal = React.forwardRef((props, ref) => { mergedContainer === document.body), ); + // ========================= Esc Keydown ========================== + useEscKeyDown(open, onEsc); + // =========================== Ref =========================== let childRef: React.Ref = null; diff --git a/src/useEscKeyDown.ts b/src/useEscKeyDown.ts new file mode 100644 index 0000000..79c022e --- /dev/null +++ b/src/useEscKeyDown.ts @@ -0,0 +1,37 @@ +import { useEffect, useMemo } from 'react'; +import { type EscCallback } from './Portal'; +import useId from '@rc-component/util/lib/hooks/useId'; +import { useEvent } from '@rc-component/util'; + +let stack: string[] = []; + +export default function useEscKeyDown(open: boolean, onEsc?: EscCallback) { + const id = useId(); + + const handleEscKeyDown = useEvent((event: KeyboardEvent) => { + if (event.key === 'Escape') { + const top = stack[stack.length - 1] === id; + onEsc?.({ top, event }); + } + }); + + useMemo(() => { + if (open && !stack.includes(id)) { + stack.push(id); + } else if (!open) { + stack = stack.filter(item => item !== id); + } + }, [open, id]); + + useEffect(() => { + if (!open) { + return; + } + + window.addEventListener('keydown', handleEscKeyDown); + + return () => { + window.removeEventListener('keydown', handleEscKeyDown); + }; + }, [open, id]); +} diff --git a/tests/index.test.tsx b/tests/index.test.tsx index e8d77fb..72fa6a1 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; import React from 'react'; import Portal from '../src'; @@ -18,6 +18,11 @@ jest.mock('@rc-component/util/lib/hooks/useLayoutEffect', () => { return origin.useLayoutEffect; }); +jest.mock('@rc-component/util/lib/hooks/useId', () => { + const origin = jest.requireActual('react'); + return origin.useId; +}); + describe('Portal', () => { beforeEach(() => { global.isOverflow = true; @@ -290,4 +295,65 @@ describe('Portal', () => { expect(document.querySelector('.checker').textContent).toEqual('true'); }); + + describe('onEsc', () => { + it('only last opened portal is top', () => { + const onEscA = jest.fn(); + const onEscB = jest.fn(); + + render( + <> + +
+ + +
+ + , + ); + + fireEvent.keyDown(window, { key: 'Escape' }); + + expect(onEscA).toHaveBeenCalledWith( + expect.objectContaining({ top: false }), + ); + expect(onEscB).toHaveBeenCalledWith( + expect.objectContaining({ top: true }), + ); + }); + + it('top changes after portal closes', () => { + const onEscA = jest.fn(); + const onEscB = jest.fn(); + + const { rerender } = render( + <> + +
+ + +
+ + , + ); + + rerender( + <> + +
+ + +
+ + , + ); + + fireEvent.keyDown(window, { key: 'Escape' }); + + expect(onEscA).toHaveBeenCalledWith( + expect.objectContaining({ top: true }), + ); + expect(onEscB).not.toHaveBeenCalled(); + }); + }); });